Transforms¶
The transforms module provides transformation functions used in Marketing Mix Models, including adstock (carryover effects), saturation curves, seasonality, and trend features.
Adstock Transformations¶
Adstock (carryover effect) transformations for marketing data.
Adstock models the lagged effect of marketing activities. The classic
geometric adstock assumes the effect of advertising decays monotonically
from the period of spend, with parameter alpha controlling the decay rate.
Geometric adstock cannot represent a delayed peak (an effect that builds for a few periods before decaying), which is common for brand/TV/video/OOH media. Two richer weight shapes are provided for that case:
Delayed geometric (Jin et al., 2017):
w_k = alpha ** ((k - theta) ** 2), which places the peak weight at lagtheta.Weibull (PDF form): flexible decay whose peak can sit at lag 0 (shape < 1), at lag 0 (shape == 1, exponential), or be delayed (shape > 1).
All three are finite-impulse-response (FIR) convolutions that differ only in the
lag-indexed weight vector; adstock_weights() builds that vector and
apply_adstock() convolves a series with it. w_0 always multiplies the
current period, so geometric weights peak at lag 0 while delayed/Weibull weights
can peak later.
- mmm_framework.transforms.adstock.geometric_adstock(x, alpha)[source]¶
Apply geometric adstock transformation to a 1D array.
- Implements the recurrence relation:
y[t] = x[t] + alpha * y[t-1]
where y[0] = x[0].
This models the carryover effect of marketing activities, where past spending continues to have an effect in future periods, decaying exponentially with rate alpha.
Parameters¶
- xNDArray[np.floating]
Input time series (e.g., media spend), shape (n_periods,).
- alphafloat
Decay rate parameter in [0, 1). Higher values mean slower decay (longer-lasting effects). alpha=0 means no carryover.
Returns¶
- NDArray[np.floating]
Adstocked time series, same shape as input.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import geometric_adstock >>> >>> # Single pulse of spend >>> spend = np.array([100.0, 0.0, 0.0, 0.0, 0.0]) >>> adstocked = geometric_adstock(spend, alpha=0.5) >>> print(adstocked) [100. 50. 25. 12.5 6.25]
Notes¶
The sum of the adstock weights is 1/(1-alpha), so the total effect of a unit spend is scaled by this factor. For alpha=0.5, total effect is 2x the immediate effect.
- mmm_framework.transforms.adstock.geometric_adstock_2d(X, alpha)[source]¶
Apply geometric adstock to a 2D array (multiple channels).
Applies the geometric adstock transformation independently to each column (channel) of the input matrix.
Parameters¶
- XNDArray[np.floating]
Input matrix of shape (n_periods, n_channels).
- alphafloat
Decay rate parameter in [0, 1).
Returns¶
- NDArray[np.floating]
Adstocked matrix, same shape as input.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import geometric_adstock_2d >>> >>> # Two channels with different spend patterns >>> X = np.array([ ... [100.0, 50.0], ... [0.0, 50.0], ... [0.0, 0.0], ... ]) >>> adstocked = geometric_adstock_2d(X, alpha=0.5)
See Also¶
geometric_adstock : 1D version of this function.
- mmm_framework.transforms.adstock.adstock_weights(kind, l_max, *, alpha=0.5, theta=0.0, shape=2.0, scale=2.0, normalize=True)[source]¶
Build the lag-indexed weight vector for an FIR adstock kernel.
The returned array
whas lengthl_maxand is indexed by lag, sow[0]weights the current period and the adstocked series isy[t] = sum_k w[k] * x[t - k].Parameters¶
- kind{“geometric”, “delayed”, “weibull”, “none”}
Kernel shape.
"none"returns a unit impulse (no carryover).- l_maxint
Number of lags (kernel length).
- alphafloat
Decay rate in [0, 1) for
"geometric"and"delayed".- thetafloat
Peak/delay lag for
"delayed"(0 reproduces geometric).- shapefloat
Weibull shape
k.<1front-loads,1is exponential,>1produces a delayed peak.- scalefloat
Weibull scale
lambda(controls how far out the mass spreads).- normalizebool
If True, weights sum to 1 (the kernel becomes a weighted moving average and total spend magnitude is absorbed into the coefficient).
Notes¶
Equifinality / identifiability (critique.md §3.6). When
normalize=Truethe kernel sums to 1, so the total carryover magnitude is folded into the channel coefficientbetarather than the kernel. The decay shape (alpha/theta/shape), the saturation strength, andbetathen trade off against one another: a long-carryover/weak-saturation fit and a short-carryover/strong-saturation fit can be nearly indistinguishable in-sample. This is inherent to additive MMM, not a bug, but it means per-channel decay and saturation parameters are only weakly identified from observational data. Mitigations: informative priors onalpha/saturation, anchoring the half-saturation point to data percentiles (SaturationConfig.compute_kappa_bounds_from_data()for the Hill path), and – most importantly – experiment-calibrated coefficient priors (mmm_framework.calibration), which pinbetaand thereby break the trade-off. Settingnormalize=Falsekeeps magnitude in the kernel but does not remove the shape/saturation entanglement.Returns¶
- NDArray[np.floating]
Weight vector of length
l_max.
- mmm_framework.transforms.adstock.apply_adstock(x, weights)[source]¶
Convolve a 1D series with an FIR adstock kernel (causal).
Computes
y[t] = sum_k weights[k] * x[t - k]with zero padding before the start of the series, so the output has the same length asx.Parameters¶
- xNDArray[np.floating]
Input series, shape
(n_periods,).- weightsNDArray[np.floating]
Lag-indexed kernel from
adstock_weights(), lengthl_max.
Returns¶
- NDArray[np.floating]
Adstocked series, same shape as
x.
- mmm_framework.transforms.adstock.parametric_adstock(x, kind, l_max=8, *, alpha=0.5, theta=0.0, shape=2.0, scale=2.0, normalize=True)[source]¶
Apply a geometric, delayed, or Weibull FIR adstock to a 1D series.
Convenience wrapper around
adstock_weights()+apply_adstock(). Seeadstock_weights()for the meaning of each parameter.
Saturation Transformations¶
Saturation curve transformations for marketing response modeling.
Saturation curves model diminishing returns from marketing activities. As spend increases, the incremental effect decreases, eventually reaching a saturation point where additional spend has minimal impact.
- mmm_framework.transforms.saturation.logistic_saturation(x, lam)[source]¶
Apply logistic saturation transformation.
- Implements the transformation:
f(x) = 1 - exp(-lam * x)
for x >= 0, with negative values clipped to 0.
This creates an S-shaped response curve that: - Starts at 0 when x=0 - Increases rapidly for small x - Asymptotically approaches 1 as x -> infinity
Parameters¶
- xNDArray[np.floating]
Input values (e.g., normalized media spend). Negative values are clipped to 0.
- lamfloat
Saturation rate parameter. Higher values cause faster saturation. lam > 0 is required for valid behavior.
Returns¶
- NDArray[np.floating]
Saturated values in the range [0, 1).
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import logistic_saturation >>> >>> x = np.array([0.0, 0.5, 1.0, 2.0, 5.0, 10.0]) >>> saturated = logistic_saturation(x, lam=1.0) >>> print(saturated.round(3)) [0. 0.393 0.632 0.865 0.993 1. ]
Notes¶
This is sometimes called “exponential saturation” in the literature. The half-saturation point (where f(x) = 0.5) occurs at x = ln(2)/lam.
For modeling purposes, the input x is typically normalized (e.g., by dividing by max spend) so that lam can be interpreted consistently across channels.
See Also¶
Hill saturation is another common choice, implemented in the PyMC model via pm.math operations.
- mmm_framework.transforms.saturation.root_saturation(x, exponent)[source]¶
Apply root / power saturation transformation.
- Implements the transformation:
f(x) = x ** exponent
for x >= 0, with negative values clipped to 0. With
0 < exponent < 1this is the classic concave power-response curve — diminishing returns that fall off as a fixed power of (adstocked, normalized) spend.exponent = 1is linear;exponent > 1is convex (increasing returns, not saturation).Parameters¶
- xNDArray[np.floating]
Input values (e.g., normalized media spend). Negative values are clipped to 0.
- exponentfloat
The power
k. Use0 < k < 1for a saturating (concave) curve.
Returns¶
- NDArray[np.floating]
Saturated values
x ** exponent.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import root_saturation >>> >>> x = np.array([0.0, 0.25, 0.5, 1.0]) >>> root_saturation(x, exponent=0.5).round(3) array([0. , 0.5 , 0.707, 1. ])
Notes¶
Unlike the logistic/Hill forms this curve has no finite asymptote; it is typically applied to spend normalized into
[0, 1]sofstays in[0, 1]. The marginalf'(x) = k * x**(k-1)is unbounded atx = 0fork < 1, which the model’s in-graph form guards against by clampingxaway from 0.See Also¶
logistic_saturation : exponential-CDF saturation
1 - exp(-lam * x).
Seasonality Features¶
Seasonality feature creation for time series modeling.
Provides functions to create periodic features (Fourier terms) that capture seasonal patterns in time series data.
- mmm_framework.transforms.seasonality.create_fourier_features(t, period, order)[source]¶
Create Fourier features for capturing seasonality.
Generates sine and cosine features at multiple harmonics of the specified period. This is the standard approach for modeling periodic patterns in time series (e.g., weekly, yearly seasonality).
Parameters¶
- tNDArray[np.floating]
Time index values. Can be any numeric scale (e.g., week numbers, day of year, etc.).
- periodfloat
The fundamental period length in the same units as t. For example, if t is in weeks, period=52 captures yearly seasonality.
- orderint
Number of Fourier terms (harmonics) to include. Higher order captures more complex seasonal patterns but may overfit. order=0 returns an empty array.
Returns¶
- NDArray[np.floating]
Feature matrix of shape (len(t), 2 * order). Columns are [sin_1, cos_1, sin_2, cos_2, …, sin_order, cos_order]. Returns shape (len(t), 0) if order=0.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import create_fourier_features >>> >>> # Weekly data with yearly seasonality >>> weeks = np.arange(104) # 2 years of data >>> features = create_fourier_features(weeks, period=52.0, order=3) >>> print(features.shape) (104, 6) >>> >>> # Values repeat after one period >>> np.allclose(features[0], features[52]) True
Notes¶
- The Fourier features at order k are:
sin(2 * pi * k * t / period) cos(2 * pi * k * t / period)
Using both sine and cosine allows the model to capture phase shifts in the seasonal pattern.
For most applications: - order=3-4 is sufficient for smooth seasonal patterns - order=6-10 can capture more complex patterns - Very high order risks overfitting and should be used with
regularization
See Also¶
Prophet (Facebook) uses this same approach for seasonality modeling.
Trend Features¶
Trend modeling utilities for time series.
Provides functions to create basis matrices for flexible trend modeling, including B-splines and piecewise linear (Prophet-style) trends.
- mmm_framework.transforms.trend.create_bspline_basis(t, n_knots, degree=3)[source]¶
Create B-spline basis matrix for flexible trend modeling.
B-splines are piecewise polynomial functions that provide a smooth, flexible way to model trends. The basis functions are localized, which helps with interpretability and numerical stability.
Parameters¶
- tNDArray[np.floating]
Time values, should be scaled to [0, 1] for best results.
- n_knotsint
Number of interior knots. More knots allow more flexibility but risk overfitting.
- degreeint, default=3
Spline degree. degree=3 gives cubic splines, which are smooth through the second derivative.
Returns¶
- NDArray[np.floating]
Basis matrix of shape (len(t), n_knots + degree + 1). Each column is a B-spline basis function evaluated at t.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import create_bspline_basis >>> >>> t = np.linspace(0, 1, 100) >>> basis = create_bspline_basis(t, n_knots=5, degree=3) >>> print(basis.shape) (100, 9) >>> >>> # Basis sums to 1 (partition of unity) >>> np.allclose(basis.sum(axis=1), 1.0) True
Notes¶
The basis is “clamped” at the boundaries, meaning the first and last basis functions are 1 at t=0 and t=1 respectively. This ensures the fitted curve passes through the endpoint predictions.
In a Bayesian model, coefficients for each basis function are given priors, and the posterior captures uncertainty in the trend.
Raises¶
- ImportError
If scipy is not installed.
See Also¶
create_piecewise_trend_matrix : Alternative trend representation.
- mmm_framework.transforms.trend.create_piecewise_trend_matrix(t, n_changepoints, changepoint_range=0.8)[source]¶
Create design matrix for piecewise linear trend (Prophet-style).
This approach models the trend as a piecewise linear function with potential changepoints. At each changepoint, the slope can change, allowing the trend to capture shifts in growth rates.
Parameters¶
- tNDArray[np.floating]
Time values, should be scaled to [0, 1].
- n_changepointsint
Number of potential changepoints. The actual number used is determined by the model (sparse priors can shrink some to zero).
- changepoint_rangefloat, default=0.8
Proportion of the time range where changepoints can occur. Default 0.8 means changepoints only in the first 80% of data, which helps avoid overfitting to recent observations.
Returns¶
- tuple[NDArray[np.floating], NDArray[np.floating]]
A tuple (s, A) where s is the array of changepoint locations with shape (n_changepoints,), and A is the design matrix with shape (len(t), n_changepoints). A[i, j] = 1 if t[i] >= s[j], else 0.
Examples¶
>>> import numpy as np >>> from mmm_framework.transforms import create_piecewise_trend_matrix >>> >>> t = np.linspace(0, 1, 100) >>> s, A = create_piecewise_trend_matrix(t, n_changepoints=5) >>> print(s.shape) (5,) >>> print(A.shape) (100, 5)
Notes¶
This matrix drives the Facebook Prophet piecewise-linear trend, in which each
delta[j]is a change in the growth rate (slope) that takes effect at changepoints[j]. The full trend istrend(t) = (k + A[t] @ delta) * t + (m + A[t] @ gamma), with gamma[j] = -s[j] * delta[j],
where: -
kis the base growth rate (slope before any changepoint), -mis the offset (level at t = 0), -deltaare the per-changepoint slope adjustments (often with sparse priors), -gammais the offset correction that keeps the trend continuous at eachchangepoint (without it, changing the slope would introduce a jump).
The design matrix A implements the indicator
A[t, j] = 1 if t >= s[j] else 0,
so
A[t] @ deltaaccumulates the slope changes of every changepoint at or beforet. Note the cumulative slope multipliest– usingA @ deltaas a bare additive term instead (omitting the* t) yields piecewise-constant level shifts, not the intended slope changes.See Also¶
create_bspline_basis : Alternative smooth trend representation.