The Mathematics of Saturation & Diminishing Returns

Adstock answered when spend acts. Saturation answers how much each additional dollar buys. The first $10k of search spend captures the high-intent shoppers; the next $10k reaches people who were going to convert anyway, or no one at all. Formally, the response is a nonlinear, increasing function of (adstocked) spend whose slope — the value of the next dollar — falls as spend rises. That falling slope is the whole point: it is what makes a finite optimal budget exist. This page derives both saturation families the framework ships, corrects a docstring myth about which one is "S-shaped," and lets you bend every curve live. It mirrors nbs/math/math_02_saturation.ipynb, where each claim ends in an assert.

The two families have different shapes — getting that distinction right is the spine of this page:

FormEquationShape on \(x \ge 0\)Where in the framework
Core logistic / "exponential" \( f(x) = 1 - e^{-\lambda x} \) strictly concave (no S) transforms.logistic_saturation; the default BayesianMMM
Alternative Hill \( f(x) = \dfrac{x^{n}}{x^{n} + \kappa^{n}} \) \(n=1\) hyperbolic · \(n>1\) S-shaped per-channel SaturationConfig.hill(); mmm_extensions

⚠️ A naming trap, corrected head-on

The framework's docstring calls \(1 - e^{-\lambda x}\) "S-shaped." It is not — on \(x \ge 0\) it is strictly concave, steepest at the origin, with no inflection point. The genuine S (slow start, then acceleration, then a ceiling) belongs only to the Hill curve with \(n > 1\). The notebook makes this concrete and asserts it; section 2 derives it.

🧭 Before you start

Prerequisites: comfort with calculus & probability; the Workshop series recommended first.  ·  Time: ~30–45 min.

You will learn:

  • Why the core saturation \(f(x) = 1 - e^{-\lambda x}\) is strictly concave — not "S-shaped" as the docstring claims — and why the genuine S belongs only to Hill with \(n > 1\).
  • The half-saturation points and slopes of both families: \(\ln 2/\lambda\) for the core curve, exactly \(\kappa\) for Hill at every \(n\).
  • Why spend is normalized before saturating, and why the ceiling \(f \in [0,1)\) makes contribution \(\beta f\) — and an optimal budget — finite.

Run it: nbs/math/math_02_saturation.ipynb on GitHub.

1 · The core curve: \( f(x) = 1 - e^{-\lambda x} \)

This is what the default BayesianMMM actually builds, per channel (model/base.py):

sat_lam     = pm.Exponential(f"sat_lam_{channel}", lam=0.5)   # rate prior, mean = 2
exponent    = pt.clip(-sat_lam * x_adstocked, -20, 0)         # numerical clamp only
x_saturated = 1 - pt.exp(exponent)                            # == 1 - exp(-sat_lam * x)

The curve acts on the adstocked spend, and the single parameter \(\lambda\) (sat_lam) gets an \(\mathrm{Exponential}(0.5)\) prior (mean 2). The pt.clip(..., -20, 0) only guards exp from underflow at huge spend; mathematically the function is

\[ f(x) \;=\; 1 - e^{-\lambda x}, \qquad x \ge 0 \;\;(\text{negatives clipped to } 0), \qquad \lambda > 0. \]

Properties, derived

Anchored at the origin. \(f(0) = 1 - e^{0} = 0\). No spend, no response.

Bounded above by 1, never reached. Since \(e^{-\lambda x} > 0\) for all finite \(x\), \(f(x) < 1\) strictly and \(f(x) \to 1\) as \(x \to \infty\): the range is \([0, 1)\). This is the ceiling — an intrinsic maximum effect the channel can deliver no matter how much you spend.

Strictly increasing, with falling marginal returns from the very first dollar. Differentiate twice:

\[ f'(x) \;=\; \lambda\, e^{-\lambda x} \;>\; 0, \qquad f''(x) \;=\; -\lambda^{2}\, e^{-\lambda x} \;<\; 0 \quad \text{for all } x \ge 0. \]

The marginal response \(f'(x)\) is maximal at the origin — \(f'(0) = \lambda\) — and strictly decreasing thereafter: the first dollar is the most valuable and every dollar after is worth less. Always diminishing, from the start.

Half-saturation point. Solve \(f(x_{1/2}) = \tfrac12\):

\[ 1 - e^{-\lambda x_{1/2}} = \tfrac12 \;\Longrightarrow\; e^{-\lambda x_{1/2}} = \tfrac12 \;\Longrightarrow\; x_{1/2} = \frac{\ln 2}{\lambda}. \]

Larger \(\lambda\) ⇒ smaller \(x_{1/2}\) ⇒ the channel saturates sooner. The slope at the half-saturation point is a clean number: \(f'(x_{1/2}) = \lambda e^{-\ln 2} = \lambda/2\).

Assert-encoded identities (notebook cell, verified on every run)

The notebook calls the framework's real logistic_saturation — not a re-implementation — and asserts: it matches \(1 - e^{-\lambda\,\mathrm{clip}(x,0)}\) to \(10^{-12}\); \(f(0)=0\) and negatives map to 0; the output is strictly increasing and strictly below 1; the curve is strictly concave everywhere (every successive slope smaller than the last — no inflection); and \(f(\ln 2/\lambda) = \tfrac12\) exactly, giving half-saturation points \(x_{1/2}\) = 1.386, 0.693, 0.347, 0.173 for \(\lambda\) = 0.5, 1, 2, 4. Sanity row at \(\lambda = 1\): \(f(0, 0.5, 1, 2, 5)\) = (0, 0.393, 0.632, 0.865, 0.993).

Computed live in your browser — the same math as the notebook. \(f(x) = 1 - e^{-\lambda x}\) for four saturation rates. Each dotted dropline lands exactly on \(x_{1/2} = \ln 2 / \lambda\), where the curve crosses ½; the dashed line at 1 is the ceiling, approached but never reached. Larger λ saturates sooner.

Computed live in your browser — the same math as the notebook. The marginal response \(f'(x) = \lambda e^{-\lambda x}\): each curve starts at its maximum \(f'(0) = \lambda\) (dots) and only falls. The squares mark \(f'(x_{1/2}) = \lambda/2\) — the slope at half-saturation. Diminishing returns from dollar one, with no warm-up region.

2 · The correction: concave, not S-shaped

Because \(f''(x) = -\lambda^2 e^{-\lambda x} < 0\) everywhere on \(x \ge 0\), the core curve has no inflection point. It is the classic diminishing-returns silhouette, not a sigmoid. (Algebraically, \(1 - e^{-\lambda x}\) is the CDF of an \(\mathrm{Exponential}(\lambda)\) random variable; an exponential CDF is concave, unlike the S-shaped normal or logistic CDFs.) The notebook encodes this as np.diff(slope) < 0 on a dense grid — the machine-checked statement that the curve never stops bending downward.

Where does a genuine S come from? Take the Hill function \(f(x) = x^n / (x^n + \kappa^n)\) and differentiate twice. The first derivative is (derived in section 3) \( f'(x) = n \kappa^n x^{n-1} / (x^n + \kappa^n)^2 \), and its sign change works out to

\[ f''(x) = 0 \;\Longleftrightarrow\; x^{n} = \kappa^{n}\,\frac{n-1}{n+1} \;\Longleftrightarrow\; x^{\star} = \kappa \left( \frac{n-1}{n+1} \right)^{1/n}. \]

For \(n \le 1\) there is no positive root — the curve is concave throughout, just like the exponential. For \(n > 1\) the root \(x^{\star}\) is a real inflection point: convex below it (slow start), concave above it (bend to the ceiling). That — and only that — is an S-curve. The two shapes are not cosmetically different: below the inflection the truth says "spend does almost nothing," while every concave curve is at its steepest there.

Computed live in your browser — the same math as the notebook. Both curves are calibrated to hit half-saturation at the same spend (0.45 of max): the core exponential with \(\lambda = \ln 2 / 0.45 \approx 1.54\) versus Hill with \(\kappa = 0.45,\ n = 3\) (dark dashed — the series' "truth" styling, because this is exactly the true curve in the stress-test world below). The triangle marks the Hill inflection \(x^{\star} = \kappa\,(2/4)^{1/3} \approx 0.357\). The green curve has no such point anywhere: steepest at zero, bending down forever.

⚠️ Why this distinction costs real money

In the stress-test world saturation_misspec (stress 01, act 2), the true response is Hill with \(n \approx 3\) and roughly three-quarters of spend weeks sitting below the threshold. Fitting it with the concave default over-credited total media by +38% with 25% coverage — while r-hat, divergences, the posterior-predictive check, and the refutation suite all passed. The purest silent failure in the catalog: the posterior was exactly where it should be, given the wrong curve.

3 · The Hill alternative: the real S-curve

The other family — selectable per channel via SaturationConfig.hill() and used throughout the mmm_extensions path — is the Hill function:

\[ f_{\text{Hill}}(x) \;=\; \frac{x^{n}}{x^{n} + \kappa^{n}}, \qquad x \ge 0,\; \kappa > 0,\; n > 0. \]

It has two interpretable knobs:

Like the core curve, \(f_{\text{Hill}}(0) = 0\) and \(f_{\text{Hill}} \to 1\) as \(x \to \infty\) — range \([0, 1)\), and \(f\) itself is monotone increasing for every \(n\), S-shapes included.

Marginal returns: where the two families part ways

Differentiate Hill with the quotient rule:

\[ f_{\text{Hill}}'(x) = \frac{n x^{n-1}(x^n + \kappa^n) - x^n \cdot n x^{n-1}}{(x^n + \kappa^n)^2} = \frac{n\,\kappa^{n}\,x^{\,n-1}}{\left(x^{n} + \kappa^{n}\right)^{2}}. \]

Behaviour at the origin splits on \(n\):

A clean checkpoint the notebook asserts exactly: the slope at half-saturation is

\[ f_{\text{Hill}}'(\kappa) = \frac{n\,\kappa^{n}\,\kappa^{\,n-1}}{(2\kappa^{n})^{2}} = \frac{n\,\kappa^{2n-1}}{4\,\kappa^{2n}} = \frac{n}{4\kappa}, \]

against the exponential's \(f'(\ln 2/\lambda) = \lambda/2\) at its half-sat. The notebook also checks both analytic derivatives against finite differences of the actual curve functions, and that the Hill marginal for \(n > 1\) is genuinely non-monotone with its peak at the predicted \(x^\star\).

When do thresholds matter? Whenever a channel needs critical mass before it works — awareness media that must hit reach thresholds, channels with auction dynamics, or any case where low weekly spend is genuinely wasted. Under a threshold, small budgets can be worse than none, and the optimal move is concentrate-or-exit — a qualitatively different optimization problem from "every dollar worth less than the last." Picking the saturation family is a real modeling decision, not a cosmetic one. One caution from math 01's equifinality theme: \(\kappa\) is only weakly identified from observational data (it trades off against adstock decay and \(\beta\)), so SaturationConfig.compute_kappa_bounds_from_data(x, percentiles=(0.1, 0.9)) anchors \(\kappa\)'s prior to observed-spend percentiles, keeping the curve's elbow inside the region the data actually cover.

Curve explorer: response and marginal returns, live

Pick a family and drag its parameters. The left panel is the response \(f(x)\); the right panel is the marginal return \(f'(x)\) — the value of the next dollar. Watch what happens to the marginal curve the moment Hill's \(n\) crosses 1.

2.00

Computed live in your browser — the same math as the notebook. The dot marks the half-saturation point (\(\ln 2/\lambda\) for logistic, exactly \(\kappa\) for Hill at every \(n\)); the square on the marginal panel marks the slope there (\(\lambda/2\), resp. \(n/4\kappa\)). With logistic selected, the marginal curve starts at its peak \(f'(0) = \lambda\) and only falls — no λ produces a slow start. Switch to Hill and push \(n\) above 1: \(f'(0)\) pins to zero and the marginal curve humps at the inflection \(x^\star\) (triangle) before diminishing. At \(n = 1\) (Michaelis–Menten) the hump vanishes and \(f'(0) = 1/\kappa\).

4 · Why normalize the input before saturating

\(\lambda\) is a rate with units of 1/spend: the product \(\lambda x\) in \(e^{-\lambda x}\) must be dimensionless. If channel A spends in the thousands and channel B in the millions, the same numeric \(\lambda\) means completely different saturation behaviour — and a shared prior on \(\lambda\) would be incoherent across channels. The fix is to normalize each channel's (adstocked) spend to a common scale: divide by its own maximum, so \(x \in [0, 1]\). Then:

This is why the prior \(\lambda \sim \mathrm{Exponential}(0.5)\) (mean 2) is sensible only on normalized input: at \(\lambda = 2\) on \(x \in [0,1]\), the elbow \(x_{1/2} = \ln 2/2 \approx 0.347\) sits comfortably inside the observed range, where the data can actually inform the curve (asserted in the notebook). On raw spend it would be nonsense.

Computed live in your browser — the same math as the notebook's normalization argument. Left: one shared λ applied to two channels' raw spend — A in the thousands, B near millions. A rate that bends B sensibly leaves A trapped in its linear toe (\(f < 0.005\) over A's entire range — no curvature the data could ever identify); tune λ for A instead and B saturates within the first pixel. One number cannot serve two scales. Right: after dividing each channel by its own max, the two curves at \(\lambda = 2\) coincide exactly (the notebook asserts identity), and the shared half-saturation point sits at 0.347 of max spend.

⚠️ The flip side: max-normalization has a single point of failure

Dividing by the maximum makes the whole transform hostage to one cell of the spreadsheet. In the stress-test world spend_outliers (stress 01, act 3), a single ~15× data-entry spike per channel rescaled the normalizer so that the largest real week sat below ~0.07 of max — the saturation curve was only ever evaluated in its near-linear toe, the channel could not exhibit curvature, and recorded coverage fell to 0% with r-hat 1.01 and zero divergences. A 30-second max/median screen catches it before fitting; the sampler never will.

5 · Saturation × coefficient: the contribution ceiling

A channel's contribution to the outcome is the saturated response scaled by its coefficient \(\beta\) (model/base.py: channel_contrib = beta * x_saturated):

\[ \text{contribution}(x) \;=\; \beta \cdot f\big(\text{adstock}(x)\big). \]

Two consequences fall straight out of \(f \in [0, 1)\):

  1. \(\beta\) is a pure vertical scale. It stretches the whole curve; the shape (where the elbow is, how fast it bends) is owned entirely by \(\lambda\) (or \(\kappa, n\)). Timing/shape and size are cleanly separated — which is also why they can trade off against adstock (the identifiability theme of math 01: long-carryover + weak-saturation ≈ short-carryover + strong-saturation).
  2. A hard ceiling at \(\beta\). Since \(f(x) < 1\) strictly and \(f \to 1\), we get \(\beta f(x) < \beta\) for all finite \(x\), approaching \(\beta\) as \(x \to \infty\). So \(\beta\) is the channel's maximum achievable contribution — the most outcome you could ever buy from it at any budget. That cap is what makes "throw money at the best channel" a bounded, not unbounded, strategy.

Computed live in your browser — the same math as the notebook. \(\beta \cdot f(x)\) at \(\lambda = 2\) for four coefficients: every curve is the same shape stretched vertically (the notebook asserts contribution/β is identical across β), and each approaches — never reaches — its own dotted ceiling at \(\beta\).

End to end: spend → adstock → saturation → ×β

The full single-channel media response, composed exactly as the model does, in order:

\[ \underbrace{x_t}_{\text{spend}} \;\xrightarrow{\;\text{adstock}\;}\; \underbrace{a_t = \textstyle\sum_{k \ge 0} \alpha^{k} x_{t-k}}_{\text{carryover (math 01)}} \;\xrightarrow{\;\text{saturation}\;}\; \underbrace{f(a_t) = 1 - e^{-\lambda a_t}}_{\text{diminishing returns}} \;\xrightarrow{\;\times\,\beta\;}\; \underbrace{\beta\, f(a_t)}_{\text{contribution}_t}. \]

One subtlety carried over from math 01: the framework's geometric_adstock is the un-normalized recurrence whose impulse weights sum to \(1/(1-\alpha) > 1\), so a steady per-period spend \(s\) settles to the amplified level \(s/(1-\alpha)\) before saturation sees it (the notebook asserts the steady state: \(s/(1-\alpha) = 2.50\) at \(s = 1, \alpha = 0.6\)). Pushing that steady level through the curve gives the static response curve a planner reads to choose a budget — contribution as a function of steady weekly spend. Its half-response level solves \(s_{1/2}/(1-\alpha) = \ln 2/\lambda\), i.e. \(s_{1/2} = (1-\alpha)\ln 2/\lambda\) — 0.139 at the notebook's defaults (\(\alpha = 0.6, \lambda = 2\)), and the notebook asserts the composed curve is monotone increasing, concave, and bounded by \(\beta\).

0.60
2.00
3.00

Computed live in your browser — the same math as the notebook. The planner's diminishing-returns view: steady contribution \(\beta\,f\!\left(\frac{s}{1-\alpha}\right)\) against steady weekly spend \(s\). The dot marks the half-response spend \(s_{1/2} = (1-\alpha)\ln 2/\lambda\) (0.139 at the defaults); the dashed line is the \(\beta\) ceiling. Raising α makes the channel reach its ceiling at lower weekly spend — carryover amplifies the level saturation sees — while β only stretches the axis. Adstock, saturation and β each bend this curve in a way the others can partly imitate: that is the identifiability trade-off experiments are for (math 05).

6 · What the framework supports today

As of 2026-06-10, the core BayesianMMM honors a per-channel SaturationConfig in-graph:

SaturationConfig typeCurveFree RVs per channelShape
logistic (default) \(1 - e^{-\lambda x}\) sat_lam_<ch> (\(\mathrm{Exponential}(0.5)\) unless overridden) strictly concave
hill \(x^{n}/(x^{n} + \kappa^{n})\) sat_half_<ch>, sat_slope_<ch> hyperbolic (\(n=1\)) or S (\(n>1\))
michaelis_menten \(x/(x + \kappa)\) sat_half_<ch> concave (Hill with \(n\) fixed at 1)
tanh \(\tanh(x/\kappa)\) sat_half_<ch> concave
root \(x^{k}\), \(0<k<1\) sat_exponent_<ch> concave power
none identity linear (no ceiling)

The root curve, and two forms you already have

root (SaturationConfig.root()) is the classic econometric power-response curve \(f(x) = x^{k}\): with \(0 < k < 1\) it is strictly concave with an unbounded marginal at the origin — the diminishing-returns shape from log-log-style models, distinct from the elbow of the hyperbolic forms. Its exponent \(k\) is the sat_exponent_<ch> RV (default \(\mathrm{Beta}(2,2)\)).

Two shapes practitioners sometimes name separately are already here under other names: ADBUDG's S-curve \(x^{s}/(\kappa^{s}+x^{s})\) is exactly the hill form, and the exponential-CDF \(1 - e^{-\lambda x}\) is exactly the logistic default. So the common library is complete: use hill for ADBUDG and logistic for exponential-CDF.

🔧 This was a fix surfaced by the stress series

Before: the core model silently ignored per-channel MediaChannelConfig.saturation — the config could say hill while the model fit \(1 - e^{-\lambda x}\) anyway; Hill existed only as mmm_extensions components you had to assemble by hand. After (fixed 2026-06-10): the declared type is honored in-graph — SaturationConfig.hill() creates real sat_half_*/sat_slope_* random variables — and the default flipped to an explicit SaturationConfig.logistic() so default configs build a graph bit-identical to models from before the fix. On the Hill-truth stress world, the one-line pivot cut total-media bias from ≈ +40% to ≈ +15%. Full story and measured before/after: stress 01 · Carryover & Shape. One open edge remains: the holdout-validation path still falls back to a logistic curve for non-logistic channels, so holdout predictions for Hill-fit models are wrong without crashing (tracked on the pressure-testing scorecard).

Recap

The two saturation families, side by side:

\(f(x)\)\(f'(x)\)Half-satShape on \(x \ge 0\)Marginal at \(x = 0\)
Core (exp) \(1 - e^{-\lambda x}\) \(\lambda e^{-\lambda x}\) \(\ln 2/\lambda\) strictly concave \(\lambda\) (max, then ↓)
Hill, \(n = 1\) \(\frac{x}{x + \kappa}\) \(\frac{\kappa}{(x + \kappa)^2}\) \(\kappa\) concave (hyperbolic) \(1/\kappa\) (then ↓)
Hill, \(n > 1\) \(\frac{x^n}{x^n + \kappa^n}\) \(\frac{n \kappa^n x^{n-1}}{(x^n + \kappa^n)^2}\) \(\kappa\) S-shape (inflection) \(0\) (rises, then ↓)

Next in the series: math 03 builds the baseline these media curves sit on top of (Fourier seasonality and the trend families). To see these defaults fitted to data for the first time, take the beginner route through workshop 03 · Your First MMM. For the measured cost of choosing the wrong saturation form — with every convergence diagnostic green — see stress 01 · Carryover & Shape.

What to take away

1 · The core saturation \(f(x) = 1 - e^{-\lambda x}\) is strictly concave (\(f'' < 0\), no inflection) — the docstring's "S-shaped" is a misnomer the notebook corrects and asserts. The genuine S belongs only to Hill with \(n > 1\), whose marginal return starts at zero and humps at \(x^\star = \kappa((n-1)/(n+1))^{1/n}\) before diminishing.
2 · Half-saturation is \(\ln 2/\lambda\) for the core curve (assert-encoded: \(f(\ln 2/\lambda) = \tfrac12\)) and exactly \(\kappa\) for Hill at every \(n\); the slopes there are \(\lambda/2\) and \(n/4\kappa\).
3 · Normalize spend before saturating so \(\lambda\) is comparable across channels and the \(\mathrm{Exponential}(0.5)\) prior puts the elbow (≈ 0.35 of max at the prior mean) inside the observed range — but remember max-normalization is hostage to a single outlier week.
4 · With \(f \in [0,1)\), contribution \(\beta f\) has a hard ceiling at \(\beta\) — the channel's maximum achievable effect, and the reason an optimal budget is finite.
5 · Saturation family is a per-channel config choice the model now honors in-graph (default logistic); choosing wrong is a measured +38% silent failure, and a one-line Hill pivot is the measured fix.

Check your understanding

Commit to an answer before opening each one.

The core saturation is configured as logistic and an old docstring calls it "S-shaped". What is the actual curve, and what shape does it have?

The default is exponential saturation, f(x) = 1 − e−λx, which is strictly concave: the second derivative is negative everywhere, so there is no inflection point and marginal returns fall from the very first dollar. A genuine S-curve exists only in the Hill family with n > 1, whose inflection sits below the half-saturation point κ. Sections 1 and 2 derive both facts.

Why is the choice between the concave default and Hill with n > 1 a budgeting decision rather than a cosmetic one?

The two families disagree about the value of the next dollar at low spend: the concave curve is steepest at zero, while Hill with n > 1 has a marginal return near zero below the inflection — the "clear a threshold before the channel works" story, where small budgets can be worse than none and the optimal move is concentrate-or-exit. Fitting a true S-shaped world with the concave default over-credited total media by +38% while every diagnostic stayed green. Sections 2 and 3 work through this.

Why is each channel's adstocked spend normalized — divided by its own maximum — before the saturation curve is applied?

Because λ is a rate with units of one over spend, the same numeric λ means completely different curvature for a channel spending in the thousands versus one spending in the millions, so a shared prior would be incoherent. Normalizing to [0, 1] makes λ comparable across channels and places the Exponential(0.5) prior's elbow inside the observed range. The trade-off, covered in section 4, is that max-normalization is hostage to a single outlier week.

💡 Run it yourself

This page mirrors nbs/math/math_02_saturation.ipynb, authored by nbs/builders/build_math_02_saturation.py — every claim above ends in an assert against the framework's real logistic_saturation and geometric_adstock, so "the notebook runs clean" means "the math is right." Series guide: nbs/README_math.md. Source: github.com/redam94/mmm-framework.