Skip to content

chc.lift

Stability tier: experimental.

Lift tests read as structure: a geo test's gap between its groups, period by period, identifies a channel's carryover, the shape of its curve and its size, over the spend the test covered. Experimental.

A geo test changes one channel's spend in one group of regions and leaves another group spending as planned. The groups share everything else -- the season, the other channels, promotions, every demand shock nobody observed -- so the difference of their outcomes is the channel's own::

d_t = M(s_treated)_t - M(s_control)_t + nu_t,

M the channel's return on a spend path (a chc.response.Channel) and nu_t the groups' own noise (Heusch 2026a). Every parameter of M is in it: the gap's rise when the spend changes and its decay after carry the carryover, tests at different spend levels carry the curve's shape, and the gap's size carries the coefficient. A test reduced to one number, its total lift over its total spend, keeps only the last.

fit_lift fits the equation by least squares over every period of every test, with one noise scale across them, and gives each parameter a profile-likelihood interval with an F cutoff (Bates and Watts 1988): exact for a model linear in its parameters and, unlike a Wald interval, it follows a likelihood that curves. A parameter the tests do not pin runs to its bound, and its interval says so: a curve the tests never bent has no upper scale.

The curve is identified over the adstock the tests covered, LiftFit.tested_adstock, and nowhere else. Past it a plan reads the family's shape, not the experiment.

check_observational reads a channel fitted to observational data, where spend follows the business, against the tests a fit read: an F test of whether the tests' gaps could be the channel's, and the factor of the lift it predicts that the tests read. Through an analyst's effect-scale gap the factor gives the least marginal-sensitivity Gamma the tests leave the observational channel, a floor under the confounding a plan assumes (De Bartolomeis et al. 2024 bound Gamma from below with a trial in the same way).

HONEST SCOPE:

  • The noise is one scale, independent across periods and tests. A gap read through a synthetic control or a regression carries an error common to its periods, which this does not model; the intervals are then too narrow.
  • The groups spend alike on every other channel and answer alike, so a group's spend and outcome divided by its share of the market are the market's (Heusch's scaling).
  • The check's F is the region the intervals are profiles of: exact for a model linear in its parameters, approximate on a curve. Its Gamma bounds the tested channel's confounding; read for a channel no test reached, it assumes the two are confounded alike.
  • The arithmetic is JAX's, as for the library's other JAX estimators: float64 under jax_enable_x64, and float32, good to about seven digits, without it.

GeoArm dataclass

One group of a geo test: the tested channel's spend and the group's outcome. Experimental.

spend runs over the periods before the readout and then the readout, outcome over the readout. A period's adstock reads the spend of the kernel's length up to it, so the readout's first periods need the spend before them: a window cut from a longer series, with nothing before it, understates the adstock at its start (Heusch 2026b). share is the group's share of the market the channel is modelled at; its spend and outcome are divided by it.

Raises:

Type Description
ValueError

on an empty or non-finite series, a negative spend, a spend shorter than the readout, or a share outside (0, 1].

history property

The periods of spend before the readout.

LiftTest dataclass

A geo test of one channel: a treated group whose spend on it changed and a control group that spent as planned, alike on every other channel. Experimental.

Raises:

Type Description
ValueError

when the groups' spend or readout cover different numbers of periods.

difference property

The treated group's outcome less the control's, each at the market's scale.

LiftFit dataclass

A channel fitted to lift tests' gaps, with an interval for each parameter. Experimental. See fit_lift.

interval(parameter)

parameter's interval, by its name in parameters.

ObservationalCheck dataclass

An observational channel read against the lift tests a fit read. Experimental. See check_observational.

rejected property

Whether the tests reject the channel at level.

least_gamma(cvar_gap)

The least marginal-sensitivity Gamma whose identified set reaches the tests.

In units of the lift the channel predicts, the set is 1 ± (Gamma-1)/(Gamma+1) cvar_gap, chc.sensitivity's radius, cvar_gap the analyst's effect-scale CVaR gap as a share of that lift. The least Gamma whose set reaches factor_interval inverts the radius at the interval's distance from 1, as chc.barrier.barrier_gamma_star does. Less confounding than this the tests refute at level, as a known-null outcome refutes it in chc.uncertainty.negative_control_gamma: a floor, not a ceiling.

1.0 when the interval holds 1; inf when it lies cvar_gap or further from 1, so that no level reconciles the two and the tests refute the model rather than calibrate it; nan when the channel predicts no gap.

Raises:

Type Description
ValueError

when cvar_gap is not positive and finite.

fit_lift(tests, channel, *, level=0.95)

Fit channel to the tests' gaps by least squares, each parameter with a profile-likelihood interval. Experimental.

channel names the families, its kernel and its curve, and its kernel's and curve's values are where the fit starts, so give a scale near the spend the tests saw. Every parameter is fitted: a coefficient of either sign, so a channel that loses sales and one that does nothing are both answers; a retention in (0, 1] and each scale and shape as its logarithm, within a factor of e^40 of its start. The coefficient is never searched: the gap is linear in it, so wherever the rest moves it takes its least-squares value there (variable projection, Golub and Pereyra 1973), and the template's own is ignored. The periods of every test are stacked, with one noise variance, estimated.

Each interval holds the values whose profile, the least cost with that value held, lies within s^2 F_{1, n - p}(level) of the least cost, s^2 the noise variance over n - p degrees of freedom. A walk out from the estimate finds each end and a root-finder sharpens it; an end the tests leave open is the parameter's bound, or 0 or inf for a retention, a scale or a shape that moves a factor of e^20 without reaching the cutoff. Should the walk find a point cheaper than the fit by a millionth of the cutoff, the fit restarts from there.

Where the least cost lies on the way to a bound, the estimate is where the fit stopped on that way. Tests that never bent the curve are fitted best by a line, which a saturating family reaches only as its scale and coefficient grow without end; noise the size of the effect can be fitted best by a step, a curve saturated at any spend with a carryover gone within a period, which the family reaches as the scale and the retention fall together. The estimate predicts the gaps as the limit does, and its interval, open on the limit's side, is the answer about the parameter.

Raises:

Type Description
TypeError

when channel is not a chc.response.Channel.

ValueError

on no tests, level outside (0, 1), a test with fewer periods of spend before its readout than the kernel reads, fewer periods than parameters, a template whose retention, scale or shape is not positive, or a gap whose slope in a parameter is not finite.

RuntimeError

when a solve does not converge within 10 000 evaluations, or the profile keeps finding points cheaper than the fit.

check_observational(fit, observed)

Read observed, a channel fitted to observational data, against the tests fit read. Experimental.

Two readings of the tests' gaps d, at the fit's level and with its noise variance s^2 over n - p degrees of freedom:

  • An F test of observed as a point of the fit's model: the least squares of the gaps it predicts over the fit's, per parameter, over s^2, against F_{p, n-p} (Bates and Watts 1988), the joint region the fit's intervals are profiles of.
  • The factor, the least-squares multiple of the gaps it predicts, g, that the tests read: c = g.d / g.g, with the t interval c ± t s / |g|. It is the tested lift over the lift observed predicts, each period weighted by what it predicts there, so tests of either sign add rather than cancel; 1 when the lift is the channel's.

The F rejects a channel whose shape the tests contradict even where its size is right; the factor says how far its size is off, and ObservationalCheck.least_gamma turns that into a floor under the confounding. Where spend follows the business an observational fit reads the business's decisions as the channel's effect, and the tests are what can say so: on Heusch's generator it reads paid shopping's return at 8.45 against a true 4.20, with an oracle's controls (Heusch 2026a).

Raises:

Type Description
TypeError

when observed is not a chc.response.Channel.

ValueError

when observed is not of the fit's families, kernel length and form, so the fit's model does not hold it; when it predicts a gap that is not finite; when the fit reproduces the tests exactly, leaving no noise to test against; or when observed fits the tests better than the fit by more than the fit tells apart, so the fit is not their least squares, and a fit from it, fit_lift(fit.tests, observed), is due.