| title | Planogram Optimization | |||||
|---|---|---|---|---|---|---|
| description | Decide integer facing counts per SKU to maximize predicted weekly demand under shelf capacity and category cardinality limits, where per-(SKU, facing_count) demand comes from a regression model. | |||||
| featured | false | |||||
| private | false | |||||
| experience_level | intermediate | |||||
| industry | Retail & Consumer | |||||
| reasoning_types |
|
|||||
| tags |
|
Retailers running grocery, mass-merchandise, drug, and CPG categories decide how many facings (front-row product positions) to allocate per SKU on each shelf. More facings of a hot product lift its weekly sales; more facings of a slow mover wastes shelf length that could carry a faster competitor. The right answer differs by category, by SKU within a category, and by the diminishing-returns curve each SKU exhibits as you give it more space.
This template uses Predictive + Prescriptive reasoning: it solves the per-shelf facing-count allocation as a constraint satisfaction model where the per-(SKU, facing_count) expected weekly demand is a predictive output. The hand-off from the predictive arm into the CSP is the canonical element-style decision-indexed table lookup: the CSP picks an integer Sku.facings, and Sku.realized_demand is bound -- via an implies cascade -- to the PredictedDemand.demand_units row whose facings_count matches the chosen value. No bilinearity, no big-M, no SOS2.
The same pattern applies to any predict-then-optimize problem where the prediction is a per-decision-value lookup table: workforce demand by staffing level, conversion rate by ad-spend bucket, throughput by line speed.
Important
The bundled runner ships with a vendored predicted_demand_table.csv -- a stand-in for the output of any per-(SKU, k) demand regressor -- so the predict->CSP hand-off pattern can be inspected and run without a training arm. Wiring in a real regressor is documented under "Customize this template"; the CSP shape is unchanged.
- Retail and CPG analysts allocating shelf space across SKUs and categories
- Operations researchers exploring the predict-then-optimize pattern with decision-indexed table lookups
- Data scientists wiring regression-based demand prediction into integer optimization
- Software developers seeking a clean canonical example of predictive output as a decision-indexed lookup
- A constraint model with one integer decision per SKU (
facingsin[0, max_facings]) plus two derived integer variables pinned by ICs (realized_demandvia the lookup,activevia a linear-inequality coupling) - An element-style decision-indexed table lookup binding
realized_demandto the matchingPredictedDemandrow via animpliescascade - Shelf-length capacity (
sum(Sku.facings * Sku.width_cm).per(Shelf) <= Shelf.length_cm) and per-category cardinality (sum(Sku.active).per(Category)in[min_skus_active, max_skus_active]) - A linear
sum(realized_demand)objective the CSP maximizes - Post-solve verification via
problem.verify()for the capacity, cardinality, and active-coupling ICs, plus a Python dict lookup confirming(sku_id, facings) -> demand_unitsmatches the input table
planogram_optimization.py-- main script with ontology, decisions, constraints, and solver call- Runbook:
runbook.md-- a paste-testable walkthrough that reproduces the template step by step with the RAI skills; as important a reference as the script itself. data/skus.csv-- 18 SKUs across 4 categories (snacks, beverages, candy, household_paper) with width, max-facings, and pre-assigned shelf;sku_idmust be unique,categorymust match acategories.csvname, andassigned_shelf_idmust reference ashelves.csvid. Thebrandcolumn is loaded but not used by the bundled CSP -- it is reserved for the brand-block contiguity customization described under "Customize this template"data/shelves.csv-- 4 shelves (Top Eye-Level 100cm, Upper-Middle 80cm, Lower-Middle 80cm, Bottom 90cm);idmust be unique and is the foreign-key target ofskus.assigned_shelf_iddata/categories.csv-- per-category min/max active SKU bounds;namemust be unique and is the foreign-key target ofskus.categorydata/predicted_demand_table.csv-- vendored regression output: one row per(sku_id, facings_count)fork in {0, 1, ..., sku.max_facings}with concave per-SKU demand curves; thek=0row isdemand_units=0so the lookup is total over the decision domainpyproject.toml-- Python package configuration
- A Snowflake account that has the RAI Native App installed.
- A Snowflake user with permissions to access the RAI Native App.
- Python >= 3.10
- RelationalAI Python SDK (
relationalai == 1.1.0)
-
Download ZIP:
curl -O https://docs.relational.ai/templates/zips/v1/planogram_optimization.zip unzip planogram_optimization.zip cd planogram_optimization[!TIP] You can also download the template ZIP using the "Download ZIP" button at the top of this page.
-
Create venv:
python -m venv .venv source .venv/bin/activate python -m pip install --upgrade pip -
Install:
python -m pip install . -
Configure:
rai init
-
Run:
python planogram_optimization.py
-
Expected output (the solver maximizes total predicted weekly demand; the exact selection may vary across solver versions if multiple allocations achieve the same optimal objective). The script first prints the formulation (~30 lines, omitted here for brevity), then the solve-result block, then the per-SKU allocation, shelf utilization, and category active counts:
Solve result: • status: OPTIMAL • objective: 1656 • solve time: 1.4s • num_points: 1 • solver: MiniZinc_unknown Optimal facings per SKU: id name category shelf facings realized_demand 0 1 Cookie Box snacks Top Eye-Level Shelf 2 82 1 2 Granola Bar snacks Top Eye-Level Shelf 3 147 2 3 Trail Mix snacks Top Eye-Level Shelf 2 66 3 4 Tortilla Chips snacks Upper-Middle Shelf 2 140 4 5 Popcorn Bag snacks Upper-Middle Shelf 3 94 5 6 Cola 12oz beverages Lower-Middle Shelf 4 212 6 7 Lemon Soda 12oz beverages Lower-Middle Shelf 3 136 7 8 Sparkling Water beverages Lower-Middle Shelf 2 58 8 9 Bottled Water beverages Lower-Middle Shelf 3 116 9 10 Energy Drink beverages Upper-Middle Shelf 3 158 10 11 Gummy Pack candy Top Eye-Level Shelf 3 74 11 12 Chocolate Bar candy Top Eye-Level Shelf 3 116 12 13 Mint Roll candy Bottom Shelf 0 0 13 14 Caramel Squares candy Bottom Shelf 2 50 14 15 Paper Towels 6pk household_paper Bottom Shelf 0 0 15 16 Toilet Tissue 4pk household_paper Bottom Shelf 2 99 16 17 Facial Tissue Box household_paper Bottom Shelf 2 58 17 18 Napkins 200ct household_paper Bottom Shelf 2 50 Shelf utilization: id name used_cm length_cm 0 1 Top Eye-Level Shelf 98 100 1 2 Upper-Middle Shelf 79 80 2 3 Lower-Middle Shelf 77 80 3 4 Bottom Shelf 90 90 Category active counts: name active_skus min_skus_active max_skus_active 0 beverages 5 3 5 1 candy 3 2 3 2 household_paper 3 2 3 3 snacks 5 3 5MiniZinc_unknownis the version string MiniZinc reports for itself today; the underlying solver binary is selected by the RAI Native App. Total weekly demand of1656units, 16 of 18 SKUs active. Two SKUs go inactive because the candy and household_paper categories each have 4 SKUs butmax_skus_active=3: Mint Roll loses the candy cap (lowest predicted demand at every k), Paper Towels 6pk loses the household_paper cap (lowest predicted demand-per-cm). The bottom shelf is fully binding (90/90cm); the other three are within 1-3cm of capacity.
.
├── README.md
├── pyproject.toml
├── planogram_optimization.py
└── data/
├── skus.csv
├── shelves.csv
├── categories.csv
└── predicted_demand_table.csv
Start here: run python planogram_optimization.py for the full predict-then-optimize run end to end — it validates the data, binds each SKU's demand from the prediction table, solves the facing-count CSP, and prints the allocation — or follow runbook.md to reproduce it step by step with the RAI skills.
The bundled CSVs are illustrative, fully synthetic demo data sized so the model runs in seconds. The predicted_demand_table.csv is a vendored stand-in for the output of any per-(SKU, facings-count) demand regressor, so the predict-to-CSP hand-off can be inspected and run without a training arm. A pre-solve pass validates unique keys, category and shelf foreign keys, and that the demand table is complete over every SKU's [0, max_facings] range with demand_units = 0 at facings_count = 0.
skus.csv(18 rows) — SKUs across 4 categories (snacks, beverages, candy, household_paper) withwidth_cm,max_facings,category, and a pre-assignedassigned_shelf_id. Thebrandcolumn is loaded but unused by the bundled CSP (reserved for the brand-block-contiguity customization).shelves.csv(4 rows) — shelves with fixed lengths (Top Eye-Level 100cm, Upper-Middle 80cm, Lower-Middle 80cm, Bottom 90cm);shelf_idis the foreign-key target ofskus.assigned_shelf_id.categories.csv(4 rows) — per-category minimum and maximum active-SKU bounds;categoryis the foreign-key target ofskus.category.predicted_demand_table.csv— one row per(sku_id, facings_count)for everyfacings_countin{0, 1, ..., sku.max_facings}, with concave per-SKU demand curves; thefacings_count = 0row isdemand_units = 0.
The model has four concepts. The predictive output (PredictedDemand) is a data table that the CSP reads through a decision-indexed lookup; the decision lives on Sku.
- Key entities:
Sku— a product with a width, max-facings cap, category, and assigned shelf (its facing count is the CSP decision);Shelf— a shelf with a fixed length that facings must fit within;Category— a product category with minimum and maximum active-SKU bounds;PredictedDemand— the predicted demand for each(sku, facings_count), read by the CSP through a decision-indexed lookup. - Primary identifiers: integer
idonSkuandShelf; stringnameonCategory; composite(sku_id, facings_count)onPredictedDemand. - Important invariants:
width_cm,max_facings, andlength_cmare non-negative integers; everySku.categorymatches aCategory, and everySku.assigned_shelf_idmatches aShelf;PredictedDemandcovers every(sku_id, facings_count)over[0, max_facings]withdemand_units = 0atfacings_count = 0; the CSP decisionsfacings,realized_demand, andactiveare integers (activeis 0/1).
For the full concept and property definitions, see planogram_optimization.py; runbook.md builds them step by step with the RAI skills.
The CSP picks integer facings per SKU; the predictive output binds each SKU's realized demand via a decision-indexed table lookup, and the solver maximizes total predicted demand under shelf and category limits.
skus + shelves + categories + predicted-demand table → facings / realized_demand / active decisions → lookup + capacity + cardinality constraints → maximize demand → solve → verify
The decision-indexed table lookup is the predict-to-CSP hand-off. For each SKU, realized_demand must equal the PredictedDemand row whose facings_count matches the chosen facings. Because equating a data property with a decision variable inside a where binding isn't a form the prescriptive rewriter lowers today, the lookup is written with an implies cascade — one half-reified equality per candidate facing count, of which only the row matching the chosen facings activates. This is the element-style lookup: no bilinearity, no big-M, no SOS2.
An active indicator couples to facings via two linear inequalities. The per-category cardinality is a relational sum over a 0/1 active variable (a sum over a Boolean expression like facings >= 1 is not a valid form). Two linear constraints pin active = 1 whenever facings >= 1 and active = 0 otherwise, after which the per-category count reads as a plain sum(active).per(Category) with min/max bounds. Shelf capacity is likewise a plain relational inequality: facings times width, summed per shelf, cannot exceed the shelf length.
Verification is split by constraint kind. The implies-bodied lookup is solver-only — it goes to the solver but is not passed to verify(), which would return silently OK without evaluating it; instead a post-solve Python dict re-checks each (sku_id, facings) -> demand_units against the input table. The pure relational-arithmetic constraints (capacity, cardinality, active coupling) are re-evaluated by verify(), and a post-solve assertion confirms the solver reached OPTIMAL.
For the exact PyRel formulation, see planogram_optimization.py; runbook.md reproduces the pipeline step by step with the RAI skills.
- Replace the four CSV files with your SKUs, shelves, categories, and predicted demand table. The constraint structure does not change. The data invariant:
predicted_demand_table.csvmust contain a row for every(sku_id, k)fork in {0, 1, ..., sku.max_facings}— if a row is missing, the implies cascade leavesSku.realized_demandunconstrained for that combination and the solver may pick an arbitrary value. - Wire in a real demand model. In production,
PredictedDemandis the output of any model that predicts weekly demand at each candidate facing count — linear or GBM (gradient-boosted machine) regression over engineered features, a per-tier head, or a graph-aware regressor over(sku, week, facings_count, units_sold)history. The structural requirement is thatfacings_countis a feature (or there is a head per tier) so the model can be queried at everyk. Producedata/predicted_demand_table.csvin the formatsku_id,facings_count,demand_units(integer demand) from your inference pipeline and drop it in — the script picks it up unchanged. To call inference inline, replace theread_csv(DATA_DIR / "predicted_demand_table.csv")line with code that returns a DataFrame with those three columns.
- Per-SKU minimum facings — tighten the lower-coupling IC to
model.require(Sku.facings >= Sku.min_facings * Sku.active)after adding aSku.min_facingsdata property. (Removing disallowed(sku, k)rows frompredicted_demand_table.csvwould trip_assert_demand_table_complete; the lookup formulation requires the table to cover the full[0, max_facings]range.) - Category cardinality — adjust
min_skus_active/max_skus_activeincategories.csvto change how many SKUs each category may keep active.
- Eye-level priority is a constraint over the static shelf assignment in the bundled model — it validates that every premium SKU's
assigned_shelf_idalready points to the eye-level shelf and fails otherwise. Reassigning premium SKUs at solve time requires the multi-shelf reformulation below.Sku.is_premium = model.Property(f"{Sku} has {Integer:is_premium}") # ... populate from a column on skus.csv ... EYE_LEVEL_SHELF_ID = 1 # Top Eye-Level Shelf eye_level_ic = model.require( implies(Sku.is_premium == 1, Sku.shelf.id == EYE_LEVEL_SHELF_ID) ) problem.satisfy(eye_level_ic)
- Multi-shelf reassignment changes the shape of the model meaningfully.
solve_foraccepts onlyint/cont/bin, not relationship-valued domains, so shelf assignment cannot be aSku.shelfdecision directly. The same decision-vs-data-equality limitation that drives theimplies-cascade table lookup also blocksmodel.define(Sku.shelf(Shelf)).where(Shelf.id == Sku.shelf_id)whenSku.shelf_idis a decision variable. The principled rewrite introduces a per-(SKU, Shelf)integer facing decision (e.g.SkuShelf.facings), usessum(SkuShelf.facings).per(Sku)as the SKU's total facings, and writes capacity assum(SkuShelf.facings * Sku.width_cm).per(Shelf) <= Shelf.length_cm. This is more involved than the bundled CSP and is left as an exercise. - Brand-block contiguity — same-brand SKUs must occupy adjacent facings on the shelf — requires a per-shelf SKU position decision variable with adjacency constraints (more involved; the basic facing-count CSP above is unchanged but augmented).
- For a live catalog, replace the
read_csv(...)loads withmodel.data(snowflake_table)calls so the SKUs, shelves, categories, and predicted-demand table read directly from your warehouse; the CSP is unchanged. - The vendored
predicted_demand_table.csvbecomes a materialized inference output on a schedule — refresh it from your demand model, and the pre-solve completeness guard (_assert_demand_table_complete) gates each run. - The bundled data is 18 SKUs across 4 shelves; the CSP scales to whatever the constraint solver's budget allows. Raise
time_limit_secin theproblem.solve(...)call for larger catalogs. - Pin
relationalai(see Prerequisites) so runs stay reproducible across environments.
Solver returns INFEASIBLE
- The category cardinality bounds may be inconsistent with the SKU pool. If
sum(min_skus_active)exceeds the number of SKUs whose categories appear in the data, no allocation can satisfy every category's minimum. Check that each category'smin_skus_activeis<=the number of SKUs in that category. - Shelf-capacity binding can starve a category. If the SKUs in a category all share a small shelf and their widths exceed the shelf's
length_cmeven atfacings = 1, the category'smin_skus_activeis unreachable. Inspectdata/skus.csvagainstdata/shelves.csv. - Missing rows in
predicted_demand_table.csvfor some(sku, k)combination cause the lookup to leaverealized_demandunconstrained for that pair. The solver is free to pick a feasible value, but the resulting solution does not reflect real demand. Confirm that every SKU has rows fork in {0, 1, ..., sku.max_facings}.
Import error or AttributeError on relationalai
- Confirm your virtual environment is active:
which pythonshould point to.venv. - Reinstall dependencies:
python -m pip install .. The pinned version (relationalai==1.1.0) ships thesolve_info(),verify(), andwhere().require()APIs this template uses; older versions lack them and produce attribute errors. - If you share a venv across templates, run
python -m pip install --upgrade --force-reinstall relationalai==1.1.0.
FileNotFoundError on a CSV
- The script resolves data paths as
Path(__file__).parent / "data". Runpython planogram_optimization.pyfrom the unzipped template root, not from a parent directory. - Confirm
data/containsskus.csv,shelves.csv,categories.csv, andpredicted_demand_table.csv.
Authentication or configuration errors
- Run
rai initto create or update your RelationalAI/Snowflake configuration. - If you have multiple profiles, set
export RAI_PROFILE=<your_profile>.
MiniZinc solver not available
- This template uses the MiniZinc constraint solver. Ensure the RAI Native App version supports MiniZinc.
- MiniZinc is the right backend for the current PyRel formulation: the
implies-bodied table lookup lowers to half-reified linear equalities the constraint backend handles natively. A reformulation that one-hot encodes(sku_id, k)and writes the lookup as a linear sum could run on HiGHS, but it adds O(N * max_facings) auxiliary binaries and loses the pure-CSP "no big-M, no SOS2" framing this template is meant to demonstrate.
The optimal allocation differs slightly between runs
- The solver is free to return any allocation that achieves the optimal objective. If multiple allocations tie, different solver versions may pick different ones.
- To pin a single answer, add a tie-breaker to the objective -- e.g.
problem.maximize(sum(Sku.realized_demand) * 1000 - sum(Sku.facings))prefers fewer facings among equal-demand allocations.
- Predict-then-optimize patterns — feeding a predictive model's output into a prescriptive solve on one ontology.
- Prescriptive reasoner — the
ProblemAPI: integer decisions (solve_for), constraints (satisfy), objective (maximize), andverify.
- Concepts and properties — modeling
Sku,Shelf,Category, and the composite-keyedPredictedDemand. - Integrity constraints and
implies— half-reified linear equalities behind the decision-indexed table lookup.
- Constraint-solver selection (MiniZinc) — why a pure CSP formulation avoids big-M and SOS2, and when to reformulate for a linear backend.
- File issues at the RelationalAI templates repository.