Robyn MMM: how Meta's open-source marketing mix model works, and how to run it

Robyn is the open-source marketing mix modeling package from Meta's Marketing Science team. It doesn't give you one model; it gives you a front of good ones and asks you to choose. Here's how it gets there, how to run it, and how to choose well.

Mike Taylor By Mike Taylor · Updated · 12 min read

Robyn is Meta’s open-source marketing mix modeling package: an R library that fits ridge regressions with adstock and saturation transformations, searches thousands of hyperparameter combinations with Meta’s Nevergrad optimizer, and hands you a set of Pareto-optimal models to choose from rather than a single answer. People search for it as Robyn MMM, Meta Robyn or Facebook Robyn, and they all mean the same package from Meta Marketing Science, which also includes experiment calibration, a budget allocator and a way to refresh a model as new weeks arrive.

When I built the original Vexpower MMM path, Robyn was the first proper library students met after building a model by hand in a spreadsheet, and the legacy simulator for it was called Can we try Facebook Robyn? because that was literally the question marketers were being asked by their bosses at the time. Apple’s App Tracking Transparency had just broken user-level attribution on iOS, Meta had a free tool, and everyone wanted to know whether it worked. The honest answer then and now is that it works well as a disciplined way to search a big space of plausible models, and that the hard part is the bit it leaves to you, which is choosing. If you’re new to the method itself, start with what marketing mix modeling is; this page assumes you know what a channel contribution is. I’ll walk through what Robyn actually does under the hood, then show you how to run it and how to pick a model from its Pareto front.

Where Meta’s Robyn came from and where it stands in 2026

The GitHub repository was created in April 2020 under Meta’s facebookexperimental organization, and Meta dates Robyn’s official release to November 2021. It reached CRAN in 2022 (the oldest archived CRAN release is 3.6.3, from May 2022), and the current CRAN version is 3.12.1, published in July 2025. The package is MIT licensed, the README lists Gufeng Zhou (Robyn’s creator) and Igor Skokan as contacts, and Bernardo Lares, who wrote much of the R code, appears as an author on CRAN. Meta’s December 2024 announcement put the R library at more than 70,000 CRAN downloads.

There’s also a Python story, and it’s worth being precise about it because the search results are confusing. In November 2023 Meta released an API wrapper (a plumber web service) that let Python call the R package, which still needed R installed. Then in 2024 it published a native Python port called robynpy on PyPI, announced in December 2024 as ready for beta testing; the README says it was rewritten from version 3.11.1 of the R package using LLMs and AI workflows such as Llama, and warns that the translation may have issues. The latest robynpy release as of October 2026 is 0.3.6, from April 2025. For anything you’d put in front of a CFO, I’d still use the R package.

In terms of momentum, the repository isn’t archived, but its main branch hasn’t had a commit since June 2025, and the more recent activity (into January 2026) sits on feature branches. That’s not a reason to avoid it (a stable method doesn’t need weekly releases), but it’s a contrast with Google’s Meridian and PyMC-Marketing, which both shipped major versions in the autumn of 2026. My comparison of open-source MMM libraries sets them side by side.

How Robyn builds a marketing mix model

Underneath, each Robyn model is a regression of your KPI (revenue or conversions) on transformed media variables plus trend, seasonality, holidays and any context variables you supply. Trend, seasonality and holiday effects come from Meta’s Prophet decomposition, which Robyn runs on your KPI first and feeds in as regressors. Each paid or organic media variable goes through two transformations before it reaches the regression: adstock for carryover (geometric, with one decay rate called theta, or the more flexible Weibull CDF and Weibull PDF shapes), then a Hill saturation curve with two parameters, alpha for the shape (larger is more S-shaped) and gamma for where the inflexion point sits.

The regression itself is ridge regression, fitted with the glmnet package. Ridge adds a penalty on the size of the coefficients, which shrinks them towards zero without setting any to exactly zero, and Robyn’s documentation chooses it over lasso for exactly that reason: Dropping a channel entirely is rarely realistic for a business that spends money on it. The penalty also helps with the multicollinearity you get when channels move together, at the cost of some bias, which I cover on the ridge regression page. Robyn constrains media coefficients to be positive by default.

The clever part is the hyperparameter search. A single model has a theta, alpha and gamma for every media variable, plus the ridge penalty lambda and (if you turn on time-series validation) a training-set share. In Meta’s demo, with five paid channels and one organic variable, that’s 18 transformation parameters plus lambda and the training share, a 20-dimensional space no grid search could cover. Robyn uses Nevergrad, Meta’s gradient-free optimization library (it’s a Python package, which is why you need Python alongside R), in an “ask and tell” loop: Nevergrad proposes a set of hyperparameters, Robyn transforms the data and fits the ridge regression, scores the result, and tells Nevergrad how it did. The default algorithm is TwoPointsDE, an evolutionary algorithm, and the defaults in robyn_run() are 2,000 iterations and five trials (independent runs with different seeds), which is also what the documentation recommends for the demo data.

The two objectives Robyn trades off

Most model searches optimize one number. Robyn optimizes two at once, and a third if you calibrate. The first is NRMSE, the normalized root mean squared error, which is the RMSE of the fit divided by the range of the KPI. The source code computes it as:

NRMSE=1n∑t(yt−y^t)2max⁡(y)−min⁡(y)\text{NRMSE} = \frac{\sqrt{\tfrac{1}{n}\sum_t (y_t - \hat{y}_t)^2}}{\max(y) - \min(y)}

The second is DECOMP.RSSD, which Robyn calls the “business error”. It’s the distance between each paid channel’s share of the modeled media effect and its share of spend:

DECOMP.RSSD=∑i(effect sharei−spend sharei)2\text{DECOMP.RSSD} = \sqrt{\sum_{i} \left(\text{effect share}_i - \text{spend share}_i\right)^2}

By default (rssd_zero_penalty = TRUE) Robyn also multiplies DECOMP.RSSD by one plus the share of media variables with zero effect, so a model that wipes out half your channels gets its score inflated by 50 percent. The logic is that a model which says your smallest channel drove most of your sales is more likely to be overfitting than telling you something profound, so the search should prefer models whose credit split is plausible given how the budget was allocated. That’s a sensible guard rail, but it’s worth seeing what it implies: DECOMP.RSSD rewards models that agree with your current budget, and if your budget is badly allocated, the “best” models will understate how badly. I treat it as a plausibility filter, not as evidence. If you turn on time-series validation (ts_validation = TRUE), the objective becomes the NRMSE on a validation slice of the data rather than the training data, with a separate test slice held out, which I’d recommend; see MMM validation for why fit on training data says so little.

Why Robyn gives you a Pareto front instead of one answer

Because there are two objectives, there’s no single best model. A model is “Pareto-optimal” if no other model beats it on both objectives at once, and the set of such models forms the first Pareto front; take those away and the next set forms the second front, and so on. robyn_outputs() collects enough fronts to give at least 100 candidate models by default (pareto_fronts = "auto"), clusters them with k-means on their channel-level returns, and picks the best model in each cluster by the combined normalized objectives. It writes a one-page summary for each candidate, with the decomposition, response curves, adstock decay and fit, and then it’s your turn.

The explorer below recreates that situation with 200 synthetic candidates. Every green model on the first front is defensible on the numbers, yet they tell quite different stories about which channel works, and that’s the point Robyn is making by not choosing for you.

Interactive
Pick a model from Robyn's Pareto front

Each dot is one candidate model from a Robyn-style search. Lower-left is better on both objectives. Click any dot to see how it splits credit between channels, and compare it with how you split the budget.

Front 1Fronts 2–3DominatedSelectedBest by your weighting

Model
–
NRMSE
–
DECOMP.RSSD
–
Pareto front
–

Synthetic candidates, not Robyn output: effect shares are drawn around an invented 'true' split, and NRMSE rises the further a model's split is from it. DECOMP.RSSD is computed the way Robyn does, √Σ(share of effect − share of spend)², without the zero-effect penalty. Spend shares are invented.

Play with the weighting and you’ll see the trade-off. Put all the weight on fit and the winning model gives most of the credit to Search and Facebook, because that’s what the (invented) data says; put the weight on business sense and the winner looks much more like the spend split, with TV getting credit closer to its 35 percent of the budget. Neither is automatically right. In my experience the useful move is to shortlist three or four models from the first front, open their one-pagers, and check each against things you know independently: an experiment result, the timing of a campaign you remember, whether a channel’s response curve has flattened at a spend level that matches what your media buyer tells you. Robyn’s own analyst guide says the same thing in its own words: Check calibration first, then business plausibility (ROI, response curves, spend and effect shares), and only use the statistics to break ties between models that tell the same story.

Calibrating Robyn with experiment results

Calibration is where Robyn moves from “plausible” towards “causal”. You give it a data frame of experiment results through the calibration_input argument of robyn_inputs(), one row per test, with the channel name, the start and end dates of the test, the incremental outcome measured (liftAbs), the spend during the test, a confidence level, the metric (which must match your dependent variable) and a calibration_scope of “immediate” or “total”. Robyn then adds a third objective, MAPE.LIFT, the mean absolute percentage error between the model’s predicted contribution for that channel over the test window and the experiment’s measured lift, and the search now has to satisfy the experiment as well as the fit and the spend shares. By default robyn_outputs() also discards candidates whose calibration error is outside the best 10 percent (calibration_constraint = 0.1).

The demo script includes some careful guidance here that’s easy to skim past. Robyn takes a point estimate only, not an interval, so the confidence column is the only way your test’s uncertainty enters. The spend you report must match the spend in the model’s data for that window within about 10 percent. Experiments like Meta’s conversion lift or a geo-lift test measure the immediate effect of the test spend, not historical carryover, so use “immediate” scope for those. Meta also recommends Weibull PDF adstock when calibrating, to give the search more freedom to fit both the history and the test. I go deeper into how to choose and weight experiments on the MMM calibration page.

The budget allocator and model refresh

Once you’ve picked a model, robyn_allocator() turns its response curves into a budget recommendation. There are two scenarios. “max_response” asks the classic question, given this budget, how should I split it across channels to get the most response; “target_efficiency” asks how much I can spend before my ROAS (or CPA, for conversion models) hits a target. You constrain each channel with channel_constr_low and channel_constr_up, expressed as multiples of its current spend (so 0.7 and 1.5 mean “between 70 and 150 percent of today”), and date_range sets which period’s spend levels and carryover the simulation starts from. The optimizer is nloptr’s SLSQP and AUGLAG combination. The demo script itself warns that the allocation needs further validation and shouldn’t be interpreted if the selected model doesn’t match business expectations, which is the right attitude: An allocator is only as good as the curves it’s maximizing over, and I’ve written about how to sanity-check a budget allocation separately.

robyn_refresh() addresses the other practical problem, which is that a model built last quarter needs to absorb new weeks without being rebuilt from scratch. It takes the JSON file exported for your chosen model, the updated data, and a number of new periods (refresh_steps), and reruns the search with the hyperparameter ranges narrowed around the original model, so new results stay comparable with old ones. The demo’s rule of thumb is to rebuild rather than refresh when more than half the data is new or when you add variables. The features page also says plainly that refresh hasn’t been thoroughly tested and may give unstable results, so I’d compare a refresh against a fresh build at least once before relying on it.

How to run Robyn in R, step by step

You need R 4.0 or later and a working Python with Nevergrad that R can reach through reticulate. The install is two lines for Robyn and a few for Nevergrad, following Meta’s own installation guide (which warns that newer Python versions have caused Nevergrad errors, so if it won’t load, try a slightly older Python in the virtual environment):

install.packages("Robyn")              # CRAN, 3.12.1 as of October 2026
# remotes::install_github("facebookexperimental/Robyn/R")  # development version

install.packages("reticulate")
library(reticulate)
virtualenv_create("r-reticulate")
use_virtualenv("r-reticulate", required = TRUE)
py_install("nevergrad", pip = TRUE)

Robyn ships with a simulated dataset, dt_simulated_weekly, which is what the official demo uses: 208 weeks from 23 November 2015 to 11 November 2019, with revenue, spend on TV, out-of-home, print, Facebook and search, Facebook impressions, search clicks, a competitor-sales control, an events flag and newsletter sends as an organic variable. It also ships dt_prophet_holidays, Prophet’s holiday calendar for more than 120 countries. Model specification happens in robyn_inputs(), which is where you map columns to roles:

library(Robyn)
data("dt_simulated_weekly")
data("dt_prophet_holidays")

InputCollect <- robyn_inputs(
  dt_input = dt_simulated_weekly,
  dt_holidays = dt_prophet_holidays,
  date_var = "DATE",
  dep_var = "revenue",
  dep_var_type = "revenue",                 # or "conversion"
  prophet_vars = c("trend", "season", "holiday"),
  prophet_country = "DE",
  context_vars = c("competitor_sales_B", "events"),
  paid_media_spends = c("tv_S", "ooh_S", "print_S", "facebook_S", "search_S"),
  paid_media_vars = c("tv_S", "ooh_S", "print_S", "facebook_I", "search_clicks_P"),
  organic_vars = c("newsletter"),
  factor_vars = c("events"),
  window_start = "2016-01-01",
  window_end = "2018-12-31",
  adstock = "geometric"                     # or "weibull_cdf" / "weibull_pdf"
)

Note the split between paid_media_spends (what you paid, used for ROI and allocation) and paid_media_vars (the exposure metric the model uses, such as impressions, which can be the spend again if that’s all you have). Next come the hyperparameter ranges. Their names are built from your media variables, so run hyper_names() to get them exactly right, then give each a lower and upper bound (or a single value to fix it). The demo’s rules of thumb for geometric theta are 0.3 to 0.8 for TV, 0.1 to 0.4 for out-of-home, print and radio, and 0 to 0.3 for digital; for the Hill function, alpha between 0.5 and 3 and gamma between 0.3 and 1:

hyper_names(adstock = InputCollect$adstock, all_media = InputCollect$all_media)

hyperparameters <- list(
  facebook_I_alphas = c(0.5, 3),  facebook_I_gammas = c(0.3, 1),  facebook_I_thetas = c(0, 0.3),
  print_S_alphas = c(0.5, 1),     print_S_gammas = c(0.3, 1),     print_S_thetas = c(0.1, 0.4),
  tv_S_alphas = c(0.5, 1),        tv_S_gammas = c(0.3, 1),        tv_S_thetas = c(0.3, 0.8),
  search_clicks_P_alphas = c(0.5, 3), search_clicks_P_gammas = c(0.3, 1), search_clicks_P_thetas = c(0, 0.3),
  ooh_S_alphas = c(0.5, 1),       ooh_S_gammas = c(0.3, 1),       ooh_S_thetas = c(0.1, 0.4),
  newsletter_alphas = c(0.5, 3),  newsletter_gammas = c(0.3, 1),  newsletter_thetas = c(0.1, 0.4),
  train_size = c(0.5, 0.8)
)
InputCollect <- robyn_inputs(InputCollect = InputCollect, hyperparameters = hyperparameters)

Then you run the search, build the Pareto front and export the candidates, pick one, and hand it to the allocator. The model ID below is a placeholder; yours will come from your own run:

OutputModels <- robyn_run(
  InputCollect = InputCollect,
  cores = NULL,          # all cores but one
  iterations = 2000,
  trials = 5,
  ts_validation = TRUE   # train / validation / test split for NRMSE
)
OutputModels$convergence$moo_distrb_plot   # did the objectives settle?

OutputCollect <- robyn_outputs(
  InputCollect, OutputModels,
  pareto_fronts = "auto",
  csv_out = "pareto",
  clusters = TRUE,
  export = TRUE,
  plot_folder = "~/Desktop",
  plot_pareto = TRUE      # one-pagers for every candidate
)

select_model <- "5_257_2"  # the candidate you chose after reading the one-pagers
ExportedModel <- robyn_write(InputCollect, OutputCollect, select_model, export = TRUE)

AllocatorCollect <- robyn_allocator(
  InputCollect = InputCollect,
  OutputCollect = OutputCollect,
  select_model = select_model,
  channel_constr_low = 0.7,
  channel_constr_up = c(1.2, 1.5, 1.5, 1.5, 1.5),
  scenario = "max_response"
)
plot(AllocatorCollect)

These calls follow the official demo/demo.R for version 3.12.1 and the function signatures in the package source; robyn_response() then gives you the marginal return at any spend level, which is how you’d get a marginal ROAS for the next thousand dollars. I’ve adapted the same pipeline to the 104-week practice dataset used across this library, with Robyn’s simpler setup for data that only has trend, seasonality and a promotion flag. That dataset was generated from an exactly linear formula with no noise, no carryover and no saturation, so a run on it should find decay rates near zero and push the Hill parameters to the edges of their ranges; it’s for learning the workflow, and any decay it reports is not a finding. One caveat in the spirit of honesty: I wrote it against the demo and the source, but it hasn’t yet been executed end to end. If an argument has moved, ?robyn_inputs will tell you.

r
Robyn quickstart for the practice data (R)Inputs, hyperparameter ranges, robyn_run, Pareto outputs, model selection, allocator and a marginal-return check on mmm-practice-synthetic.csv.
Download

Where Robyn is strong and where it struggles

Robyn’s biggest strength is that it makes the analyst’s degrees of freedom visible. A hand-built MMM involves dozens of choices about decay rates and curve shapes, usually made quietly by whoever built it, and Robyn turns those into a search with explicit ranges and a set of alternatives you can inspect. It’s quick, because each candidate is a penalized linear regression rather than an MCMC run, so the recommended 10,000 fits (2,000 iterations times five trials) are a laptop job rather than a GPU one. The outputs are designed for marketers rather than statisticians, with one-pagers that put the decomposition, response curves and ROI side by side, and the calibration, allocator and refresh functions cover most of what a working measurement team needs. For a first serious model in a team that knows R, it’s still a very reasonable choice.

The limits are just as real. Ridge regression gives you point estimates, not posterior distributions, so uncertainty shows up only as disagreement between candidate models (Robyn does bootstrap confidence intervals for ROI within each cluster of models, which helps, but it isn’t the same thing as a posterior). It’s national only: There’s no hierarchical geo model, which matters if regional data is where your extra signal lives, and you’ll find that in Meridian or PyMC-Marketing instead. DECOMP.RSSD builds a mild bias towards your current budget split into model selection, as I discussed above. The R-plus-Python dependency on Nevergrad is the most common installation headache I’ve seen people hit. And like every MMM, it needs data: The analyst guide asks for at least two years of weekly data and suggests roughly one independent variable per ten observations, which a lot of smaller advertisers don’t have.

Who should use Robyn in 2026

I’d reach for Robyn if your team works in R, your data is national and weekly with at least two years of history, and you value a fast, transparent search over a full Bayesian treatment of uncertainty. It’s also a good teaching tool, which is why it sat in the Vexpower path: Watching the Pareto front form makes the point that an MMM is a family of defensible answers better than any lecture. If you have regional data, experiment results you want to encode as priors, reach and frequency data, or a Python team, look at Google Meridian or PyMC-Marketing first, and if you’d rather understand every step by building one yourself, the R walkthrough or the Vexpower MMM course are a gentler start.

Whichever tool you pick, the choice Robyn forces on you, between models that fit equally well but tell different stories, doesn’t go away with the others. Bayesian libraries hide it in the priors rather than presenting it as a front, which is arguably tidier but not more honest, and I’ve come to appreciate that Robyn makes you look at it directly.

Downloads

r
Robyn quickstart script for the practice data (R)robyn_inputs, hyperparameters, robyn_run, robyn_outputs and robyn_allocator on the 104-week practice CSV. Written against Robyn 3.12.1's demo and source.
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 Robyn MMM?

Robyn is a free, open-source marketing mix modeling package from Meta Marketing Science, written in R and released under the MIT license. It fits ridge regressions with adstock and Hill saturation transformations, uses Meta's Nevergrad library to search thousands of hyperparameter combinations, and returns a Pareto front of models that balance prediction error (NRMSE) against how far each model's credit split strays from the spend split (DECOMP.RSSD). It also includes experiment calibration, a budget allocator and model refresh.

Is there a Python version of Robyn?

Yes, but it's a beta. Meta published a native Python port called robynpy on PyPI in 2024, built by translating the R code with large language models, and announced it in December 2024. Its latest release as of October 2026 is 0.3.6 from April 2025. The R package on CRAN, version 3.12.1 from July 2025, is still the reference implementation.

What is DECOMP.RSSD in Robyn?

DECOMP.RSSD, the decomposition root sum of squared distance, measures how far a model's share of media effect is from the share of spend, summed over paid channels: the square root of the sum of squared differences between each channel's effect share and spend share. Robyn minimizes it alongside NRMSE to steer away from models that hand most of the credit to a tiny channel. It's a plausibility filter, not a measure of truth.

How many iterations should I run in Robyn?

Robyn's documentation recommends at least 2,000 iterations across at least five trials for the default TwoPointsDE optimizer with geometric adstock. Weibull adstock and calibration widen the search space and need more. Smaller runs are fine for checking your code works, but not for decisions; check the convergence plots before you trust the results.

Does Robyn support geo-level data?

Not as a hierarchical model. Robyn fits one national time series per model. If you have regional data, you can model regions separately or aggregate them, but you lose the partial pooling that Google's Meridian or PyMC-Marketing's multidimensional MMM provide.

Is Robyn still maintained?

Robyn isn't archived, and the R package's most recent CRAN release is 3.12.1 from July 2025. The main branch hasn't had a commit since June 2025 and the Python port's last release was April 2025, so check the GitHub repository's recent activity before you build a long-term process on it.

References

  1. Robyn on GitHub (facebookexperimental/Robyn) README, MIT licence, maintainers, Python beta, release tags; repository created April 2020.
  2. Robyn demo script (demo/demo.R) The function calls, arguments and hyperparameter guidance used on this page.
  3. Robyn source: robyn_run() in R/R/model.R Defaults (iterations 2000, trials 5, TwoPointsDE, rssd_zero_penalty), the NRMSE and DECOMP.RSSD formulas, glmnet.
  4. Robyn on CRAN Version 3.12.1, published 2 July 2025; imports glmnet, nloptr, prophet, reticulate.
  5. Robyn documentation: Features Ridge rather than lasso, Nevergrad TwoPointsDE, objectives, clustering, allocator, refresh.
  6. Robyn documentation: An analyst's guide to MMM Data requirements and model selection advice.
  7. Meta for Developers: Announcing the Python version of Project Robyn (19 December 2024) Official release November 2021; robynpy beta built with LLM-assisted porting.
  8. robynpy on PyPI Latest release 0.3.6, 3 April 2025.
  9. Robyn Nevergrad installation guide (demo/install_nevergrad.R) reticulate virtualenv setup and the Python version warning.
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