← Back to Blog

Bayesian MMM in 2026: What's Changed, What Hasn't, and Where AI Fits

February 14, 2026  ·  AI  ·  9 min read


I spent three months building a Bayesian Marketing Mix Model at MPL, covering six channels: Google, Meta, YouTube, influencer, push notifications, and email. We had two years of weekly spend and revenue data. The model converged well. The ROI curves looked sensible. The adstock and saturation parameters passed the sniff test.

Then I presented it to the CMO. About four slides in, she stopped me and said: "I appreciate all this, but just tell me where to put the money."

I didn't have a clean answer ready. I had posterior distributions and credible intervals. I had marginal ROI curves. I had a model that told her what had worked in the past. None of that translated cleanly into "put 30% more into YouTube and cut Meta by 15%." That translation — from model output to decision — was the part I'd underinvested in.

That's what this article is actually about.


What MMM does and why Bayesian specifically

Marketing Mix Modeling is a regression-based approach to attributing revenue (or installs, or some north-star metric) to marketing spend across channels, after controlling for organic factors like seasonality, promotions, and macroeconomic conditions.

Frequentist MMM gives you point estimates — "Google ads drove 18% of revenue." That sounds precise. It isn't. The confidence interval on that number might be wide enough that 9% and 31% are both plausible. A point estimate hides that uncertainty. You make budget decisions on a number that's really a distribution.

Bayesian MMM forces you to be explicit about uncertainty. You get a posterior distribution for every channel's contribution — not "Google drove 18%" but "Google drove somewhere between 12% and 26%, most likely around 18%." That range matters when you're making a ₹2 crore reallocation decision.

The other thing Bayesian gives you: the ability to incorporate prior knowledge. If you know from past experiments that influencer campaigns have a 3-6 week decay period, you can encode that as a prior rather than letting the model infer it from insufficient data. This is especially useful when spend history is short or spend patterns are sparse.


Adstock and saturation — why they're not optional

Two transformations sit at the core of any serious MMM, and most tutorials either skip them or explain them badly.

Adstock captures carryover — the idea that advertising this week affects consumer behaviour next week, and the week after. If you model raw spend without adstock, you'll underestimate the delayed impact of channels like TV and YouTube that build brand awareness over time. The adstock transformation decays spend geometrically: this week's spend contributes fully, next week at rate λ, the week after at λ², and so on. The retention parameter λ is what you're estimating.

Saturation captures diminishing returns — each additional rupee spent on a channel produces less incremental revenue than the one before it. The Hill function is the standard choice: S(x) = xα / (xα + kα). k is the half-saturation point (the spend level at which you're getting 50% of the maximum possible lift) and α controls the steepness of the curve.

These aren't just modelling niceties. They're the parameters that tell you whether a channel is underspent (you're still on the steep part of the saturation curve) or overspent (you're in the flat zone where additional spend has near-zero marginal ROI). Getting these wrong produces budget recommendations that are backwards.

import pymc as pm
import numpy as np

def adstock_transform(spend: np.ndarray, retention: float) -> np.ndarray:
    """Geometric adstock decay."""
    adstocked = np.zeros_like(spend, dtype=float)
    adstocked[0] = spend[0]
    for t in range(1, len(spend)):
        adstocked[t] = spend[t] + retention * adstocked[t - 1]
    return adstocked

def saturation_transform(spend: np.ndarray, alpha: float, k: float) -> np.ndarray:
    """Hill function saturation."""
    return spend**alpha / (spend**alpha + k**alpha)

The PyMC model structure

This is a simplified but structurally honest version of what I built at MPL. The key design choices: weakly informative priors on retention (Beta distribution keeps it between 0 and 1), positive-only priors on channel coefficients (we expect positive ROI, even if uncertain), and a Gamma prior on saturation parameters constrained by domain knowledge.

import pymc as pm
import pytensor.tensor as pt

def build_mmm(revenue: np.ndarray, spend_dict: dict) -> pm.Model:
    """
    spend_dict: {"google": array, "meta": array, "youtube": array, ...}
    revenue: weekly revenue array (same length)
    """
    channels = list(spend_dict.keys())
    n_channels = len(channels)

    with pm.Model() as model:
        # Priors on adstock retention per channel
        retention = pm.Beta("retention", alpha=2, beta=2, shape=n_channels)

        # Saturation priors — half-saturation near median spend, steep curve
        # Encode domain knowledge: k near median spend level
        k = pm.HalfNormal("k", sigma=1.0, shape=n_channels)
        alpha = pm.Gamma("alpha", alpha=3, beta=1, shape=n_channels)

        # Channel contribution coefficients — must be positive
        beta = pm.HalfNormal("beta", sigma=0.5, shape=n_channels)

        # Seasonality: simple Fourier features (pre-computed)
        gamma = pm.Normal("gamma_seasonal", mu=0, sigma=0.2, shape=4)

        # Base revenue (intercept)
        base = pm.HalfNormal("base", sigma=np.median(revenue))

        # Transform spend and compute contributions
        contributions = []
        spend_arrays = [spend_dict[c] for c in channels]

        for i, (channel_spend) in enumerate(spend_arrays):
            adstocked = adstock_transform(channel_spend, retention[i])
            saturated = saturation_transform(adstocked, alpha[i], k[i])
            contributions.append(beta[i] * saturated)

        channel_total = pt.sum(pt.stack(contributions), axis=0)

        # Seasonal component (pass pre-computed Fourier features as data)
        # seasonal_features: shape (n_weeks, 4)
        # seasonal_component = pm.Data("seasonal_features", ...) @ gamma

        mu = base + channel_total
        sigma_obs = pm.HalfNormal("sigma_obs", sigma=np.std(revenue))
        revenue_obs = pm.Normal("revenue_obs", mu=mu, sigma=sigma_obs,
                                observed=revenue)

    return model

Running inference with NUTS (No-U-Turn Sampler) takes a few minutes on a few hundred weeks of data. Four chains, 2,000 draws, 1,000 warmup. Check R-hat values (should be near 1.0) and effective sample size before you trust the posteriors.


The thing nobody mentions: MMM tells you the past

Here's the gap that caught me at MPL. The model tells you what happened — channel A had an ROI of X historically. It does not tell you what will happen if you change the budget mix going forward. Those are different questions, and conflating them is where most budget recommendations go wrong.

If Google Ads had a marginal ROI of 2.3x last quarter, that's not a guarantee it will have the same ROI if you double the budget. You're likely moving into a higher-saturation zone. The saturation curve tells you how much ROI degrades with spend, but only within the range of spend you've historically observed. Extrapolating beyond that range introduces real model uncertainty that often isn't communicated to stakeholders.

Budget optimisation under this uncertainty requires a simulation step: sample from the posterior, apply the saturation transformation at different budget levels, and show the distribution of expected outcomes rather than a single number. It's more honest and usually more persuasive to a sceptical CMO than a point estimate.

def simulate_budget_scenarios(trace, channels: list,
                               base_budget: dict, scenarios: list) -> dict:
    """
    For each scenario (a dict of channel -> spend), simulate revenue
    using posterior samples to get uncertainty bands.

    scenarios: [{"google": 500000, "meta": 300000, ...}, ...]
    Returns: dict mapping scenario name to (mean, 5th pct, 95th pct) revenue
    """
    results = {}
    posterior_betas = trace.posterior["beta"].values  # shape: (chains, draws, n_channels)
    posterior_alphas = trace.posterior["alpha"].values
    posterior_ks = trace.posterior["k"].values

    for i, scenario in enumerate(scenarios):
        revenues = []
        for chain in range(posterior_betas.shape[0]):
            for draw in range(posterior_betas.shape[1]):
                rev = 0
                for j, channel in enumerate(channels):
                    spend = scenario.get(channel, 0)
                    sat = saturation_transform(
                        np.array([spend]),
                        posterior_alphas[chain, draw, j],
                        posterior_ks[chain, draw, j]
                    )[0]
                    rev += posterior_betas[chain, draw, j] * sat
                revenues.append(rev)
        revenues = np.array(revenues)
        results[f"scenario_{i}"] = {
            "mean": revenues.mean(),
            "p5": np.percentile(revenues, 5),
            "p95": np.percentile(revenues, 95)
        }
    return results

Where AI fits: closing the last mile

The last mile problem in MMM is that the outputs are technical and the decisions need to be made by people who aren't. A posterior distribution for Google's adstock retention parameter is not a meeting agenda item. A channel contribution chart is not a budget recommendation.

I now use Gemini to translate the model outputs — posterior means, credible intervals, budget scenario simulations — into a narrative that a CMO can read and act on. This is not AI doing the analysis. The analysis is already done. This is AI doing the communication.

import google.generativeai as genai

genai.configure(api_key="YOUR_GEMINI_API_KEY")

MMM_NARRATION_PROMPT = """You are an analytics advisor presenting Marketing Mix Model results to a CMO.

Model outputs (from Bayesian MMM):
- Channels analysed: {channels}
- Time period: {time_period}
- Channel contributions to revenue (posterior means with 90% credible intervals):
{channel_contributions}

Budget scenario simulations:
{budget_scenarios}

Key model signals:
- Most underspent channel (still on steep saturation curve): {underspent_channel}
- Most overspent channel (in saturation zone): {overspent_channel}
- Channel with highest marginal ROI at current spend: {highest_marginal_roi_channel}

Write a 4-5 paragraph narrative that covers:
1. What the model found about channel performance (specific numbers, honest about uncertainty)
2. What the budget scenarios show — with ranges, not single numbers
3. The 2-3 clearest reallocation signals from the model
4. One honest caveat about what the model cannot tell us

Rules:
- Never present a range as a point estimate. Always communicate uncertainty.
- Never say "the model predicts" for future outcomes. Say "the model suggests" or "historically"
- Be specific about which channels and which magnitudes
- No jargon (no "posterior", no "adstock", no "credible interval" — say "likely range" instead)
- Do not recommend a specific allocation — present the signals and let the CMO decide"""

def narrate_mmm_results(channel_contributions: dict,
                         budget_scenarios: dict,
                         channels: list,
                         time_period: str,
                         key_signals: dict) -> str:
    channel_text = "\n".join([
        f"  {ch}: mean {v['mean']:.1f}%, range {v['p5']:.1f}%-{v['p95']:.1f}%"
        for ch, v in channel_contributions.items()
    ])
    scenario_text = "\n".join([
        f"  {name}: expected revenue ₹{v['mean']:,.0f} (range: ₹{v['p5']:,.0f}–₹{v['p95']:,.0f})"
        for name, v in budget_scenarios.items()
    ])
    prompt = MMM_NARRATION_PROMPT.format(
        channels=", ".join(channels),
        time_period=time_period,
        channel_contributions=channel_text,
        budget_scenarios=scenario_text,
        **key_signals
    )
    model = genai.GenerativeModel("gemini-1.5-pro")
    return model.generate_content(prompt).text

I use Gemini 1.5 Pro rather than Flash for this one. The output goes into a stakeholder presentation — it's worth the slightly higher cost for better coherence on a complex prompt with a lot of structured input.


When not to use MMM

Short history. MMM needs to observe variation in spend to identify what each channel contributes. If you have less than 52 weeks of data — less than a year — you're unlikely to observe enough seasonal variation for the model to separate channel effects from seasonal effects. You can still run it, but be very conservative about how much you trust the outputs.

Single channel or very sparse variation. If 80% of your marketing budget goes to one channel, MMM will struggle to isolate the effects of everything else. There's not enough variation in the other channels for the model to learn from. This is actually the situation at a lot of early-stage companies — you've found one channel that works and you're doubling down. MMM isn't the right tool yet.

Short-cycle, fast-decision needs. MMM is a strategic planning tool. It runs on weekly data and produces quarterly insights. If you need to know whether today's Google campaign is working, that's a different problem — incrementality testing or short-run geo experiments are better fits.

The teams I've seen get the most out of Bayesian MMM are ones that have 18+ months of spend history across at least four channels, have done enough spend variation (deliberately or accidentally) that the model has something to learn from, and have a stakeholder who is willing to receive ranges rather than point estimates. That last one is rarer than it sounds.