Price Elasticity of Demand Modeling
Repricing decisions are only as good as the demand response they assume. A rule that undercuts a competitor by two percent is a bet that the extra units will more than pay for the thinner margin — and that bet is exactly the price elasticity of demand. This guide covers estimating elasticity from historical price and sales data for pricing decisions: fitting a constant-elasticity log-log regression, controlling for promotions, seasonality and competitor moves, recognizing the endogeneity that biases naive estimates, and turning a validated coefficient into an optimal price that still respects competitive constraints. It sits under Competitive Price Intelligence & Repricing and is deliberately paired with two siblings: price positioning and competitive benchmarking supplies the competitor price series this model consumes as an input, and automated repricing rule engines is where a trusted elasticity estimate finally changes a live price. The focused, runnable recipe for a single product’s own-price elasticity lives in estimating price elasticity from sales history.
Problem Framing & Prerequisites
Elasticity is the percentage change in quantity demanded for a one percent change in price, $\varepsilon = \frac{\partial \ln Q}{\partial \ln P}$. A product with $\varepsilon = -1.8$ loses 1.8 percent of unit sales for every one percent price increase; one with $\varepsilon = -0.4$ barely reacts. The sign is almost always negative, and the magnitude is what pricing decisions turn on: demand is elastic when $|\varepsilon| > 1$ (revenue rises as you cut price) and inelastic when $|\varepsilon| < 1$ (revenue rises as you raise price). Getting the number roughly right is worth far more than getting a repricing rule exactly right on top of a wrong number.
The reason a dedicated modeling stage exists — rather than a spreadsheet dividing one percentage by another — is that raw price and sales data almost never move for the clean reason the arithmetic assumes. Prices drop because a promotion launched, because a competitor cut first, or because it is December; each of those also moves demand directly, and an uncontrolled estimate silently attributes the whole swing to price. The model’s job is to hold those confounders fixed so the price coefficient measures price alone.
Three input contracts must hold before the estimator runs. First, the sales panel must carry aligned price and units at a consistent grain (a product-week or product-day), because elasticity is a within-product relationship over time, not a cross-sectional one. Second, prices must already be clean: converted to a single base currency via currency conversion and exchange-rate sync, stripped of fake-sale artifacts through statistical outlier detection for price data, and tagged with promotion structure from parsing complex promotional discount structures. Third, the competitor price series that acts as a cross-price control has to arrive from the positioning layer already matched to the same canonical product, which is what the upstream fuzzy matching for SKU alignment stage guarantees.
A minimal contract for one row entering the estimator:
from pydantic import BaseModel
from typing import Optional
class DemandObservation(BaseModel):
product_id: str # canonical id, already resolved upstream
period: str # ISO week or date; the panel time index
units: int # units sold in the period (the target, > 0)
price: float # own net price in base currency
competitor_price: Optional[float] = None # matched rival price, base currency
on_promo: bool = False # any active promotion in the period
week_of_year: int # 1..53, drives seasonal controls
Rows with units == 0 are dropped before log transforms rather than clipped, and periods with no price variation at all are flagged — a product whose price never moved carries no information about elasticity no matter how many weeks you observe.
Algorithm & Architecture Detail
The workhorse specification is the constant-elasticity log-log model. Taking logs of both price and quantity turns the multiplicative demand curve $Q = A \cdot P^{\beta}$ into a line whose slope is the elasticity:
$$\ln Q = \alpha + \beta \ln P + \gamma,\text{promo} + \delta,\ln P_{\text{comp}} + \sum_k \theta_k s_k + u$$
Here $\beta$ is the own-price elasticity, $\delta$ is the cross-price elasticity with respect to the competitor, and the $s_k$ are seasonal indicators. Because both sides are logged, $\beta$ reads directly as a percentage-for-percentage response with no rescaling — the single most useful property of this form and the reason it dominates in practice over a linear demand curve, whose slope has awkward units and whose elasticity varies along the curve.
Fit it with ordinary least squares in statsmodels, which gives standard errors and confidence intervals for free:
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
def fit_elasticity(panel: pd.DataFrame) -> smf.ols:
"""Fit a constant-elasticity log-log demand model for one product.
panel columns: units, price, competitor_price, on_promo, week_of_year
Returns a fitted statsmodels result; .params['np.log(price)'] is elasticity.
"""
df = panel[panel["units"] > 0].copy()
df["comp_price"] = df["competitor_price"].fillna(df["price"]) # neutral fill
# Seasonality as a coarse quarter factor keeps degrees of freedom sane.
df["quarter"] = ((df["week_of_year"] - 1) // 13 + 1).astype("category")
model = smf.ols(
"np.log(units) ~ np.log(price) + np.log(comp_price) "
"+ C(on_promo) + C(quarter)",
data=df,
).fit(cov_type="HC3") # heteroskedasticity-robust standard errors
return model
# Interpreting the fit:
res = fit_elasticity(panel)
beta = res.params["np.log(price)"] # own-price elasticity, e.g. -1.73
ci_low, ci_high = res.conf_int().loc["np.log(price)"]
print(f"elasticity = {beta:.2f} 95% CI [{ci_low:.2f}, {ci_high:.2f}]")
# elasticity = -1.73 95% CI [-2.05, -1.41]
Read the coefficient literally: -1.73 means a one percent price rise costs about 1.73 percent of units, so this product is elastic and price cuts grow revenue. The competitor coefficient (np.log(comp_price)) is the cross-price elasticity; a positive value is the expected sign for substitutes — when the rival raises price, your demand rises. Always report the coefficient with its interval, never alone. An estimate of -1.7 ± 0.3 is actionable; the same -1.7 with an interval spanning -3.1 to -0.3 is noise dressed as a number, and the confidence gate below exists precisely to reject it.
Controlling Confounders & Data Requirements
The gap between a textbook estimate and a trustworthy one is entirely in the controls, and controls are useless without price variation to exploit. The estimator needs the price to have moved independently of the other regressors across the observed periods; if every price cut coincided perfectly with a promotion, no model on earth can separate the two effects (they are collinear) and the price coefficient becomes unstable.
Three controls carry most of the weight:
- Promotion state. A
C(on_promo)factor absorbs the demand lift that comes from the promotion mechanics themselves — display, urgency, coupon visibility — rather than from the lower price. Without it, elasticity is overstated because promoted weeks bundle a price cut with a marketing push. The structure of those promotions comes from handling BOGO and bundle pricing in scraped data, which resolves an effective unit price the model can log. - Seasonality. Quarter or week factors soak up recurring demand cycles so a December sales spike is not misread as a response to a December discount. For long panels, a smooth time trend guards against slow drift in the product’s popularity.
- Competitor price. Including $\ln P_{\text{comp}}$ does double duty: it estimates the cross-price elasticity you need for competitive strategy, and it prevents your own elasticity from absorbing rival-driven demand swings. This series is not scraped ad hoc here — it is the matched, indexed output of the price positioning layer, and its quality caps the quality of the cross term.
On data volume, the practical floor is around 30 to 40 periods with genuine price movement across a meaningful range — a handful of distinct price points spanning at least 10 to 15 percent. Below that, standard errors balloon and the confidence gate will (correctly) reject almost everything. Sparse-history products should be pooled: fit a single hierarchical or fixed-effects model across a category so low-volume SKUs borrow strength from the group, rather than fitting hundreds of hopeless single-product regressions.
Endogeneity & the Estimation Pitfall
The deepest problem is endogeneity: price is not set at random, it is set by someone reacting to demand. If a category manager raises price precisely in the weeks they expect strong demand, price and the error term correlate, and OLS biases the elasticity toward zero — demand looks less price-sensitive than it truly is, and every downstream markup is too aggressive. This is not a data-cleaning issue that better normalization fixes; it is structural.
Three defenses, in ascending order of effort:
- Exploit exogenous price moves. The cleanest variation comes from price changes driven by cost pass-through, currency swings, or automated rules — moves not chosen in anticipation of demand. Flag those periods and lean on them; a rule-driven repricing history from the repricing engine is close to an experiment you already ran.
- Instrument the price. A valid instrument moves price but not demand directly — supplier cost indices and upstream FX shocks are common choices. Two-stage least squares (
statsmodels’IV2SLS) then recovers an unbiased slope. Instruments are powerful but fragile; a weak instrument is worse than none. - Run a deliberate price test. The gold standard is randomizing small price changes across periods or stores. Even a modest, structured price experiment produces variation that is exogenous by construction and sidesteps the whole problem.
The honest posture is to treat a single-equation OLS elasticity as a lower bound on sensitivity (biased toward zero) unless you have addressed endogeneity, and to widen the confidence gate accordingly for any product whose price history is purely manager-driven.
Configuration & Threshold Tuning
Elasticities group tightly by category, and those priors are how you sanity-check a fresh estimate and gate it before it touches a live price. An estimate that lands far outside its category band is a modeling failure until proven otherwise. Confidence gating combines three tests: the sign must be negative, the 95 percent interval must be narrower than a category-specific width, and the point estimate must fall inside a plausibility band.
| Category | Typical elasticity range | Auto-use if CI half-width < | Plausibility band (reject outside) | Min. periods | Notes |
|---|---|---|---|---|---|
| Consumer electronics | −2.4 to −1.4 | 0.35 | −4.0 to −0.6 | 40 | High cross-price; competitor term matters most |
| Apparel & footwear | −1.8 to −0.9 | 0.40 | −3.5 to −0.3 | 36 | Strong seasonal factors mandatory |
| Grocery staples | −0.9 to −0.3 | 0.30 | −2.0 to 0.0 | 30 | Inelastic; small price moves, pool sparse SKUs |
| Impulse / consumables | −1.5 to −0.7 | 0.40 | −3.0 to −0.2 | 30 | Promo confounding is severe; control hard |
| Luxury / premium | −0.8 to −0.2 | 0.35 | −2.0 to 0.2 | 40 | Watch for positive Veblen-style anomalies |
When a fresh estimate passes the gate, translate it into a price. For a constant-elasticity product with marginal cost $c$, the profit-maximizing price follows the Lerner markup rule:
$$P^{*} = \frac{|\varepsilon|}{|\varepsilon| - 1}, c \qquad (\text{requires } |\varepsilon| > 1)$$
def optimal_price(elasticity: float, marginal_cost: float,
comp_low: float, comp_high: float) -> float:
"""Constant-elasticity optimal price, clamped to the competitive band."""
if elasticity >= -1.0: # inelastic: unconstrained optimum is unbounded
target = comp_high # push toward the top of the allowed band
else:
e = abs(elasticity)
target = (e / (e - 1.0)) * marginal_cost
# Never leave the competitive envelope from the positioning layer.
return max(comp_low, min(target, comp_high))
print(optimal_price(-1.73, marginal_cost=42.0, comp_low=79.0, comp_high=115.0))
# 99.32 -> unconstrained optimum sits inside the band, used as-is
The clamp is not optional. An unconstrained markup optimum ignores what rivals charge, and in competitive intelligence the price you can actually hold is bounded by the market. The comp_low/comp_high envelope comes straight from building a competitive price index; elasticity chooses where inside the band to sit, positioning defines the band itself.
Failure Modes & Mitigations
- Positive or near-zero elasticity. A positive coefficient almost never means a genuine Veblen good; it means a confounder is uncontrolled — usually a stockout (low units and high price coincide because inventory ran out) or a promotion the flag missed. Reject the estimate, inspect the periods, and never reprice on it.
- Collinear price and promotion. If price only ever moved during promotions, $\beta$ and $\gamma$ cannot be separated and both become unstable across refits. The fix is upstream: source non-promotional price variation, or fit the promo and non-promo regimes separately.
- Stockout contamination. Periods where the product was out of stock show artificially low units unrelated to price. Filter them using availability flags before fitting; treating a stockout as weak demand biases elasticity toward inelastic.
- Regime change. A product repositioned, relaunched, or hit by a new entrant has a structurally different demand curve. Cap the estimation window (e.g. trailing 78 weeks) and monitor for a break in the coefficient across rolling refits, the same way statistical outlier detection watches for distribution shifts in price itself.
- Overreaction to a tight but wrong CI. A narrow interval around a biased (endogenous) estimate is confidently wrong. Widen the gate for products lacking exogenous price variation, and prefer a category prior over a suspiciously precise single-product fit.
Compliance & Auditability
An elasticity estimate that moves real prices is a decision artifact and must be reconstructable. Persist, per fit, the coefficient and its interval, the exact model formula, the estimation window, the number of usable periods, the robust standard-error type, and a hash of the input panel. When a repricing action is later questioned — by finance, by a supplier, or in a regulatory review of pricing conduct — this record proves the price change rested on a specific, versioned estimate rather than a guess.
elasticity_record = {
"product_id": "p-44182",
"elasticity": -1.73,
"ci_95": [-2.05, -1.41],
"cross_price_elasticity": 0.61,
"formula": "log(units) ~ log(price) + log(comp_price) + C(on_promo) + C(quarter)",
"n_periods": 84,
"window": "2024-W40..2026-W28",
"cov_type": "HC3",
"gate_passed": True,
"panel_hash": "sha256:7c1e…",
"model_version": "elasticity-v4",
}
Two boundaries matter. First, coordinated use of competitor prices to set your own can raise antitrust questions in some jurisdictions; the cross-price term here is an observational input to a unilateral decision, and keeping the audit trail unilateral and internal is part of staying on the right side of that line. Second, the competitor data feeding the model inherits the scraping-compliance obligations owned upstream in the ingestion stage — lineage, robots and terms handling — so the model never becomes a place where unlawfully sourced data is laundered into a price.
Deployment Checklist
A disciplined elasticity model is what separates repricing that grows contribution margin from repricing that just chases competitors. Fit the constant-elasticity form, control the confounders, respect endogeneity, gate on confidence, and let the positioning layer bound the answer — then hand a trustworthy number to the rules that act on it.
Related
- Competitive Price Intelligence & Repricing — the parent guide that frames how positioning, elasticity, and repricing fit together.
- Price Positioning & Competitive Benchmarking — supplies the matched competitor price series and the competitive band this model is constrained by.
- Automated Repricing Rule Engines — where a gated elasticity estimate becomes an actual price change.
- Estimating Price Elasticity from Sales History — the focused, runnable recipe for one product’s own-price elasticity.
- Building a Competitive Price Index — the source of the
comp_low/comp_highenvelope the optimal price is clamped to.