PyMC-Marketing: Bayesian marketing mix modeling in Python, explained and tested

PyMC-Marketing is PyMC Labs' open-source toolbox for Bayesian marketing analytics, and its MMM is the most flexible of the big three libraries. Version 1.0 in August 2026 reorganized the API, so most tutorials online are now out of date.

Mike Taylor By Mike Taylor · Updated · 11 min read

PyMC-Marketing is an open-source Python library from PyMC Labs for Bayesian marketing analytics, and its marketing mix model is the most configurable of the major open-source MMMs: You choose the adstock and saturation functions, the priors, any panel dimensions such as geography, whether effects vary over time, and how to fold in experiment results, and PyMC samples the whole thing with MCMC. It also covers customer lifetime value, customer choice and Bass diffusion models, but most people arrive for the PyMC-Marketing mix model, which is what this page is about.

The library has changed a lot recently, which is the main reason I wanted to write this up carefully. Version 1.0.0 shipped on 7 August 2026, described by the team as their stable API after two years of 0.x releases, and it made the “multidimensional” MMM the only MMM, removed the old class most tutorials use, and moved to PyMC 6. Version 1.3.0 followed on 22 October 2026. I’ve read the source for the current release and run the example below against it, so the function names and defaults here are the ones you’ll actually get. If you want the theory first, the Bayesian MMM page explains priors and posteriors, and the overview of what marketing mix modeling is covers the basics. I’ll show you the model, its defaults (with an explorer that uses the same maths), and how to calibrate and optimize it.

What PyMC-Marketing is and how the 1.x API is organized

PyMC Labs is the consultancy formed around the core developers of PyMC, the Python probabilistic programming library, and the repository dates from March 2022. Its MMM started from the same Google paper as Meridian (Jin et al. 2017), and the README describes extending that approach with the expertise of core PyMC developers. The license is Apache 2.0, and the current release needs Python 3.12 or later, PyMC 6.3, ArviZ 1.2 and PyTensor 3. Install it in a fresh virtual environment:

pip install pymc-marketing            # 1.3.0 as of October 2026
pip install "pymc-marketing[dag]"     # optional: causal identification tools

If you used PyMC-Marketing before August 2026, here’s what moved. The class you want is MMM, imported from pymc_marketing.mmm (or pymc_marketing.mmm.mmm), and it is what used to live at pymc_marketing.mmm.multidimensional.MMM. The old national-only MMM class was removed in 1.0. The multidimensional module still exists as a shim that re-exports the new class with a FutureWarning, and it will be removed in a future release, so update your imports now. The MultiDimensionalBudgetOptimizerWrapper became BudgetOptimizerWrapper, which is itself deprecated in favor of a budget_optimizer() method on the model. And priors now come from pymc_extras.prior.Prior, since PyMC-Marketing’s own Prior module was removed in 1.0.

How the PyMC-Marketing mix model is specified

The model is a Bayesian linear regression: an intercept, plus each channel’s spend (or impressions) pushed through an adstock function and a saturation function and multiplied by a coefficient, plus control variables, optional Fourier terms for yearly seasonality, and noise. The docstring writes it out in full, and the ingredients are objects you pass in. The default pairing in the README and docs is GeometricAdstock(l_max=8) with LogisticSaturation(), and the component library goes well beyond that. Reading the 1.3.0 source, the adstock options are geometric, delayed (a peak after a lag), binomial, Weibull PDF and Weibull CDF, or none; the saturation options are logistic, inverse scaled logistic, tanh and a “baselined” tanh, Michaelis-Menten, Hill and a sigmoid form of Hill, root, log, or none. You can write your own by subclassing the transformation base classes.

A few defaults matter more than they look. Before fitting, the target and each channel are divided by their maximum value (control variables are left as they are, so put them on a comparable scale yourself). GeometricAdstock normalizes its weights by default (normalize=True), so the weights over l_max lags sum to one, and its retention rate alpha gets a Beta(1, 3) prior, which puts most of its mass on short carryover; you can also parameterize it by half-life instead. LogisticSaturation computes the curve below, with priors of Gamma(3, 1) on the steepness lam and HalfNormal(2) on beta, the ceiling, in units of the max-scaled target:

f(x)=β⋅1−e−λx1+e−λxf(x) = \beta \cdot \frac{1 - e^{-\lambda x}}{1 + e^{-\lambda x}}

Adstock is applied before saturation unless you set adstock_first=False. The explorer below runs exactly that chain on a spend flight, so you can see what the parameters the model estimates actually do to your media. I checked its numbers against PyMC-Marketing’s own geometric_adstock and logistic_saturation functions.

Interactive
Push a spend flight through PyMC-Marketing's default transforms

Pick a flight, then set the values the model would estimate: adstock alpha and l_max, saturation lam and beta. The top chart follows the spend week by week; the bottom one is the saturation curve it's pushed through.

Half-life
–
Carryover cut off by l_max
–
Saturation at peak week
–
Where this sits in the default priors
–

Same maths as pymc-marketing 1.3.0: spend is divided by the channel's maximum (the default MaxAbs-style scaling), convolved with weights α⁰…α^(l_max−1) (divided by their sum when normalize=True, the GeometricAdstock default), then passed through β·(1 − e^(−λx))/(1 + e^(−λx)). The output is in units of the max-scaled target. Flights are invented; the practice-data option loads the video spend column from Vexpower's synthetic CSV, whose revenue was generated with no adstock or saturation, so it only shows the transforms on a realistic spend pattern.

Two things are worth trying. Turn normalize off with alpha high and watch the adstocked input inflate well above one, which changes what lam means; that’s why the defaults normalize. Then push alpha up to 0.8 with the default l_max of eight and you’ll see a warning that the window is cutting off a meaningful share of carryover (0.8 to the eighth power is about 17 percent), which is a common silent mistake with slow-decaying channels like TV. The “default priors” readout shows where your settings sit in the library’s default priors: an alpha of 0.5 is already at the 88th percentile of Beta(1, 3), so if you believe a channel has long carryover, say so with a prior rather than hoping the data will drag it there. The adstock and saturation curve pages go deeper on the shapes themselves.

Running a PyMC-Marketing MMM on real-shaped data

Here’s the model I fitted to the 104-week practice dataset used across this library (synthetic weekly revenue, search, social and video spend, and a promotion flag). The constructor takes column names and transformation objects; fit() takes the feature DataFrame and the target and passes sampler settings through to PyMC:

import pandas as pd
from pymc_marketing.mmm import MMM, GeometricAdstock, LogisticSaturation

df = pd.read_csv("mmm-practice-synthetic.csv", parse_dates=["week"])
channels = ["search_spend", "social_spend", "video_spend"]

mmm = MMM(
    date_column="week",
    target_column="revenue",
    channel_columns=channels,
    control_columns=["promotion"],
    adstock=GeometricAdstock(l_max=8),
    saturation=LogisticSaturation(),
    yearly_seasonality=2,
)
mmm.fit(
    df[["week", *channels, "promotion"]], df["revenue"],
    chains=4, draws=1000, tune=1000, target_accept=0.95, random_seed=42,
)

print(mmm.summary.roas(frequency="all_time"))     # ROAS by channel with HDIs
mmm.idata.posterior["adstock_alpha"].mean(("chain", "draw"))

For panel data, add a column per dimension (country, region, brand) and pass dims=("country",); every parameter then gets a value per country, with hierarchical priors that pool information across them. fit() also accepts method="map" for a fast point estimate, or variational methods ("advi", "fullrank_advi"), and PyMC can hand sampling to faster NUTS implementations such as nutpie, NumPyro or BlackJAX. The mmm.summary accessor returns tidy DataFrames (contributions, ROAS, saturation and adstock curves, waterfalls, residuals), and mmm.plot gives you the matching charts.

Before reading the results, you need to know one thing about the practice file: It was generated from an exactly linear equation with no noise, revenue = 20,000 + 1.4 × search + 0.8 × social + 0.6 × video + 3,000 × sin(πt/26) + 1,800 × promotion, with no adstock and no saturation. So the true ROAS values are 1.4, 0.8 and 0.6, the true carryover is zero, and the true response curves are straight lines. That makes it a good test of whether a model can recover a simple truth, and a bad dataset for learning anything about real carryover or diminishing returns.

On my laptop the four chains took about 40 seconds. The posterior gave search and social essentially no carryover (alpha around 0.001) and ROAS of 1.40 and 0.81, which is right. Video came out at 0.53, with a 94 percent interval of 0.52 to 0.55 that excludes the true 0.6, and with a spurious carryover of about 0.26. The saturation steepness lam came out between 0.25 and 0.42 for all three channels, well below the bulk of the Gamma(3, 1) prior (its median is about 2.7), which is the model pushing its curves as close to straight lines as it can. PyMC also printed warnings: Every chain hit the maximum tree depth, and some r-hat values were above 1.01 with low effective sample sizes. That’s the sampler telling you the posterior is awkward, and the reason is that a near-straight logistic curve can be made from many combinations of lam and beta, so the parameters trade off against each other. The lesson is that tight intervals are conditional on the model’s structure: Here the structure has extra pieces (carryover and saturation) that the data doesn’t contain, and the result is a confident, slightly wrong video estimate and a decay rate that isn’t a real finding. (On the same data, Meridian with its default priors gave much wider intervals and got social and video further from the truth, for the same underlying reason.) This is exactly the kind of thing model diagnostics are for, and the fixes are the usual ones: more informative priors on lam or alpha, fewer free parameters (NoAdstock or NoSaturation are real options), or more data.

py
PyMC-Marketing quickstart on the practice data (Python)Fits the model above, prints media parameters and ROAS, and runs a bounded 13-week budget optimisation. Tested with pymc-marketing 1.3.0 on Python 3.13, CPU only.
Download

Calibrating with lift tests and ROAS

PyMC-Marketing’s approach to calibration is to add the experiment as extra data rather than as a prior. You build the model, then call add_lift_test_measurements() with a DataFrame describing each test: the channel, the spend level before the test x, the change in spend delta_x, the measured change in the outcome delta_y, and the test’s standard error sigma, plus a column for each model dimension (such as geo) if you have them. The method converts these to the model’s internal scale for you. The model then computes how much its own saturation curve says the outcome should rise between x and x + delta_x, and conditions that difference on the measured lift with a Normal likelihood (StudentT or Gamma are options). In other words, the experiment pins down the slope of the curve at the spend level where the test ran, which is exactly the information a geo-lift or conversion-lift test actually contains.

mmm.build_model(X, y)

lift = pd.DataFrame({
    "channel": ["social_spend"],
    "x": [1800.0],        # weekly spend before the test
    "delta_x": [900.0],   # extra weekly spend during the test
    "delta_y": [1100.0],  # measured weekly incremental revenue
    "sigma": [300.0],     # standard error of that lift
})
mmm.add_lift_test_measurements(lift)
mmm.fit(X, y)

The numbers above are illustrative, not from a real test. Two details from the docstring are easy to miss: The model has to be built before you add the measurements, and lift tests added after a fit only change the posterior once you fit again. Lift tests also require the default identity link, because with the multiplicative (log) model added in 1.0 the curve difference isn’t on the same scale as an observed lift. Version 1.0 also added add_cost_per_target_calibration(), which calibrates against a ROAS or cost-per-acquisition estimate rather than a lift. I discuss the general trade-offs of each approach on the calibration page.

Budget optimization and time-varying effects

For budget allocation, the model has a budget_optimizer() method that takes the start and end dates of a future window and returns an optimizer; you then call allocate_budget() with a total budget and optional bounds per channel. The budget is per period (per week, for weekly data), spread evenly across the window unless you pass a distribution over time, and the response it maximizes includes the carryover that spills past the end of the window. It uses SciPy’s SLSQP by default:

current_weekly = df[channels].tail(13).mean()   # average weekly spend, last 13 weeks

optimizer = mmm.budget_optimizer(start_date="2025-12-29", end_date="2026-03-23")
result = optimizer.allocate_budget(
    total_budget=float(current_weekly.sum()),   # per week, about 5,565 here
    budget_bounds={ch: (0.5 * now, 1.5 * now) for ch, now in current_weekly.items()},
)
result.budgets

Without bounds, each channel can go anywhere from zero to the whole budget, and on my practice run that meant everything went to search, the channel with the steepest, straightest curve. With bounds of 50 to 150 percent of current spend, social and video dropped to their lower bounds and search took the rest, about 4,200 a week instead of 2,900, which is as far as I’d want an optimizer to move on a model with those diagnostics. The budget allocation page covers why bounds matter so much.

Time-varying effects are one argument each: time_varying_intercept=True lets the baseline drift over time and time_varying_media=True lets media effectiveness change, both using a Hilbert space Gaussian process approximation (HSGP) that keeps sampling tractable. You can pass a configured HSGP object instead of True to set its priors and dimensions. This is the PyMC-Marketing answer to the problem Uber’s Orbit was built for, a channel whose effectiveness in 2024 isn’t its effectiveness in 2026, and it’s one of the main reasons to choose this library over the others. Beyond that, the 1.0 release added funnel-aware models through custom “MuEffect” components, long-term brand effects via a Bayesian VARX on brand metrics, multiplicative (log and log-log) models, and an optional DAG-based tool for checking which controls you need for causal identification, which ties in with my page on confounding variables.

The rest of the toolbox: CLV and more

The same package includes customer lifetime value models under pymc_marketing.clv: BG/NBD (BetaGeoModel), Pareto/NBD, Gamma-Gamma for spend per transaction, shifted beta-geometric for contractual churn, a modified BG/NBD and a beta-geometric/beta-binomial model for discrete-time data. There’s also a customer choice module (discrete choice, MaxDiff and a Bayesian BLP demand model), Bass diffusion for new-product adoption, and PIE (predicted incrementality by experimentation, in alpha), which learns from your past experiments to predict the incrementality of campaigns that were never tested. None of that is needed for MMM, but if your team is already using PyMC for one, having the other in the same stack is convenient.

Strengths, limits and who should use it

PyMC-Marketing’s strength is that almost nothing is fixed. If you need a delayed-peak adstock for TV, a Michaelis-Menten curve for search, geo and brand dimensions at once, a baseline that drifts, and three experiments folded in as likelihood terms, you can build that model, and because it’s all PyMC, you can inspect and change any part of it. It runs comfortably on a CPU for national models, the maintainers ship frequently (four releases since August), and the documentation includes worked case studies rather than just API references.

The cost of that flexibility is that you make more decisions, and each one changes the answer. There’s no Robyn-style search over specifications and no Meridian-style ROI priors by default (you can calibrate to ROAS, but the default priors are on the transformation parameters and the scaled coefficients, which are harder to explain to a marketer). The 1.0 migration broke a lot of existing code and tutorials, so check the version of anything you copy. And as my run shows, sampler warnings on small national datasets are common; you need someone comfortable reading r-hat, effective sample size and divergences, or you’ll report confident numbers from a model that hasn’t converged.

I’d choose PyMC-Marketing if your team works in Python, is comfortable with Bayesian workflows (or wants to become so), and has questions the more opinionated tools can’t express, such as time-varying effectiveness, funnel effects, or several panel dimensions. If you’d rather start from strong defaults, Meridian is the alternative, and if you’re in R, Robyn; the open-source library comparison has all of them side by side. And if you want to understand what these libraries automate before you trust one, building a small model yourself with the Python MMM tutorial or in the Vexpower course is time well spent, because the hardest part of using PyMC-Marketing well is knowing which of its many choices you’re making.

Downloads

py
PyMC-Marketing quickstart on the practice data (Python)MMM with GeometricAdstock and LogisticSaturation, posterior media parameters, ROAS and a bounded budget optimization. Tested with pymc-marketing 1.3.0 on CPU.
Download
csv
MMM practice dataset (104 weeks, CSV)Synthetic weekly revenue, search, social and video spend and a promotion flag.
Download

Frequently asked questions

What is PyMC-Marketing?

PyMC-Marketing is an open-source Python library from PyMC Labs for Bayesian marketing analytics, released under the Apache 2.0 license. Its best-known part is a marketing mix model with configurable adstock and saturation transformations, experiment calibration, time-varying effects and budget optimization, and it also includes customer lifetime value models, customer choice models, Bass diffusion and a predicted-incrementality module. It's built on the PyMC probabilistic programming library.

What changed in PyMC-Marketing 1.0?

Version 1.0.0, released on 7 August 2026, made the multidimensional MMM the only MMM class, removed the old single-dimension MMM class, and moved the code from pymc_marketing.mmm.multidimensional to pymc_marketing.mmm.mmm. It moved to PyMC 6, ArviZ 1.2 and PyTensor 3, added a new plotting API, and introduced ROAS calibration and multiplicative (log) models. Code written for the 0.x releases will need changes.

How do I add lift tests to a PyMC-Marketing model?

Build the model first, then call add_lift_test_measurements with a DataFrame that has one row per test and columns channel, x (the spend level before the test), delta_x (the change in spend), delta_y (the measured change in the outcome) and sigma (the test's standard error), plus a column for each model dimension such as geo. Then fit, or refit, the model; the lift tests only affect the posterior after fitting.

Is PyMC-Marketing better than Meridian?

Neither is better in general. PyMC-Marketing gives you more choice of model structure, with many adstock and saturation functions, time-varying effects, causal DAG tools and custom effects, and runs comfortably on a CPU. Meridian is more opinionated, with ROI priors by default, reach and frequency modeling and Google's reporting and scenario-planning tools. Pick PyMC-Marketing if you want to shape the model yourself, Meridian if you want Google's defaults and ecosystem.

What Python version does PyMC-Marketing need?

PyMC-Marketing 1.3.0 requires Python 3.12 or later and PyMC 6.3. Install it with pip install pymc-marketing, ideally in a fresh virtual environment, and add the dag or pie extras if you need causal identification tools or the predicted incrementality module.

References

  1. PyMC-Marketing on GitHub (pymc-labs/pymc-marketing): README Features, install, Python 3.12+, MMM quickstart, CLV quickstart.
  2. PyMC-Marketing 1.0.0 release notes (7 August 2026) Multidimensional MMM becomes the MMM; old class removed; module renamed; PyMC 6.
  3. PyMC-Marketing releases 1.3.0 released 22 October 2026.
  4. PyMC-Marketing source: pymc_marketing/mmm/mmm.py MMM constructor and fit signatures, scaling defaults, add_lift_test_measurements, budget_optimizer.
  5. PyMC-Marketing source: adstock and saturation components GeometricAdstock (normalize=True, alpha ~ Beta(1, 3)) and LogisticSaturation (lam ~ Gamma(3, 1), beta ~ HalfNormal(2)) defaults; full list of components.
  6. PyMC-Marketing source: transformers.py geometric_adstock weights and normalisation; logistic_saturation formula.
  7. PyMC-Marketing docs: lift test calibration Worked example of add_lift_test_measurements.
  8. PyMC-Marketing docs: budget allocation example Budget optimiser walkthrough.
  9. pymc-marketing on PyPI Version 1.3.0, requires Python 3.12 or later and PyMC 6.3.
Mike Taylor
Mike Taylor

I've built marketing mix models for Monzo Bank, Booking.com, Time Out, The AA and others, advised Google, Appsflyer and Recast on MMM, and teach marketing mix modeling on LinkedIn Learning. Before that I co-founded a 50-person growth marketing agency. I write the Vexpower MMM course.

Keep reading

Learn MMM by doing itThe Vexpower course walks through thirty lessons on one invented brand, with simulators, Python labs and a decision case you can defend.
See the course