You Modelled One P

A marketing mix model earns its keep the same way for every media channel. It asks how much of the outcome the channel actually caused, which is a harder question than how much the channel happened to move alongside it. This post asks that question of price, because this framework already treats price as more than an ordinary control. It hands back a single number, price_elasticity, meant to describe how sales respond when a retailer changes price. That is the pun in the title. One P is being asked to answer two different questions at once. The first is descriptive. Given everything else in the model, how does sales move with price in this dataset? The model answers that one well. The second is causal. If the client cut price next month, how much would sales actually move? Nothing about how the number is built guarantees that the two questions share an answer. The framework does already refuse one specific bad answer, because it is built so a fitted elasticity can never say a price increase raised demand. Sign is only half of it. Whether the size of the number tells a pricing decision what it needs to know is the question this post is after, and in a simulation built to mirror the shipped mechanism, the sign-respecting estimate recovers 39% of a planted elasticity: correct in sign, confidently estimated, and considerably smaller than the truth.

Every model here, and every model anywhere, is a simplification built to be useful for a decision rather than a finished, perfectly correct account of the world. A maturing measurement practice does not stop once a number's sign looks right. It keeps finding sharper questions to ask of the same number, and turning one up is a sign the practice is working rather than a sign something was missed earlier. Price is where the next sharper question lies.

A Choice Variable, Not a Proxy

Start with what makes price different from an ordinary control variable. A companion post on this blog looked at a related but different problem: a control column measured with error, standing in imperfectly for a confounder the model can't see directly. That's a measurement problem, and the honest fix is a better or more complete measurement of the thing the column is standing in for.

Price's problem is a different one, and it deserves a plain-language name before anything formal. Endogeneity just means a variable in a regression is tangled up with the very thing the regression is trying to explain, so its coefficient stops being a clean cause-and-effect number. Price is tangled because it is a choice variable. A retailer sets it, usually while forecasting the same demand that will go on to determine sales, and often cuts it precisely because that forecast looks soft. A price is a decision, made by someone reacting to the same signal that is also driving sales. Economists call this specific flavor of tangle simultaneity: price and sales are both outcomes of the same underlying demand story, rather than price simply causing sales in one direction.

Simultaneity, in one line

In plain terms: the same demand shock moves both price (a retailer discounts harder when demand looks soft) and sales, so a plain regression of sales on price can't cleanly separate "price caused this" from "the shock caused both." Formally, let \( D_t \) be that unobserved demand shock. The retailer sets \( \log P_t = \gamma D_{t-1} + \nu_t \) with \( \gamma \lt 0 \) (discounting after a soft signal), while sales respond to both price and the same shock, \( Y_t = a - \beta \log(P_t / P_{\text{ref}}) + \theta D_t + \varepsilon_t \), \( \beta \gt 0 \). Price and sales are jointly produced by the demand process rather than one simply causing the other. This is the puzzle differentiated-products demand estimation was built to solve (Berry, Levinsohn, and Pakes, 1995). It is a different problem from a mismeasured stand-in for a confounder, which is a claim about a separate variable rather than about price itself.

Because a simultaneity problem is different from a measurement problem, it needs a different kind of fix: a source of price movement the retailer's own forecast never touched. Economists call a tool like that an instrument, something that moves price for a reason unrelated to demand, so whatever those price movements do to sales can be credited to price alone. A supplier's shipping cost is the classic example. It nudges the retailer's price around, but a shipping-cost spike has nothing to do with whether demand looks soft this week. No amount of better demand measurement substitutes for that. Only a real instrument, or a deliberately designed experiment, does.

This framework already treats price as more than an afterthought, which is exactly why it pays to understand precisely what that treatment buys. A dedicated PriceConfig promotes a price column from an ordinary control into a first-class lever with its own transform, and the model builds an elasticity from it in _build_price_promo_levers. The intent is a good one: price and promotion are decisions a client actually controls, so planners want a real elasticity and a promo ROI rather than a generic control coefficient. The mechanism behind that intent, today, is a single regressor. The model picks a reference price and computes log(price / reference) once from the raw series, with no lag, no cost-side variable, and no instrument field anywhere in the config. Here is the term itself:

# model/base.py:1794-1799
# Sign guard: elasticity ≤ 0 (a price rise cannot raise demand here).
mag = pm.HalfNormal(
    "price_elasticity_mag", sigma=float(cfg.elasticity_prior_sigma)
)
elasticity = pm.Deterministic("price_elasticity", -mag)
price_contrib = elasticity * pt.as_tensor_variable(log_price)

A non-centered HalfNormal magnitude, negated into a Deterministic. It is clean, effective engineering, and what it constrains is a sign. Identifying an effect is a different problem, and this term was never built to solve it.

There's a second layer worth understanding, because it points straight at the improvement worth making next. Before the model assigns any control a causal role (confounder, precision control, mediator, collider), it clears price out of the control block entirely, in _prepare_levers, since price already has its own dedicated lever and doesn't need a second turn as a generic control. That's sensible on its own terms. It also means price hasn't yet joined the same role-assignment conversation that keeps a confounder from being over-shrunk, or a mediator from being conditioned on by mistake, the taxonomy this blog has described elsewhere. Price simply isn't part of that machinery yet, in either direction.

The framework's own endogeneity screen tells a similar story from a different angle. endogeneity_diagnostic is a useful lead/lag test, built to catch a channel whose spend looks like it's reacting to the KPI's own recent movements, which is exactly the pattern that would flag a retailer discounting after a soft demand signal. It's a real diagnostic for a real problem, and today it's wired to read only the media arrays. Extending it to price, alongside extending the causal-role taxonomy to cover price the way it already covers controls, is a natural, well-scoped next step. The pieces already exist and already do the right thing for media. They just haven't been pointed at price yet.

Where this lives, for readers who want the exact lines

PriceConfig (config/levers.py:24–37) and _build_price_promo_levers (model/base.py:1776–1833) build the lever. _resolve_price_reference (model/base.py:1766–1774) picks the reference price. _prepare_levers (model/base.py:1156–1201) clears price from the control block before _resolve_control_causal_roles assigns roles (the taxonomy lives in config/enums.py:64). endogeneity_diagnostic's signature (diagnostics/endogeneity.py:64–69, reading model.X_media_raw/model.y_raw/model.channel_names, lines 77–79) is called from agents/model_ops.py:791 and reporting/prefit.py:153, neither passing it a price argument.

What the Sign Guard Actually Disciplines

Give the sign guard its due, because it earns its place. Picture a short fit window, thin price variation, and a promotional calendar that happens to land in soft weeks for reasons that have nothing to do with price itself. An unconstrained regression on log-price can come back with a positive coefficient in that situation, reading a price cut as though it reduced sales, purely because discounts and demand troughs happened to line up. That's a bad look for a client-facing number, and forcing price_elasticity = -HalfNormal(…) makes it impossible to report by construction. Real discipline, worth keeping exactly as it is.

But a sign constraint can only ever catch one kind of mistake: a number crossing zero. It has nothing to say about a number that keeps the right sign but lands in the wrong place. Jeziorski, Leng, and Seiler (2025) looked at this directly. Fitting a demand model with an endogenous price control reduces bias, on real supermarket scanner data, but doesn't remove it, and what's left over can be substantial. The two results sit together comfortably. Their finding names a failure mode the sign guard was never built to catch. Nothing in the result says the surviving bias tends to flip the sign. It says the number tends to move inward, toward zero, because the same demand shock that drives a discount also drives sales, and an uninstrumented regression ends up crediting price with some of what the shock actually caused. A rule that only watches for a sign crossing zero has no way to notice a number quietly losing most of its size while staying on the right side of that line.

The sign guard doesn't fix the magnitude

A world built to match the shipped mechanism. A persistent demand shock drives both price (the retailer discounts harder when demand looks soft) and sales, while a cost-shifter instrument, uncorrelated with demand, moves price for reasons that have nothing to do with the retailer's forecast. Two estimators, exact closed-form sampling distributions (asymptotic normal, no Monte Carlo noise): the framework's own sign-guarded regression on log-price alone, and the same regression instrumented by the cost shifter. The true elasticity is −1.20.

0.60
250
Sign-guarded estimate
…% of true elasticity
Instrumented estimate
…% of true elasticity
P(sign guard binds)

At the defaults, the sign-guarded estimator's sampling distribution centers at −0.47, which is 39% of the true −1.20, with a standard error of about 0.05. That is tight enough that the probability of it ever returning a positive number (the one thing the guard rail would catch) is effectively zero. The instrumented estimator centers at −1.20, essentially exact, with roughly 2.4× the sign-guarded estimator's standard error. Correct, but visibly less confident about it. Reduce the instrument to a weak trickle and the sign-guarded curve never crosses zero either. It just keeps understating the truth, precisely and without complaint.

The Detector That Isn't Pointed at the Suspect

It's one thing to say the endogeneity screen hasn't been pointed at price yet, and another to show what it would see if it were. The diagnostic works by checking, for a channel's week-over-week changes, whether the KPI's own recent moves predict the channel's future spend better than the reverse. In effect it asks whether this looks like the KPI driving the channel, rather than the other way around. A channel gets flagged when that lead correlation clears a threshold (_max_lead_corr, diagnostics/endogeneity.py:48–61, threshold 0.30 over a window of up to 8 periods) and dominates the reverse direction. The figure below runs that same test, translated faithfully from the shipped function, on two series from one simulated world: a media channel flighted on its own calendar and unrelated to demand, and a price series that discounts after a soft demand signal. Same story as the figure above, now seen through the diagnostic's own eyes.

The detector that isn't pointed at the suspect

A 300-week simulated world (seeded PRNG, reproducible): a persistent demand shock, a media channel flighted independently of it, and a price series that discounts in proportion to last period's demand reading. Bars are the demand-leads-spend correlation at each lag, computed exactly as _max_lead_corr computes it. The shaded band marks the ±0.30 flag threshold.

−3.0
Media: best |corr| (lag)
Media verdict
Price: best |corr| (lag)
Price: reverse-direction |corr|
Price verdict

At the defaults, the media channel's strongest demand-leads-spend correlation is −0.15 at lag 5, well under the 0.30 threshold and correctly unflagged. Price's is −0.90 at lag 1, clearing the threshold by a wide margin and dominating the reverse-direction correlation of 0.09. That is exactly the pattern the diagnostic's own warning describes: spend "appears to respond to demand… its effect can't be cleanly separated from demand without an experiment." Drag γ toward zero and price's bars collapse into the media channel's noise floor. The flag tracks the mechanism, not a property of "being price."

Which is exactly the invitation here. The diagnostic already draws this line correctly for every media channel, so pointing it at price next is a small, concrete extension of something that already works.

You Modelled One P

Put the last two sections together and the title says exactly what's going on. One parameter, the single Deterministic price_elasticity, is being asked to answer two different questions, and only one of them has a clean answer yet. The first question is descriptive: given everything else in the model, what's the average slope of sales on log-price in this dataset? That's a well-defined, estimable number, and the model recovers it faithfully, sign guard and all. It is exactly what Figure 1's sign-guarded curve is centered on. The second question is causal: if the client cut price next month, holding the retailer's own pricing habits fixed, how much would sales actually move? Those two questions share an answer under one assumption only, that price is set independently of whatever else is moving sales, and that assumption is precisely the one this framework already refuses to make for every media channel.

This differs from the demand-proxy post's finding, and the difference is worth pinning down, since "endogeneity" can flatten two different problems into one vague worry. That post found one control column asked to play two roles (confounder for one channel, mediator for another) inside a design matrix with no way to record which role applies where. This is a different shape of problem: one coefficient, on the model's own treatment variable, asked to be both a descriptive statistic and a causal effect, without the randomization or instrument that would make those the same thing. A sign constraint is a rule about what shape an answer must have. Whether that shape is also the right size is a separate fact about where the price variation in the data actually came from, and that fact is the one worth going after next. You modelled one P. The data can hand back two different numbers, and it takes a bit more work to know which one you're holding.

The Honest Fix, and How Rare It Is

Economists solved this problem for price a long time ago, by finding price variation a demand shock can't see. The classic move, from Berry, Levinsohn, and Pakes (1995), instruments a product's price with the observed characteristics of its competitors, correlated with cost pressures but plausibly unrelated to the idiosyncratic demand shock hitting this particular product, and it recovers demand elasticities that survive automobile pricing's own simultaneity. A related, older idea uses the price of the same good in a separate, distant market. That price shares the manufacturer's national cost shocks but not the local demand shock, as long as the two markets aren't otherwise linked (Hausman, 1996).

The marketing literature reached the same conclusion just as bluntly, and just as long ago. Villas-Boas and Winer (1999) fit scanner-panel demand models with price and promotion set endogenously by the retailer, and found that ignoring it produces substantial coefficient bias relative to an instrumented version. That is a quarter-century-old result about exactly this data-generating process.

None of this is available off the shelf to most marketing teams, and that deserves saying plainly before anything else. A cost-shifter instrument needs supplier cost data most teams never see. A cross-market instrument needs a genuinely separate market with its own price series and no demand leakage between the two, a footprint few single-country retail or DTC brands have. That scarcity is exactly why more recent work has leaned toward a more modest goal: instead of assuming a point-identifying instrument exists, estimate a plausible range for the elasticity, built from structural models and elicited managerial judgment about demand. If the field is turning to ranges specifically because point-identifying instruments are this rare, then a framework returning one tight number with no instrument at all is quietly promising more precision than the underlying data usually supports.

⚠️ The docs already flag this. This post is the deep dive

The framework's modeling guide already says the right thing about price, in one sentence. Price there is "usually endogenous (cut because demand is soft)" and the guide tells you to "treat the elasticity as conditional, cross-check the identification assumptions, and confirm with an experiment." That sentence is correct, and it's the entire argument of this post compressed into a caveat. The Table 2 fallacy in media mix models covers the general version of the same point well. A price coefficient there is "precise nonsense" needing "its own instruments or experiments." This post is the specific follow-through: what the sign guard actually does, what it doesn't do yet, and what a concrete next step looks like for this framework's own PriceConfig.

Designed Variation Closes the Gap

There's a middle path between "no instrument" and "an instrument you probably don't have": design the price variation yourself. Run a flighted or randomized discount schedule, so a share of the price movement in the data is demand-independent by construction, put there by a calendar rather than a forecast. It's the same mechanism as Figure 1's cost shifter, seen from a different angle. Instead of asking whether a usable instrument already exists somewhere out in the world, this figure asks how much demand-independent price variation a designed calendar would need to add before the sign-guarded estimate stops badly understating the truth and starts being merely a little imprecise.

Designed variation is what closes the gap

Same world and same sign-guarded estimator as Figure 1, in closed form (exact population bias, no sampling). The x-axis is the variance of price movements that are demand-independent by construction: a randomized or flighted discount schedule, rather than an instrument found in someone else's cost data.

0.36
Sign-guarded estimate
% of true elasticity recovered
Verdict

With no designed variation at all, the sign-guarded estimate recovers just 9% of the true elasticity, a hair's breadth from the sign flip the guard rail exists to prevent. At the variance an instrument of Figure 1's strength would supply, it recovers 39%, the number this post has led with throughout. Push designed variation to five times the demand-driven variance and it recovers 88%. The curve is monotone with no crossover. Unlike bias amplification from a mismeasured demand proxy, where a better measurement can move the estimate the wrong way, demand-independent price variation here only ever helps, because it dilutes the confounding term in the denominator without adding any to the numerator.

A Partial Remedy This Framework Already Ships

When there's no instrument, and no budget or client appetite for a designed discount calendar, squeezing the confound out of one coefficient by assumption is the wrong reflex. The demand-proxy post made the right move for a different confounder: model the shared demand shock openly, as its own piece of the model, rather than asking a single regression to net it out silently.

This framework already ships that pattern for a different confounder, in examples/garden_models/latent_factor_mmm.py. It treats several imperfect macro indicators as noisy readings of one underlying "economic health" factor, lets that factor evolve over time, and feeds it into the sales equation as a covariate the media coefficients now condition on jointly, with its own uncertainty carried through rather than plugged in as a single point estimate.

The same idea applies to price. Instead of asking one log-price coefficient to carry the whole weight of an unmodeled pricing rule, model the demand shock that rule reacts to as a shared latent factor sitting alongside price and sales, fed by whatever a client already tracks: a category volume index, a competitor price series, a macro demand indicator, whatever the retailer's own planners were actually watching when they set the price. Same measurement idea, pointed at a new confounder. Be precise about what it buys, because the demand-proxy post already ran this exact experiment against a known ground truth, and the lesson transfers directly. On that answer key, ignoring the confounder left one channel's estimated contribution off by 406%, and adding four indicators as ordinary controls cut that to 101%. Modeling them jointly landed at 112%, no better on point accuracy. What the joint model bought instead was the factor itself, recovered at a 0.981 correlation with the truth, sign intact. That post's own framing for the result carries over here: a knowledge purchase rather than an accuracy purchase. Applied to price, a joint demand-factor model does not hand back a corrected elasticity. What it hands back is a labeled, inspectable account of the demand story the retailer's own pricing rule was already reacting to, useful on its own and a good foundation for the identification work that comes after it.

💡 What's worth writing down when there's no instrument yet

Most of the time there won't be an instrument, a designed discount calendar, or the appetite to build either this quarter. That's fine. It changes what the honest deliverable looks like. What you hand a client in that case is a plain diagnosis (the sign guard disciplines a sign, not a magnitude), a named requirement (demand-independent price variation, which this dataset either has or doesn't), and, when that requirement isn't met yet, a labeled conditional elasticity with a wide interval. Never a confident point estimate that reads as more identified than it is.

What This Does Not Establish

The simulations are illustrations, not a prevalence study. They're built to mirror the shipped mechanism rather than to adversarially maximize the effect, and they show the failure exists and can be sizeable. Nothing here says what share of real, fitted price coefficients land far from the truth, or by how much.

None of this argues for removing the sign guard. It's a cheap, real safeguard against a genuine failure (a promotional-timing artifact flipping the reported sign), and it costs nothing to keep. The point is narrower. Don't read a tight, sign-correct posterior as evidence the size of the number is settled too.

Plenty of practitioners do have a usable instrument. Some retailers run genuinely separate regional markets, or have supplier cost pass-through data that would satisfy a Hausman-style exclusion restriction. The claim here is that this is uncommon, not impossible, which is exactly why Haschka and Herwartz (2025) build set-identification tools for the more common case, where a point-identifying instrument isn't available.

The joint-latent-factor idea is an analogy here, not a validated result for price specifically. Those 406%/101%/112% figures come from the demand-proxy post's own answer-key world, built around a different confounder. This post hasn't yet built a price-specific synthetic world with a planted elasticity and a planted demand factor to confirm the same ordering holds for price. The claim is that the mechanism transfers, not that the exact numbers do.

The promo lever is a different story, for another day. A promotion is also a client decision, but its timing is often booked well ahead of the demand forecast that drives a week-to-week price cut, a different endogeneity story with its own caveats. This post is about price specifically.

Takeaways

  • Price's endogeneity is simultaneity, not proxy measurement error: a retailer's pricing rule and the sales equation share the same demand shock. The fix differs from the demand-proxy post's. Better measurement of a stand-in doesn't help here, because what's missing is demand-independent price variation.
  • This framework's price lever is one regressor, log(price/reference), with a sign guard keeping price_elasticity ≤ 0 (model/base.py:1794–1799) and no instrument, lag structure, or cost-side field yet in PriceConfig.
  • Price hasn't yet joined the framework's two existing endogeneity safeguards: _prepare_levers (model/base.py:1156–1201) clears it from the control block before causal roles are assigned, and endogeneity_diagnostic reads only media arrays today. Both are natural, well-scoped places to extend next.
  • A sign constraint rules out a sign flip. It doesn't fix the size of the number. Jeziorski, Leng, and Seiler (2025) find that conditioning on an endogenous price control "reduces bias but does not remove it," with surviving bias that "can be substantial." In this post's own simulation, the sign-guarded estimator recovers 39% of a true −1.20 elasticity, at −0.47, confidently, without ever once returning a positive number across the sampling distribution.
  • Run the framework's own lead/lag test on price and it flags decisively: a −0.90 demand-leads-price correlation against the 0.30 threshold, versus −0.15 for a well-behaved media channel.
  • Real fixes exist (BLP-style competitor-characteristic instruments, Hausman's cross-market prices, designed demand-independent price variation), though they're rare in practice. A joint latent-demand-factor model, a pattern this framework already ships in latent_factor_mmm.py, is a knowledge purchase worth having even before an instrument is in hand.

This closes no defect. It's one more increment in the direction this whole framework is already heading: a model doesn't have to be finished to be useful, and giving price the same causal-role thinking media channels already get is a good question to be asking right now.

References