← Back to Blog
Analytics & Data MarketingQuantitative MarketingRisk ManagementB2B Marketing

Marketing Mix Modeling on a Small Budget

Attribution divides credit for clicks it saw. MMM estimates what would have happened without the spend. Here is the smallest working version in runnable Python, plus the point where a geo holdout beats it.

SPSantosh Paudel· September 6, 2026· 16 min read
Table of contents

Marketing mix modeling estimates what your revenue would have been if you had not spent, by regressing a weekly outcome on weekly spend per channel plus controls. Attribution cannot do that — it distributes credit among touchpoints it happened to observe. You do not need a data team to run one. You need two to four years of weekly rows, five or six columns, under ninety lines of Python, and the discipline to admit when the answer is noise — which, on the results below, is most of the time. Below is the smallest model that works, the code, what it recovers on synthetic data, and the decision rule for when a geo holdout beats the model outright.

What MMM does that attribution cannot

Attribution answers a question you did not ask

Every attribution model — last click, first click, position-based, data-driven — starts from observed touchpoints and divides an observed conversion among them. The conversion already happened. The model never considers the world where the spend did not occur.

MMM starts from the opposite end. It treats weekly outcome as the dependent variable and asks how much of its movement covaries with each channel's spend once seasonality and trend are removed. The output is incremental contribution: revenue you would not have had. The two numbers can disagree sharply and still both be internally consistent, because they answer different questions.

It sees the channels that leave no trace

Click attribution only ever has rows for clicks, and my own Search Console data shows how thin that layer is. Over 85 days to 2026-09-03, santoshpaudel.me got 22 clicks from 4,553 impressions. Russia supplied 1,320 impressions and zero clicks. The United States supplied 970 impressions at average position 38.8, also zero. Nepal was the best-converting market at 8 clicks from 74 impressions, a 10.8% CTR at position 9.2. The country table accounts for 19 of the 22 clicks in all — Nepal 8, the UK 3, and one each from India, Canada, Australia, Bangladesh, Belgium, Spain, Nigeria and Singapore.

So an attribution model gets 22 rows, and 4,531 impressions produce nothing it can record. Be careful about what that proves. Those are organic impressions with no spend attached, so they are not themselves a column in the model built below — MMM regresses outcome on weekly spend, and there is no spend series here to regress. What generalises is the shape of the gap. Display, video, connected TV, out-of-home and podcast reads produce exposure with the same missing click trail, and unlike organic impressions they arrive with a weekly invoice. MMM does not care whether a touch was clickable. It only needs spend and outcome on the same weekly grid. That is the same blind spot I wrote about in the dark funnel and ChatGPT attribution: the channel does not have to be trackable to be real.

Why MMM needs less data than people assume, and more than you have

What the vendors actually require

Google's Meridian documentation is specific: historical data should be "a minimum of two years' worth of weekly data for geo-level models and three years' of data for national-level models," collected at the weekly level, which it describes as an "advantageous equilibrium between the degree of variation and the extent of noise" (Meridian collect-data guide).

Meta's Robyn puts the same constraint differently. Its analyst's guide states that "an MMM will need a minimum of two years of historical weekly data," and gives the sizing rule that matters more: "for an n * p data-frame (where n = num of rows to be modelled and p = num of columns), n should be about 7-10x more than p" (An Analyst's Guide to MMM).

The rows-to-columns rule is the whole game

That 7-10x ratio is the number to design around, and it is less intimidating than "two years of data" sounds. Two years of weekly data is 104 rows: at 10:1 you can afford ten columns, and every column spent on a control is one you cannot spend on a channel.

Weekly rowsColumns affordable at 10:1Realistic model at that size
30 (7 months)3intercept, one channel, trend; effectively a slope
52 (1 year)5intercept, 2 channels, 2 seasonality terms, no controls
104 (2 years)10intercept, 4 channels, seasonality, price, one shock dummy
156 (3 years)15above, plus adstock/saturation freedom and a competitor proxy
208 (4 years)20genuine channel-level splits, holiday dummies, promo flags

The sizing rule usually gets quoted without the part that bites: adstock and saturation parameters are parameters too. Grid-searching two of them per channel costs degrees of freedom that the OLS standard errors never report, so a small-n model reads as more confident than it has earned.

The minimum viable model

Three components. Nothing else is required.

Adstock

Advertising this week still sells next week. Geometric adstock encodes that with a single decay parameter, theta. Robyn's guide describes it plainly: "an ad-stock of theta = 0.75 means that 75% of the impressions in period 1 were carried over to period 2." Their recommended bounds are useful priors — TV 0.3-0.8, out-of-home, print and radio 0.1-0.4, digital 0-0.3. Digital decays fast; if your grid picks theta = 0.8 for paid search, the model is fitting seasonality and calling it carryover.

Diminishing returns

The tenth thousand dollars into a channel does less than the first. Robyn uses the Hill function with two parameters, alpha for curve shape and gamma for inflection. I use a one-parameter version, x / (x + half), because at small n every extra parameter is bought with data I do not have. Here half is simply the adstocked spend level at which you get half the channel's ceiling.

A regression, and a rejection rule

Ordinary least squares on the transformed columns, with sine and cosine terms for annual seasonality. Then one hard rule: a negative media coefficient means the fit is nonsense, and you reject it rather than reporting a channel that supposedly destroys revenue.

The code

This runs on Python 3 with NumPy and nothing else. The data is synthetic — generated by make_data with a known true ROI so the model's answer can be graded. No client, no real spend, no real revenue.

"""Minimum viable MMM on SYNTHETIC data. Every number printed is generated."""
import numpy as np

def adstock(x, theta):
    """Geometric carryover: theta of this week's effect survives into next week."""
    out, carry = np.zeros(len(x)), 0.0
    for i, v in enumerate(x):
        carry = v + theta * carry
        out[i] = carry
    return out

def saturate(x, half):
    """Diminishing returns. half is the adstocked spend giving half the ceiling."""
    return x / (x + half)

def make_data(n_weeks, seed, noise=2500.0):
    """SYNTHETIC generator. True ROI is known, so we can grade the recovery."""
    rng = np.random.default_rng(seed)
    weeks = np.arange(n_weeks)
    spend = {"search": rng.lognormal(8.0, 0.35, n_weeks),
             "social": rng.lognormal(7.5, 0.55, n_weeks)}
    truth = {"search": (0.20, 4000.0, 9000.0),   # theta, half, beta
             "social": (0.60, 6000.0, 5000.0)}
    y = 40000 + 6000 * np.sin(2 * np.pi * weeks / 52)
    for ch, (theta, half, beta) in truth.items():
        y = y + beta * saturate(adstock(spend[ch], theta), half)
    return spend, y + rng.normal(0, noise, n_weeks), weeks, truth

THETAS = [0.0, 0.2, 0.4, 0.6, 0.8]
HALVES = [2000.0, 4000.0, 6000.0, 9000.0, 14000.0]

def fit(spend, y, weeks):
    """Grid-search the nonlinear params, OLS the linear ones."""
    season = np.column_stack([np.sin(2*np.pi*weeks/52), np.cos(2*np.pi*weeks/52)])
    chans, best, best_ok = list(spend), None, None
    for ts in THETAS:
        for to in THETAS:
            for hs in HALVES:
                for ho in HALVES:
                    cfg = {"search": (ts, hs), "social": (to, ho)}
                    cols = [saturate(adstock(spend[c], cfg[c][0]), cfg[c][1])
                            for c in chans]
                    X = np.column_stack([np.ones(len(y))] + cols + [season])
                    b, *_ = np.linalg.lstsq(X, y, rcond=None)
                    rss = float(((y - X @ b) ** 2).sum())
                    cand = (rss, cfg, dict(zip(chans, b[1:1+len(chans)])))
                    if best is None or rss < best[0]:
                        best = cand
                    if all(v >= 0 for v in cand[2].values()) and (
                            best_ok is None or rss < best_ok[0]):
                        best_ok = cand
    # a negative media coefficient is nonsense; fall back but flag it
    return (best_ok, True) if best_ok else (best, False)

def roi(spend, cfg, betas):
    return {c: float(betas[c] * saturate(adstock(spend[c], cfg[c][0]),
                                         cfg[c][1]).sum() / spend[c].sum())
            for c in betas}

def run(n_weeks, seed, noise=2500.0):
    spend, y, weeks, truth = make_data(n_weeks, seed, noise)
    (rss, cfg, betas), ok = fit(spend, y, weeks)
    est = roi(spend, cfg, betas)
    tru = {c: beta * saturate(adstock(spend[c], th), h).sum() / spend[c].sum()
           for c, (th, h, beta) in truth.items()}
    return est, tru, ok

if __name__ == "__main__":
    for n, noise in ((30, 2500.0), (104, 2500.0), (208, 2500.0), (208, 600.0)):
        rows = [run(n, s, noise) for s in range(8)]
        bad = sum(1 for _, _, ok in rows if not ok)
        for ch in ("search", "social"):
            e = np.array([r[0][ch] for r in rows])
            t = np.array([r[1][ch] for r in rows])
            err = np.abs(100 * (e - t) / t)
            print(f"n={n:3d}w noise={noise:6.0f} {ch:7s} true {t.mean():5.2f}  "
                  f"modelled {e.mean():5.2f}  range {e.min():5.2f}-{e.max():5.2f}  "
                  f"worst {err.max():4.0f}%  median {np.median(err):3.0f}%")
        print(f"        {bad}/8 runs had no non-negative fit")

    def worst(n):
        return max(abs(e[c]/t[c] - 1) for e, t, _ in [run(n, s) for s in range(8)]
                   for c in ("search", "social"))
    assert worst(208) < worst(30), "more weeks should recover ROI better"
    print("self-check passed: 208 weeks beats 30 weeks on worst-case ROI error")

The assertion at the bottom is the whole test suite. If a refactor breaks adstock or the ROI arithmetic, four years of data stops beating seven months and the script fails loudly.

What it returns, and why that should worry you

Eight random seeds per configuration. The synthetic truth is a search ROI near 1.40 and a social ROI near 1.07.

WeeksNoise sdChannelTrue ROIMean modelledRange across 8 seedsMedian error
302,500search1.411.43-1.24 to 3.8257%
302,500social1.060.51-1.47 to 3.5581%
1042,500search1.402.260.47 to 5.3464%
1042,500social1.071.41-0.66 to 4.0388%
2082,500search1.391.820.73 to 3.1941%
2082,500social1.091.920.64 to 3.1467%
208600search1.391.590.99 to 2.0528%
208600social1.091.270.87 to 2.0616%

The 30-week row is where it comes apart. Mean modelled search ROI is 1.43 against a truth of 1.41, which looks like a triumph until you see the range: individual seeds land anywhere from -1.24 to 3.82. The average of eight wrong answers happened to be right. You get one seed. In two of eight 30-week runs, no parameter combination in the entire grid gave non-negative coefficients for both channels.

These are friendly conditions, too. The generator uses the exact functional form the fitter searches, with no omitted channel, no confounding between spend and demand, no data-quality problems, no competitor. Real data has all four. If a 30-week model on perfect synthetic data misses by a median 57%, a 30-week model on your data is a decorative object.

The bottom two rows are the encouraging part, but note which lever moved them. Holding noise at 2,500 and lengthening the series, median error runs 57% to 64% to 41% for search and 81% to 88% to 67% for social — not monotonic, worse at 104 weeks than at 30, and never below 41%. Dropping the noise standard deviation from 2,500 to 600 at a fixed 208 weeks is what takes search to 28% and social to 16%. The code is fine. Signal-to-noise is the binding constraint, and more weeks on their own do not relieve it. The unglamorous work — one revenue definition, one currency, promos flagged, outsized one-off orders split out, stockouts marked — buys more accuracy than another year of waiting.

Model versus holdout: the decision table

Before building anything, price the alternative. A geo holdout — turn a channel off in randomly assigned regions, leave it on elsewhere, compare — measures incrementality directly. Google's Jon Vaver and Jim Koehler set out the design in Measuring Ad Effectiveness Using Geo Experiments, and Meta's GeoLift packages the synthetic-control version with power calculators for data-driven market selection, so you can check in advance whether a test can detect the effect size you care about.

SituationBuild the modelRun a geo holdout
Fewer than 52 weekly rowsNoYes
One dominant channel, need its ROINoYes, cheaper and unambiguous
5+ channels, need relative allocationYesImpractical to test every pair
Spend is flat week to weekNo, no variance to fitYes
Cannot geo-target the channelYes, with the error bars statedNot possible
Weekly outcome dominated by one big clientNoNo, fix the measurement first
Need an answer this quarterNo, 208 weeks or wide barsYes, if the power calculator says the effect is detectable
Budget shift under review is under 10% of spendNeither, the decision is too small to measureNeither

That last row gets skipped most often and costs the most. If the decision is whether to move 5% of spend, the measurement costs more than the decision is worth, and both methods return an interval containing "no difference." Price the decision before you build anything — the promo EV calculator will tell you in a minute whether the swing is large enough to be worth chasing. Deciding what is worth measuring is most of the job, which is the argument I made in the metrics that matter for non-data people.

Where I actually spend the effort

I do not run MMM on my own site, because I fail three rows of that table at once: fewer than 52 weekly rows of anything worth regressing, spend that is flat week to week because there is almost none of it, and budget decisions far under the 10% threshold where measurement starts paying for itself. I treat structural changes as natural experiments instead and read the difference.

Over that same 85-day window, my build-log and worked-arithmetic posts ranked at positions 2.8 to 15.5. Generic "[industry] content marketing" posts on the same domain, by the same author, in the same quarter, ranked 46 to 81. Content type is the clearest difference, though not the only one: the dev and maths queries are also less commercially contested, and the snapshot controls for nothing beyond author, domain and quarter. It still told me more than any regression on 22 clicks could. It is also why the published count went from 267 to 103, a 61% cut — and why pages now get scored before they get written (the scoring model is here).

For a single decision with a known payoff structure, skip modelling and compute the expected value directly. That is arithmetic, exact where a regression is a guess. The promo EV calculator does it for one offer; expected value of a promo walks through the derivation. For the portfolio version — how much spend you can risk before a bad quarter becomes unrecoverable — see marketing risk management with a VaR killswitch, and Monte Carlo on a promo budget for a distribution rather than a point estimate.

FAQ

How much data do I need for marketing mix modeling?

Google's Meridian guide asks for a minimum of two years of weekly data for geo-level models and three years for national models. Robyn asks for two years and adds the sizing rule that matters more: rows should outnumber columns by 7-10x. In practice 104 weekly rows buys about ten columns, which is four channels plus seasonality plus one control. Two years is the floor for fitting, not the point where the answer gets usable: on the synthetic runs above, 104 rows at realistic noise still missed by a median 64-88%.

Can I run MMM with only 30 weeks of data?

You can run it. You should not act on it. On synthetic data where the fitter already knows the exact functional form, 30 weeks produced a median ROI error of 57-81% across eight seeds, and in two of those runs no parameter combination gave non-negative coefficients for both channels. Run a geo holdout instead.

Is MMM better than attribution?

They answer different questions. Attribution divides credit for conversions it observed; MMM estimates what would have happened without the spend. Attribution has no row for exposure that draws no click, and that is nearly all exposure — in my own Search Console data, 4,531 of 4,553 impressions produced no click. MMM reaches channels of that shape provided they come with a weekly spend series. Attribution gives faster, more granular, less causal answers.

What is adstock in a marketing mix model?

Adstock models the carryover of advertising into later weeks. The geometric form uses one decay parameter, theta: theta = 0.75 means 75% of this period's effect survives into the next. Robyn recommends bounds of 0.3-0.8 for TV, 0.1-0.4 for out-of-home, print and radio, and 0-0.3 for digital.

Do I need Bayesian methods or is OLS enough?

Bayesian priors help most when data is thin, which is the small-budget case, and that is why Meridian and Robyn both regularise. But priors do not create information. If OLS on your data returns a range as wide as the one in the table above, a Bayesian model returns the same uncertainty with better manners.

Ready to find out whether your spend is measurable at all? Price the decision before you build the model, because if the answer is worth less than the measurement you should skip both. Run the promo EV numbers or get in touch.

Free resource

Get the Promo & Bonus Economics Workbook

Every formula for pricing a promo before you launch it: expected value, wagering cost, breakage, breakeven conversion. Worked examples with real executed numbers.

No spam. Unsubscribe anytime.

Browse all free guides →

Run this on your own numbers

Promo Expected-Value Calculator — free, no signup, runs in your browser.

Open the calculator →

Want to implement this with guidance?

Santosh helps founders turn insights like this into real systems.

Promo Economics Audit

External Resources

Further Reading & Tools

Related Posts

01
11 min
Financial EngineeringQuantitative Marketing
TodayB2B Marketing

Fintech Thought Leadership That Passes Compliance

Fintech thought leadership stalls in compliance review because it is written as opinion. Rewrite the claims as arithmetic a reviewer can recompute, and review stops being a fight.

Read article
02
9 min
Risk ManagementPromo Economics
1mo agoQuantitative Marketing

Value at Risk and Kill Switches for Live Promotions

A spend cap set at expected payout shuts down 46% of perfectly healthy campaigns. The right multiple is 1.52x. Sizing exposure limits and automated kill switches against a real payout distribution.

Read article
03
9 min
Promo EconomicsFinancial Engineering
1mo agoQuantitative Marketing

Monte Carlo for Promo Budgets: Your Point Estimate Is Not a Budget

Budgeting a promo at its expected payout under-funds the 95th percentile outcome by 88%. Forty lines of Python show you the whole distribution before launch instead of the invoice afterwards.

Read article