Scoring a Shelf-Price Grid: Margin, Shortfall, and Competitive Anchors
Part 1 said I do not predict a sticker. Part 2 built the demand curve. This post is the commercial layer: how I turn that curve into a scored grid without burying policy inside LightGBM leaves.
One sentence: price optimization machine learning here means score a small board-printable grid, maximise contribution after shortfall and competitive-anchor penalties, then refuse the move unless both a dollar and a percent gate clear versus today’s shelf price.
Examples use Harbourline Mart — a fictional multi-store grocer I made up for this series, not a real company. Engineers get the objective and gate. Managers get one scoreboard: can we show why $3.44 beat $3.49 and $3.39, and why we sometimes still hold?
Read first if you skipped them: Shelf Price Decision Systems: Demand, Grid, and Gate and Multi-Store Demand Models. Part 4 will ship the Azure jobs. Here we stay in the optimiser.
What price optimization machine learning actually owns
Analogy: a weekly specials sticker is a highway toll. Higher price, fewer units. You still promised a day’s volume to the category. And you do not want to look greedy versus the store across the car park. The demand model estimates traffic at each toll. The optimiser is the policy that picks a toll you can defend in a Monday stand-up.
What this layer owns:
- A discrete candidate set ops can actually print (5¢ steps, not $3.173)
- A margin optimisation objective you can write on a whiteboard
- Penalties for volume shortfall and for wandering off a rival band
- A dual change gate so noise does not reprint the board
What it does not own: training LightGBM, inventing COGS, or auto-printing stickers. Humans still own the board. Software owns the evidence pack.
Searchers often type dynamic pricing machine learning. I use that phrase with stakeholders when it helps them find the work. Under the hood I still mean a price decision system — not a model that twitches every SKU every run.

Build a board-printable grid, not a continuous solver
Around today’s sticker p₀ (and sometimes a promo anchor), I build a small step grid. Grocery shelf guns print nickels. A continuous optimiser that invents $4.173 is a demo, not an operations contract.
| Choice | What I do | Smell |
|---|---|---|
| Step size | 5¢ (or the smallest board increment for that category) | Penny-level noise no one can execute |
| Width | ± a few steps around p₀, plus hard floors/ceilings from merchandising | Sweep $1–$20 “to be thorough” |
| Always include p₀ | The hold candidate is on the grid | Optimiser cannot say “do nothing” |
| Illegal prices | Drop below COGS+floor, above MAP-style caps, off-tick sizes | Let the score “discover” a loss leader |
For each remaining p I ask the slice’s demand bundle for û(p), as in part 2: set own-price fields to p, predict units, clamp negatives to a small epsilon. The curve should already be monotone in own-price. If a tail still wiggles, I apply a cheap isotonic smooth on the grid points before scoring — never a second booster “to fix physics.”
The margin optimisation objective
Contribution first. Unit COGS is an input, not a learned trick.
# Pseudocode — score one candidate (shape only)
contrib(p) = (p - unit_cogs) * u_hat(p)
shortfall(p) = lambda_s * max(0, baseline_units - u_hat(p))
# baseline_units = weekday × margin-band table from part 2
anchor(p) = lambda_a * max(0, abs(p - rival_anchor) - band) ** 2
# rival_anchor = distance-weighted rival shelf + historical differential
score(p) = contrib(p) - shortfall(p) - anchor(p)
p_star = argmax(score(p) for p in grid)
λ_s and λ_a are policy. They are not hyperparameters you grid-search to inflate a notebook metric. Category leads set them; the job logs them on every run. If you cannot name who owns λ, you do not have a margin optimisation objective — you have a fudge factor.
| Term | What it pays for | What it must not do |
|---|---|---|
| Contribution | Margin dollars at predicted units | Ignore volume; “price up always wins” |
| Shortfall penalty | Keep a weekday × band volume promise | Replace the demand model |
| Anchor penalty | Competitive pricing strategy as a band, not a copy | Force match of the cheapest rival every day |
Shortfall is a promise, not a second demand model
Part 2 already built a weekday × margin-band baseline of expected units. I treat that table as a floor the category signed up for — house-brand milk on a Thursday in a mid-margin band should not collapse because the optimiser liked a fatter sticker.
If û(p) stays above baseline, shortfall is zero. If it undershoots, I charge λ_s dollars per missing unit. That is deliberately linear and boring. Quadratic shortfall looks clever until one noisy day nukes a whole aisle.
I do not put the baseline inside LightGBM. The booster predicts absolute units. The promise lives in the optimiser so a merchandiser can change “we will not go below 80% of Thursday mid-band” without retraining 400 slices.
Competitive pricing strategy: band, not photocopy
Rival features already conditioned û(p) in part 2. The anchor penalty is a different layer: after the curve is scored, punish candidate stickers that look insane versus a distance-weighted rival plus the store’s historical differential.
That is the competitive pricing strategy I will defend: stay inside a band, do not clone the chain across the car park every morning. Cloning transfers your margin to whoever scraped first. A band says “we can sit a few cents above that rival because we always have, and shoppers still come” — the toy below uses ±5¢ around a $3.45 anchor, not a photocopy.
| Score-time assumption | When I use it | Risk |
|---|---|---|
| Rivals hold still | Default daily score | Optimistic if they always match us within a day |
| Simple response | Optional second pass: move rival_anchor part-way toward p | Hidden inside the booster = unauditable |
If I run the response pass, I store both curves. The evidence pack must say which assumption produced p*. I will not let a “they will follow us” story live only in a Slack thread.
The dual change gate ($ AND %)
Winning the grid is not permission to reprint. I compare score(p*) to score(p₀).

- Dollar leg: score(p*) − score(p₀) ≥ $min (example: $8 contribution-equivalent per store×SKU×day — pick a number merchandising believes)
- Percent leg: that lift ÷ |score(p₀)| ≥ %min (example: 2%)
- Both must pass. Fail either → HOLD, keep p₀, write reason codes
- Freshness fail (stale COGS, missing rivals, missing bundle) short-circuits to HOLD before the gate
Why both legs? A $12 lift on a $400-contribution SKU can be noise. A 6% lift on a $1.50 SKU can be a rounding error. OR-gating looks busy on dashboards. AND-gating is how you keep store teams from peeling stickers twice a day.
If p* equals p₀, skip the theatre: you are already holding. Still write the candidate table so a human can see the runner-up.
A tiny Harbourline Mart scoreboard
Fictional West store, house-brand milk family, p₀ = $3.49, unit COGS $2.40, rival anchor $3.45, band ±5¢ (legal stickers $3.40–$3.50 before other floors). Numbers are teaching shape, not a live retailer. $3.39 and $3.64 sit outside that band; $3.44 and p₀ sit inside.
| p | û units | contrib | shortfall | anchor | score |
|---|---|---|---|---|---|
| $3.39 | 118 | 117 | 0 | 4 | 113 |
| $3.44 | 116 | 121 | 0 | 0 | 121 ← p* |
| $3.49 (p₀) | 108 | 118 | 0 | 0 | 118 |
| $3.64 | 88 | 109 | 14 | 9 | 86 |
Check the arithmetic: contribution ≈ (p − $2.40) × û. $3.44 earns a bit more stack than p₀ because units recover more than the nickel of margin it gives up, and it stays inside the rival band. $3.39 wins traffic but pays an anchor penalty for looking cheap. $3.64 dies on shortfall plus greedy-vs-rival. The table is allowed to pick a move or say hold — if your demo always moves, you tuned λ until the gate was theatre.
p* is $3.44. Lift versus p₀ is about $3 of score. Dollar leg: $3 fails $min = $8. Percent leg: 3/118 ≈ 2.5% would pass a 2% %min. AND still HOLD, reason gate, keep $3.49, still persist the four-row pack. Winning the grid is not permission to reprint.
Scoreboard for engineers and managers
| Question | Healthy answer | Smell |
|---|---|---|
| What did we maximise? | score = contrib − shortfall − anchor | “The model’s optimal price” |
| Who owns λ? | Named merchandising policy, logged | Data scientist “tuned it” |
| Can we hold? | Yes — p₀ on grid + dual AND gate | Every SKU moves every run |
| Rival logic? | Features condition demand; penalty is the band | Clone cheapest competitor |
| Audit pack? | Top N + breakdown + gate legs + reason | SHAP dump, no commercial story |
Same boundary habit as AI architecture that holds in production and decisions that survive security review: separate “the booster ran” from “we made a defensible choice.” When you later wire this into Azure jobs, keep the optimiser a pure function over artifacts — the same instinct as config-driven integration connectors, just with a grid instead of a queue.
Where this sits in the series
- Part 1 — decide with a grid, not a price-regressor: hub post
- Part 2 — demand forecasting model, features, LightGBM slices
- Part 3 (this post) — grid, objective, penalties, dual gate
- Part 4 — Azure ML jobs, storage handoffs, production gaps
FAQ
Is this price optimization machine learning or just rules?
Both, on purpose. The ML piece is the demand curve û(p). The optimiser is explicit arithmetic plus policy weights. If you collapse them into one “optimal price” model, you cannot change a volume promise without retraining.
Why not a MIP / Bayesian optimiser over a continuous price?
Shelf guns print ticks. A five-to-nine point grid is enumerable, auditable, and fast per store×SKU. Continuous solvers invent prices nobody can set and hide the trade-off in solver logs.
How is this different from dynamic pricing machine learning demos?
Those often emit a sticker every cycle. I emit a ranked grid and a hold/apply decision. Dynamic without a dual gate is automated twitching.
Why AND on the change gate, not OR?
Dollar-only moves cheap SKUs for pennies that look large in percent. Percent-only moves expensive SKUs for lifts that do not pay the sticker labour. Both legs, or hold.
Do we copy competitor prices?
No. That is a bad competitive pricing strategy. Condition demand on rival features, then penalise leaving a historical band. Copying transfers margin to whoever posted first.
Where do λ weights live?
In config owned by merchandising, versioned and logged on the decision row — not inside booster leaves, not in a notebook cell.
What does “do nothing” mean operationally?
Keep p₀ on the board, persist the candidate table and reason code (freshness, gate, equal, or a penalty tag such as anchor / shortfall on the losing rows). apply is the only reason that reprints. Store tools still show runner-ups. No silent skip.
Will part 4 change this math?
No. Part 4 is jobs, storage, and quality gates around this function. If Azure ML cannot call a pure score(grid) you can unit-test, the platform is in the way.
Part 3 of the Price Decision Systems series (grocery). Harbourline Mart is a fictional brand used only as a teaching example. Next in this lane: Azure ML jobs, storage handoffs, and the production gaps I would fix first — after the interleaved Azure integration post.