Aggregation Bends the Curve
A national brand runs television across forty markets, paces digital impressions hour by hour, and swings retail promotional spend by an order of magnitude between a Tuesday and Black Friday. By the time any of that reaches a fitted saturation curve, it has usually been summed into one weekly, national number. That is a deliberate simplification, and often a reasonable one, since most teams need one clean series to model rather than forty. But summing bends the curve. For a response that saturates, where each extra dollar buys a little less lift than the one before it, spend that swings unevenly loses something real when you add it up first and curve it second, compared with curving each piece and adding the results after. That's a plain instance of an idea called Jensen's inequality, first spotted in aggregated data seventy-six years ago and sized for exactly this kind of nonlinear marketing model in 1997. In a simulation built on this framework's own saturation curve, aggregating from daily to monthly grain makes the overall fit look better (in-sample error drops from 1.37% to 0.48%) while the marginal return the model reports at today's spend level drifts from a rounding error to a 24.8% understatement. The average holds up. The one number a budget decision actually needs does not, and that turns out to be a useful thing to understand about your own data.
Every model here is a simplification built to be useful for a decision rather than a finished, perfectly correct copy of the world. That holds for every curve this framework fits, this one included. Finding a sharper question to ask of a fitted curve, like "what did summing the data before fitting it quietly erase?", is what a measurement practice looks like as it matures. It isn't a defect someone should have caught earlier. This post takes one of those sharper questions and works out what it costs a budget call. The last few sections say where the answer naturally points next.
One Number Where There Were Many
Start with what actually happens to the data. This framework loads spend and other variables through an MFFConfig, and by default it settles at the weekly, national grain: daily detail and per-geography detail get summed together before a model ever sees them. For most teams that's a sensible starting point. Weekly, national reporting is what a lot of data sources actually deliver, and it keeps a first model simple. The summing itself happens inside the loader's variable-extraction step, which groups every row on whatever dimensions the model is configured to split by, and adds together everything else (MFFLoader._extract_variable in data_loader.py). If a model doesn't split by geography, every region's daily spend for a given week lands in one shared bucket. None of this is hidden. The loader's own comment says plainly that rows differing only on a dimension the model isn't splitting by get folded up together. What nothing downstream ever learns is that the folding happened at all.
From there, the summed series rides along unchanged. It gets standardized, run through adstock, and fed into a saturation curve exactly like any other spend series. None of those later steps know, or need to know, what grain the numbers started at. That's perfectly fine when the underlying spend was fairly even across whatever got summed together. When it wasn't, that summing quietly bends the answer, because a saturation curve has no way to tell the difference on its own.
💡 Where this lives in the code, if you want to trace it
The default grain is MFFConfig.frequency (config/mff.py:95, a Literal["W","D","M"] defaulting to "W"). The summation is the groupby(dim_cols).sum() inside MFFLoader._extract_variable (data_loader.py:572, with the comment explaining the intent at :542). The aggregated array then passes through, untouched in kind, via DataPreparer.prepare (data_preparation.py:240), BayesianMMM's own copy of it (model/base.py:738), adstock (model/base.py:1586–1604), normalization (:1606–1622), and into saturation (:1624–1647, transforms/saturation.py). Every link in that chain does its documented job correctly. It's the shape of the whole chain, end to end, that's worth noticing.
Jensen's Inequality, Not a Coding Bug
This kind of distortion has a name, and it isn't specific to marketing. In 1950, the sociologist William Robinson showed that literacy and immigration rates were negatively correlated across U.S. states (−0.53), even though the same two things were mildly positively correlated among individuals (+0.11). The correlation flipped sign purely because the data had been added up into states before anyone computed it, a founding case study in what's called aggregation bias. Marketing researchers found the same mechanism at work in nonlinear response curves specifically. Christen, Gupta, Porter, Staelin and Wittink (1997) showed that fitting a response curve on market-level scanner data biases it, in a predictable direction, relative to the true store-level curve, and built a correction, tested against real store data. A saturation curve is exactly this kind of curve. It bends the same way.
Definition: aggregation bias in a concave transform
In plain terms first: a "concave" response curve is one that bends over. Each extra dollar of spend buys a little less lift than the dollar before it, which is what "saturation" means. The "marginal return" is the payoff from the next dollar specifically, as opposed to the "average return," which is the payoff per dollar averaged over everything spent so far. When spend swings around a lot, a big day next to a quiet one, adding it all up first and then applying the curve gives back a bigger number than applying the curve to each day separately and adding the results. That gap is the whole story below, made precise.
Let \( x_1,\dots,x_M \) be the true micro-level spend values folded into one reported observation: separate days within a week, separate geographies within a national total, or both. For a channel with a concave, saturating response \( S \) (this framework's own \( S(x) = 1-e^{-\lambda x} \), transforms/saturation.py::logistic_saturation), the true combined effect is the sum of the true micro-level effects,
A model that only observes the aggregate \( X = \sum_j x_j \) and fits one application of the same curve, \( \hat R = \hat\beta\, S(X; \hat\lambda) \), is implicitly treating \( R_{\text{true}} \) as though it equaled \( M\beta\, S(\bar x) \), where \( \bar x = X/M \) is the mean micro-level spend. Jensen's inequality says otherwise: because \( S \) is concave,
$$ \frac{1}{M}\sum_{j=1}^{M} S(x_j) \;\le\; S(\bar x), $$with equality only when every \( x_j \) is identical. Micro-level spend is essentially never identical (weekdays outspend weekends, flagship geographies outspend small ones, promotional weeks outspend quiet ones), so the true aggregate effect is smaller than what a single application of the curve to the aggregated total would predict. A least-squares fit on aggregated data has no way to see this shortfall as a heterogeneity artifact. All it can do is absorb the shortfall into \( \hat\beta \) and \( \hat\lambda \). The curve it settles on then sits, at the observed spend level, at a more saturated point of its own shape than the true micro-level curve occupies at that same nominal rate.
The figure below makes this concrete, using this framework's own saturation curve with no adstock at all. I want aggregation bias isolated here, held apart from the carryover effect the companion post on saturation and fatigue is about (more on how the two connect below). A single simulated channel has a true daily response with an ordinary weekday/weekend pattern, a slow two-year swing in campaign intensity, and day-to-day noise the slider lets you control. Sum that same underlying world into windows of 1, 3, 7, 14 and 28 days, roughly daily, weekly, and monthly grain, then refit the identical curve at each one. Every grain hands back a different pair of parameters.
The curve that isn't there anymore
One simulated channel, 8,400 days (about 23 years) of a true logistic response with no adstock, refit at five aggregation windows. Left: the true per-day response curve against the curve each aggregation window's least-squares fit implies, expressed per micro-unit so the curves are directly comparable. The dashed line marks the current spend level. Right: the same fits' in-sample MAPE (small, and getting smaller) against the resulting bias in the marginal return read off at that spend level (small, and getting much larger). Drag the slider to change how unevenly spend is distributed across the days being summed.
At the default heterogeneity, in-sample MAPE runs from 1.37% at daily grain to 0.48% at 28-day aggregation, and R² rises from 0.946 to 0.974 as well. Aggregation leaves the fit looking better. Over the same five grains, the marginal return read off the fitted curve at the current spend level moves from a 0.3% rounding error to a 24.8% understatement, almost monotonically: 0.3%, 9.6%, 19.0%, 24.1%, 24.8%. The average return's bias, over the same five grains, wanders between −15.8% and +5.1% with no consistent direction at all. That contrast is itself useful to notice. A bias that bounces around with no trend looks like ordinary estimation noise. A bias that climbs steadily with the aggregation level is telling you something structural is going on.
Why Every Check on the Deck Says Fine
Nothing in a typical model review catches this, and the reason is structural, not a gap in any one check. R² and MAPE compare the model's predicted total to the observed total. But the observed total is already the aggregated number the model was trained to reproduce. A posterior-predictive check compares replicated data to observed data at the same grain the model was trained on. It can't see a discrepancy that only exists at a finer grain the model never ingested. Simulation-based calibration and coverage checks work the same way: they refit the same functional form on data generated by that same form. If the aggregation bias is baked into what "correctly specified" means for a given dataset, the calibration loop never leaves the room to notice it. None of this is a weakness specific to this framework. It's true of any diagnostic that only ever sees the data as delivered. And the data as delivered is the thing that has already been summed.
The uncomfortable part is the direction the bias runs. Coarser aggregation makes the model look better by every fit statistic a review would check. Summing noisy micro-level draws cancels idiosyncratic variance, the same way any average smooths things out. A model that fits beautifully and still reports a systematically wrong marginal return is a harder thing to catch than one that visibly struggles, because the signal a reviewer instinctively trusts ("the model explains the data well") is exactly the signal rewarding the aggregation choice that's quietly reshaping the number a budget decision will use.
The Average Survives; the Margin Doesn't
The average and the margin diverge for a specific reason, not by coincidence of this particular simulation. Fitting a curve to reproduce the total response near the center of the data pins the curve's level fairly tightly at that point, which is what MAPE is measuring. The slope at that point is a separate thing. It's set by how the fitted curve has to bend to also explain the data away from the center, and that's exactly the information aggregation degrades: the more heterogeneous micro-level spend gets folded into one number, the less the aggregate series reveals about local curvature, and the more the recovered slope reflects the aggregation bias described above rather than the true micro-level shape.
The figure below isolates that divergence directly. It holds the operating point fixed at the current spend level and reads two numbers off the same fits as above. The first is the average return, total response divided by total spend. The second is the marginal return, the slope of the fitted curve at that point, which is the number a budget allocator would actually use to decide whether the next dollar belongs in this channel (this framework's own response-curve function, planning/budget.py::compute_response_curves, is built to read exactly this).
The average survives; the margin doesn't
Same simulated world and fits as above. Solid lines are the fitted average and marginal return at the current spend level, at each aggregation grain. Dashed lines are their true values. The average line wanders around its true value without a clear direction. The marginal line falls in something close to a straight line, away from the truth, as the window widens.
At the default heterogeneity, the true average return at the current spend level is 66.7 and the true marginal return is 36.6 (a marginal-to-average ratio of 0.55, the ordinary signature of diminishing returns). The fitted average return stays inside a 56.2–70.1 band across every grain from daily to monthly, bracketing the truth without a trend. The fitted marginal return declines almost in a straight line (36.7, 33.1, 29.7, 27.8, 27.5), ending 24.8% below the truth at 28-day aggregation. A budget optimizer reading the daily fit and the monthly fit would get two different verdicts about how much room this channel has left before it saturates, from a model whose overall fit quality would tell a reviewer the monthly version is the more trustworthy of the two.
How This Differs from the Extrapolation Guard
⚠️ A different failure mode than the extrapolation guard already in the codebase
This framework already carries a caution about response curves, and it's worth being precise about how it differs. The two live a few lines apart in the same file, planning/budget.py. A pair of constants (_EXTRAP_TOL and _EXTRAP_INFLATION_K, budget.py:23–30) flag a recommendation that pushes spend beyond the range of data the model has ever observed, and widen its credible interval accordingly, because past that range the posterior's own spread understates how wrong the curve's shape could be. That's a warning about extrapolating outside the observed spend range. Aggregation bias is a different animal. It shapes the curve inside the observed range, right at the spend level the data covers most densely, because compute_response_curves reads its response curves straight from mmm.X_media_raw (budget.py:108), the same already-aggregated array this whole post traces back to its source, and nothing corrects for what got folded into it before it arrived. A model can be nowhere near the extrapolation guard and still be reading a meaningfully biased marginal return.
A Head Start Already Built In: Go Finer in Space
The most direct way to shrink this bias is spatial rather than temporal, and this framework already has a switch for it, though it ships off by default. ModelConfig.vary_media_by_geo defaults to False, meaning the model keeps one shared coefficient per channel across every geography, plus per-geo intercepts. Turning it on lets each channel's effect vary, partial-pooled, across geographies, so effectiveness that really does differ by region gets estimated as such rather than averaged away (config/model.py:28–34, implemented in model/base.py::_build_channel_betas_geo). A nationally-pooled model is, by construction, exactly the kind of aggregation this post is about: every region's spend and response summed into one series before a single curve gets fit to it. Model per geography instead and each region keeps its own operating point, so a given curve only has to explain that region's heterogeneity rather than the heterogeneity of every region combined.
Sun, Wang, Jin, Chan and Koehler (2017), the Google research that's still the standard reference for geo-level Bayesian hierarchical MMM, make the case for modeling at the geo level on different grounds: how much information a national aggregate throws away, rather than saturation-curve bias specifically. The connection to this post is one worth drawing, even if it isn't the one their paper draws. Finer spatial resolution shrinks the within-cell heterogeneity that gets averaged away at the moment of fitting, which is exactly the quantity the Jensen's-inequality gap above depends on. It's a partial answer, and worth being clear-eyed about that. The vary_media_by_geo switch addresses the spatial half of the collapse (geographies summed together) and leaves the temporal half (days summed into weeks) to the data itself, solvable only by loading at a finer MFFConfig.frequency. As the next two sections lay out, going finer in space is close to free. Going finer in time is a tradeoff worth making thoughtfully, not automatically.
Or Correct for It Without New Data
Diagnosing the bias isn't the only contribution of Christen, Gupta, Porter, Staelin and Wittink's 1997 paper. They also built a correction that doesn't require the disaggregated rows themselves. Validated on scanner data by comparing store-level truth against what a market-level fit recovers with and without the adjustment, their method leans on summary statistics of within-cell dispersion, meaning how unevenly spend is distributed inside each reported period, rather than needing every individual row that got summed. That distinction is a practical one. A data source will often report an aggregate spend total and some measure of its own internal variability (a coefficient of variation across days, a min/max range) without ever releasing the disaggregated rows a vendor may not be willing to share. A correction like this backs out the bias analytically from what's already available, which makes it a natural complement to the geo-level remedy above for the case where finer-grain data isn't obtainable at all. This framework doesn't implement a correction like that yet. It's one of the more concrete, well-scoped next additions this line of work points toward, and it would sit naturally alongside the saturation and adstock code already here.
Finer Grain Is a Real Tradeoff
"Just load the data at a finer grain" is the obvious next reaction, and it comes with its own tradeoff, one this blog has already looked at closely. The companion post, Saturation or Fatigue? Your MMM Cannot Tell the Difference, shows that geometric adstock is mathematically an AR(1) filter, \( a_t = \alpha\, a_{t-1} + x_t \): each period's carryover is a fraction of the last period's plus new spend. The autocorrelation that filter installs in the adstocked regressor is exactly the condition (Dew, Padilla and Shchetkina, 2024) under which a static, concave saturation curve becomes statistically hard to tell apart from a straight-line response with a slowly drifting coefficient. That post's own figure shows a drifting coefficient soaking up 73% of the true saturation curvature at a retention of 0.70, and 79% at 0.85.
Retention and grain are linked through a channel's real-world memory, and the link cuts against this post's fix specifically. A channel with a two-week half-life has a per-period retention of \( \alpha = 0.5^{1/h} \), where \( h \) is the half-life expressed in whatever unit the data is loaded at. At weekly grain, a two-week half-life gives \( \alpha_{\text{weekly}} = 0.5^{1/2} \approx 0.71 \), already inside the range the companion post flags as concerning. Load that same channel at daily grain to shrink the aggregation bias described here, and the same two-week memory becomes a 14-day half-life, which gives \( \alpha_{\text{daily}} = 0.5^{1/14} \approx 0.95 \). That sits past the highest retention that post's figure covers, in the direction it shows getting worse. Going finer in time to address the bias in this post can make the conflation the companion post describes more pronounced, for that same channel's real carryover. The two pieces share the same knob, which is exactly why they're worth reading as a pair rather than in isolation. The companion post covers what happens once a curve and a drifting trend become impossible to tell apart. This one covers what happens to the curve's own shape before that question even gets asked. Neither is complete without the other.
💡 A note on the other Jensen's-inequality mention on this blog
Advertising Is a Stock, Not a Flow also invokes Jensen's inequality, for a different reason entirely: an optimal-timing decision under a state variable (the goodwill stock), not an estimation bias created by summing spend before fitting a curve. Both posts lean on the same mathematical inequality. They aren't the same mechanism, and neither implies the other.
What This Doesn't Pin Down Yet
A few things need pinning down precisely, so this reads as a specific, checkable claim rather than a sweeping one.
This isn't universal. Allenby and Rossi have argued that for some functional forms, a macro logit demand model specifically, the bias this post describes can be negligible. Their work is known in this literature by the title "There Is No Aggregation Bias: Why Macro Logit Models Work" (the precise year and venue aren't pinned down here, so it's named rather than formally cited). Aggregation bias depends on the specific curve and the specific heterogeneity in the data. It isn't a law that applies to every nonlinear marketing model regardless of shape. Worth checking against your own curve and your own data before assuming it applies.
The simulation demonstrates the mechanism. It doesn't measure how common this is. It uses one channel, one exponential saturation form, a stylized weekday/weekend and campaign-cycle spend pattern, and simple Gaussian noise with no adstock at all. Real channels carry adstock, correlated controls, and heavier-tailed spend distributions. Some of that would compound the bias described here, some would soften it. What the simulation shows is that the mechanism is real and produces a bias of this size under ordinary, non-adversarial conditions. It doesn't say how large this bias typically runs in any particular team's shipped model.
There's no recent, MMM-specific paper quantifying this. This post leans on 1990s scanner-data econometrics (Christen et al., Wittink and Porter), a 2017 industry technical report (Sun et al.) whose stated motivation is geo-level precision rather than curvature bias specifically, and the 1950 root of the aggregation-bias literature generally (Robinson). That's a genuine opening in the modern MMM literature, worth naming as a good problem for someone in this space to take on, rather than treating it as settled research.
There's no automated detector for this yet. Nothing in this framework currently flags when a fitted saturation curve is likely carrying aggregation bias. The comparison in the figures above is a manual exercise, run outside the shipped pipeline, in the same spirit as the manual saturation-versus-drift comparison in the companion post. That's a reasonable next thing to build once a debiasing approach along the lines above has a home in the codebase.
What to Do About It
Start by checking the within-cell heterogeneity of the spend you're about to aggregate, even if you have no plans to model at the finer grain. A coefficient of variation across the days or geographies folded into each reporting period is a one-line diagnostic that tells you how much Jensen's-inequality room exists before you fit anything. A channel whose spend is nearly flat within each reporting period has little to worry about. One that swings between a flighted push and near-zero has a lot.
Where you have the choice, prefer spatial disaggregation over temporal. Turning on vary_media_by_geo reduces the heterogeneity being averaged away without touching the adstock-retention knob that drives the companion identification question at all. It's close to a free improvement, gated only by whether your data actually varies enough across geographies to identify per-geo effects. Going finer in time is a genuine tradeoff. Weigh the aggregation-bias reduction against the autocorrelation increase in that same channel's adstocked regressor, using the half-life arithmetic above, before treating a finer MFFConfig.frequency as a strict improvement.
Where finer-grain data isn't available, a debiasing correction in the spirit of Christen et al., built on within-cell dispersion statistics rather than the disaggregated rows themselves, is a well-documented option worth building toward, even though this framework doesn't implement one yet. And where you do have a choice of grain, fit more than one and compare the decision, not just the fit statistic. If the marginal return at current spend moves materially between a weekly and a monthly aggregation that both report an excellent MAPE, that's telling you the data's grain is doing more work than the model's functional form. Give that a sentence in the report rather than a footnote.
None of this is a verdict on a model you've already fit. It's a sharper question to bring to the next one. A saturation curve was never going to be a perfect photograph of how spend turns into sales. It's a working simplification built to inform a specific decision, and that decision gets better exactly when the simplification gets a little more honest about what it's simplifying away. Loading a finer grain where the data allows it, or adding a debiasing correction where it doesn't, is one more increment in that direction, and a genuine improvement worth making.
Takeaways
- This framework's loader sums raw spend to whatever grain
MFFConfig.frequencyspecifies before a saturation curve ever sees it (MFFLoader._extract_variable, thegroupby(dim_cols).sum()atdata_loader.py:572). That's a sensible default, and it leaves a real opportunity, because nothing downstream currently knows a collapse happened. - For a concave response, Jensen's inequality guarantees the true aggregate effect of unevenly distributed micro-level spend is smaller than the effect of applying the curve once to the aggregated total. That's the classical result behind Christen, Gupta, Porter, Staelin and Wittink (1997), and, before that, Robinson's (1950) ecological correlation.
- In a simulation on this framework's own saturation form, aggregating a channel's spend from daily to 28-day grain improves in-sample MAPE (1.37% → 0.48%) and R² (0.946 → 0.974), while the marginal return at current spend drifts to a 24.8% understatement, almost monotonically, against an average-return bias that wanders with no trend at all.
- This is a different question from the extrapolation guard already in
planning/budget.py(_EXTRAP_TOL/_EXTRAP_INFLATION_K). That guard flags spend recommendations past the observed range. Aggregation bias shapes the curve inside it, at the spend level the data covers best. - The framework's opt-in
vary_media_by_geois a close-to-free head start on the spatial half of the collapse. Fixing the temporal half by loading finer-grain data is a real tradeoff. The same channel's real carryover implies a higher adstock retention at finer grain, which the companion post on saturation and fatigue shows makes a separate identification question harder. The two are worth solving together. - The bias is not universal. It depends on the specific curve and the specific heterogeneity in the data (a named counter-case: Allenby and Rossi on macro logit models), and no 2020s paper has quantified it for MMM specifically yet. That's a genuine opening in this literature, not a settled result to lean on.
References
- Christen, M., Gupta, S., Porter, J. C., Staelin, R., & Wittink, D. R. (1997). Using Market-Level Data to Understand Promotion Effects in a Nonlinear Model. Journal of Marketing Research, 34(3), 322–334.
- Abhishek, V., Hosanagar, K., & Fader, P. S. (2015). Aggregation Bias in Sponsored Search Data: The Curse and the Cure. Marketing Science, 34(1), 59–77.
- Robinson, W. S. (1950). Ecological Correlations and the Behavior of Individuals. American Sociological Review, 15(3), 351–357.
- Sun, Y., Wang, Y., Jin, Y., Chan, D., & Koehler, J. (2017). Geo-level Bayesian Hierarchical Media Mix Modeling. Google Inc. technical report.
- Wittink, D. R., & Porter, J. C. (1991). Aggregation Bias Resulting from Nonlinearity in Scanner Retail Data. In Operations Research Proceedings 1991. Springer.
- Dew, R., Padilla, N., & Shchetkina, A. (2024). Your MMM is Broken: Identification of Nonlinear and Time-varying Effects in Marketing Mix Models. arXiv:2408.07678 [econ.EM].