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 lag theta.

  • 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 w has length l_max and is indexed by lag, so w[0] weights the current period and the adstocked series is y[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. <1 front-loads, 1 is exponential, >1 produces 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=True the kernel sums to 1, so the total carryover magnitude is folded into the channel coefficient beta rather than the kernel. The decay shape (alpha/theta/shape), the saturation strength, and beta then 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 on alpha/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 pin beta and thereby break the trade-off. Setting normalize=False keeps 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 as x.

Parameters

xNDArray[np.floating]

Input series, shape (n_periods,).

weightsNDArray[np.floating]

Lag-indexed kernel from adstock_weights(), length l_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(). See adstock_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 < 1 this is the classic concave power-response curve — diminishing returns that fall off as a fixed power of (adstocked, normalized) spend. exponent = 1 is linear; exponent > 1 is 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. Use 0 < k < 1 for 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] so f stays in [0, 1]. The marginal f'(x) = k * x**(k-1) is unbounded at x = 0 for k < 1, which the model’s in-graph form guards against by clamping x away 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 changepoint s[j]. The full trend is

trend(t) = (k + A[t] @ delta) * t + (m + A[t] @ gamma), with gamma[j] = -s[j] * delta[j],

where: - k is the base growth rate (slope before any changepoint), - m is the offset (level at t = 0), - delta are the per-changepoint slope adjustments (often with sparse priors), - gamma is the offset correction that keeps the trend continuous at each

changepoint (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] @ delta accumulates the slope changes of every changepoint at or before t. Note the cumulative slope multiplies t – using A @ delta as 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.