Skip to content

chc.response

Stability tier: experimental.

A channel's response to its spend: how its return rises with what is spent, how long the spend carries over, and what the return is per unit spent.

Every family is a standard shape g of spend in units of its own scale K, h(spend) = g(spend / K), with K in currency and every other parameter dimensionless. A change of currency therefore moves K and nothing else, and every number read off a curve -- a value, a slope per unit of spend, an inflection or a tangency in spend -- moves with it exactly.

Bounded families (Saturation) start at h(0) = 0, increase and rise to a ceiling of 1; a channel's coefficient carries the size of the return, the curve only its shape.

  • Concave from zero spend: MichaelisMenten, Exponential (Mitscherlich's law, the monomolecular curve), Tanh, Arctan, Algebraic and HalfNormal.
  • A shape parameter, S-shaped over part of its range: Hill (the log-logistic), Weibull, Logistic and Gompertz, both normalised to 0 at 0, Richards, ChapmanRichards, GammaCDF, LogNormalCDF, BurrXII, and BetaCDF and Kumaraswamy, which reach the ceiling at K.

Unbounded baselines (Response): Logarithmic and Power, the constant elasticity. Not monotone, apart: Ricker, the inverted U of ad fatigue; nothing below about envelopes applies to it.

A floor is not a shape. At zero spend a floor is the plant's base, so Janoschek's curve is Weibull with a floor, and ADBUDG (Little 1970) and Morgan--Mercer--Flodin are Hill with one.

Why the envelope. On an S-shaped curve h'(0) = 0 wherever the curve starts convex, so zero spend on a channel is a stationary point, and a planner started at zero can stop there and report convergence. The concave envelope (Envelope) is the tangent from the origin up to the spend A where it touches the curve, h(A) = A h'(A), and the curve beyond it. Planned against, it has no such trap, its plan is a warm start for the true curve, and its value bounds the true optimum, so the gap between the two certifies the plan. relax swaps every such curve in a model for its envelope, and chc.plan.causal_plan plans the relaxed model first whenever it is given no warm start. The inflection and the tangency are in closed form where one exists and found as a root otherwise (validation/response_curves.mac).

Carryover (Adstock). A period's adstock is the spend of the length periods up to it, weighted by lag, a_t = sum_{l < L} w_l s_{t - l}, with nothing spent before the series: GeometricAdstock, DelayedAdstock, whose carryover peaks after the spend, and WeibullAdstock. The length is the kernel's own, never a share of the series, so logging another period moves no earlier value; normalized has no default, since packages differ on it and every coefficient fitted through the kernel moves with it. Channel is a channel's return, coefficient * curve(kernel(spend)), adstock first.

The return, in the outcome's units unless revenue_per_kpi turns a KPI into revenue: contribution, the channel's return in a window's periods; and per currency unit spent, roi, the incremental return on a window's spend, carryover included; marginal_roi, the return on one more unit spread over the window; and steady_state_marginal_roi, the slope of a period's return once the adstock of a constant spend has settled. A coefficient is none of them: through a long geometric kernel of retention r a unit of spend on a linear channel returns 1 / (1 - r) times what it returns in its own period, and on a saturating curve the return per unit moves with the spend.

PyMC-Marketing, mapped (scripts/pymc_marketing_reference.py runs its transforms beside these). geometric_adstock and delayed_adstock with l_max are GeometricAdstock and DelayedAdstock with length = l_max; the CDF weibull_adstock with l_max carries one weight more, WeibullAdstock with length = l_max + 1 and its lam as scale. LogisticSaturation, (1 - e^{-lam x}) / (1 + e^{-lam x}), is tanh(lam x / 2): concave from zero, so Tanh with K = 2 / lam, not Logistic. tanh_saturation with b and c is b times Tanh with K = b c, michaelis_menten with alpha and lam is alpha times MichaelisMenten with K = lam, hill_function is Hill, hill_saturation_sigmoid is a multiple of Logistic, and root_saturation is Power. Neither PyMC-Marketing nor Meridian is a dependency: each would bring a probabilistic-programming stack for a few closed forms, and neither has the envelope.

HONEST SCOPE:

  • Saturation.inflection, Saturation.tangency and Envelope read concrete parameters: a fitted curve, not one inside a trace. Every curve itself traces, differentiates and compiles, and its parameters are leaves a fit can move. So does every kernel.
  • BetaCDF has no derivative in its shapes a and b, since JAX's betainc has none; asking for one raises. Kumaraswamy is its closed-form counterpart.
  • Spend is nonnegative. Below zero a family's value is not defined, and several return nan.
  • A curve that rises from zero spend like z^m has its slopes of order above m infinite there unless m is whole: the slope itself below shape 1 (Hill, Weibull, ChapmanRichards, BurrXII, Kumaraswamy, GammaCDF, BetaCDF, and Power), the curvature below shape 2. Each is read a machine epsilon of the scale off zero instead: finite, so a zero weight on it, as in a period no spend reaches, is nothing rather than nan, and the slope still steeper there than anywhere past it. A number read through one, such as a decision weight in a parameter that turns that spend on, is large and finite, not infinite. Every slope finite at zero spend is the curve's own.
  • In single precision the numeric tangencies are good to single precision.
  • The readings of the return take concrete numbers: they report on a fitted channel, not on one inside a trace. The carryover they count is what falls inside the series; to count the rest, extend the series with what is spent after it.
  • Meridian's forms are not mapped yet, nor run beside these.

Response

Bases: Module

A response curve: the return on a channel as a function of its spend, zero at zero spend.

h(spend) = standard(spend / scale), with scale in currency.

standard(z) abstractmethod

The curve with spend in units of scale.

Saturation

Bases: Response

A bounded response: zero at zero spend, increasing, and rising to a ceiling of 1.

inflection()

Where the curve turns from convex to concave, in spend; 0 if it is concave from zero.

Below it h'' > 0, so a plan's first-order conditions do not certify it there.

tangency()

The spend where the tangent from the origin touches the curve; 0 if it is concave.

For a curve convex up to the end of its support, that end, where the chord from the origin meets the ceiling at a corner: the beta and Kumaraswamy CDFs at b = 1.

nonconvexity()

The most the envelope rises above the curve, max (envelope - curve); 0 if concave.

A number of the shape alone, on the curve's scale of a ceiling of 1, so a change of currency leaves it. A plan made on envelopes loses at most a channel's coefficient times this where it leaves the channel inside its chord (chc.allocation). Hill's at slope 2, 3 and 5 is 0.0674, 0.1547 and 0.2920; it rises to 1, a step's, as the slope grows, and falls as 0.1619 (n - 1)^2 as the slope n falls to 1, where the curve turns concave (validation/envelope_nonconvexity.mac).

Envelope

Bases: Saturation

The concave envelope of a saturation curve: the least concave curve on or above it.

Linear from the origin to the tangency A, where h(A) = A h'(A), and the curve beyond it; the curve itself when it is concave. Planned against, it bounds the return any plan on the curve can reach, and its plan is the warm start that keeps a planner off zero spend.

MichaelisMenten

Bases: Saturation

z / (1 + z): half the ceiling at K. Hill at slope 1.

Exponential

Bases: Saturation

1 - e^{-z}: Mitscherlich's law, the monomolecular curve; 1 - 1/e of the ceiling at K.

Tanh

Bases: Saturation

tanh(z). PyMC-Marketing's LogisticSaturation with lam is this with K = 2 / lam.

Arctan

Bases: Saturation

(2/pi) arctan(z): half the ceiling at K, and the slowest approach to it here.

Algebraic

Bases: Saturation

z / sqrt(1 + z^2), which is x / sqrt(K^2 + x^2) in spend.

HalfNormal

Bases: Saturation

erf(z): the half-normal CDF with standard deviation K / sqrt(2).

Hill

Bases: Saturation

z^n / (1 + z^n): half the ceiling at K, the log-logistic CDF (Hill 1910).

S-shaped for slope > 1, with inflection ((n - 1)/(n + 1))^{1/n} and tangency (n - 1)^{1/n}; MichaelisMenten at slope = 1. With a floor it is ADBUDG and Morgan--Mercer--Flodin.

Weibull

Bases: Saturation

1 - exp(-z^k): the Weibull CDF, 1 - 1/e of the ceiling at K.

S-shaped for shape > 1, with inflection ((k - 1)/k)^{1/k}; the tangency is u^{1/k} at the positive root of e^u - 1 = k u. Exponential at shape = 1; with a floor, Janoschek's curve (1957).

Logistic

Bases: Saturation

The logistic 1 / (1 + e^{-s (z - 1)}) moved and stretched to run from 0 at zero spend to 1, with its midpoint and inflection at K. Always S-shaped.

Gompertz

Bases: Saturation

Gompertz's exp(-b e^{-z}) moved and stretched to run from 0 at zero spend to 1.

b is the displacement: the inflection is at log b, so the curve is S-shaped for b > 1 and concave from zero otherwise.

Richards

Bases: Saturation

Richards' generalised logistic (1 + nu e^{-s (z - 1)})^{-1/nu}, normalised to 0 at 0.

The inflection is at K for every asymmetry nu. Logistic at nu = 1; as nu -> 0 it tends to Gompertz with b = e^s and scale K / s.

ChapmanRichards

Bases: Saturation

(1 - e^{-z})^p, the Chapman--Richards growth curve.

S-shaped for power > 1, with inflection log p; the tangency is the positive root of e^z - 1 = p z. Exponential at power = 1.

GammaCDF

Bases: Saturation

The gamma CDF with unit rate, P(a, z). S-shaped for shape > 1, with inflection at the mode a - 1; Exponential at shape = 1.

LogNormalCDF

Bases: Saturation

Phi(log z / sigma): the log-normal CDF with median K. Always S-shaped, with inflection at the mode e^{-sigma^2}.

BurrXII

Bases: Saturation

1 - (1 + z^c)^{-k}, the Burr XII CDF, with slope c and tail k.

S-shaped for slope > 1, with inflection ((c - 1)/(c k + 1))^{1/c}. Hill at tail = 1; the tail sets how slowly the last of the ceiling arrives, as z^{-c k}.

BetaCDF

Bases: Saturation

The beta CDF I_z(a, b) on [0, K], and the ceiling beyond K.

S-shaped for a > 1, with inflection at the mode (a - 1)/(a + b - 2); at b = 1 it is z^a, convex up to K, where it touches its envelope. b >= 1, since below 1 the curve would rise ever faster into its ceiling. No derivative in a or b (JAX's betainc has none); Kumaraswamy has them.

Kumaraswamy

Bases: Saturation

1 - (1 - z^a)^b on [0, K], and the ceiling beyond K: Kumaraswamy's closed-form counterpart of BetaCDF.

S-shaped for a > 1, with inflection ((a - 1)/(a b - 1))^{1/a}; b >= 1, as for the beta CDF.

Logarithmic

Bases: Response

log(1 + z): concave and unbounded, a baseline.

Power

Bases: Response

z^rho, 0 < rho <= 1: constant elasticity rho, concave and unbounded. PyMC-Marketing's RootSaturation.

Ricker

Bases: Response

z e^{1 - z}: rises to its peak of 1 at K and falls after it, the inverted U of ad fatigue. Not monotone, so no envelope, and a plan's concavity arguments do not reach it.

Adstock

Bases: Module

A carryover kernel: a_t = sum_{l < L} w_l s_{t - l}, the spend of the length periods up to a period weighted by lag, with nothing spent before the series.

A period's adstock reads those L periods and no others, so appending periods to a series moves no earlier value, and normalized divides by the kernel's own sum, never the series'. It has no default: packages differ on it, and every coefficient fitted through the kernel moves with it.

weights()

The (length,) weights, lag 0 first.

__call__(spend)

The adstock of a (T,) spend series, or of each column of a (T, C) one.

GeometricAdstock

Bases: Adstock

w_l = r^l: each period keeps retention of the carryover it received.

DelayedAdstock

Bases: Adstock

w_l = r^{(l - d)^2}: the carryover peaks delay periods after the spend (Jin, Wang, Sun, Chan and Koehler 2017).

WeibullAdstock

Bases: Adstock

w_0 = 1 and w_l = prod_{j=1}^{l} (1 - F(j)), F the Weibull CDF of shape and scale: the share of the carryover that survives each lag. The scale is in periods, not a share of the series.

Channel

Bases: Module

A channel's return on its spend, coefficient * curve(kernel(spend)): adstock first.

coefficient is in the outcome's units per unit of the curve, so the return is in the outcome's units, revenue or a KPI.

relax(model)

model with every saturation curve in it that starts convex replaced by its envelope.

model is any pytree holding curves: a plant, a tuple of curves, one curve. A curve concave from zero is left as it is, and when no curve starts convex the answer is model itself, so relax(model) is model says there is nothing to relax. chc.plan.causal_plan reads it that way, and starts its descent from the relaxed problem's plan.

contribution(channel, spend, window=slice(None), *, revenue_per_kpi=1.0)

The channel's return in the window's periods, carried over from all the spend before them.

What a decomposition attributes to the channel in those periods, in the outcome's units, or in revenue through revenue_per_kpi. The return the window's own spend causes, wherever it falls, is instead roi times what the window spent.

roi(channel, spend, window=slice(None), *, revenue_per_kpi=1.0)

The incremental return on the window's spend, per currency unit, carryover included.

The channel's return over the whole series, less its return with the window's spend removed, over what the window spent. channel is any function of a (T,) spend series returning a (T,) series of returns. The carryover counted is what falls inside the series: to count all of it, extend the series by the kernel's length with what is spent after. revenue_per_kpi turns a KPI into revenue; at 1 the outcome is revenue.

marginal_roi(channel, spend, window=slice(None), *, revenue_per_kpi=1.0)

The return on one more currency unit, spread over the window in proportion to its spend.

The derivative of the channel's return over the whole series along the window's spend, over what the window spent: the limit of scaling the window's spend by 1 + e, where a step of finite size would move the number on a curved response. Carryover is counted as in roi.

steady_state_marginal_roi(channel, level, *, revenue_per_kpi=1.0)

The return on one more currency unit a period, once level a period has been spent for longer than the kernel remembers.

The adstock settles at level * sum(w), so a period's return is b h(level * sum(w)) and its slope in the level b h'(level * sum(w)) sum(w): through a long unnormalised geometric kernel of retention r, b h'(level / (1 - r)) / (1 - r).