LightweightMMM was Google’s open-source Bayesian marketing mix modeling library for Python, built on NumPyro and JAX, and it’s now deprecated: Google stopped supporting it when it made Meridian generally available on 29 January 2025, and archived the GitHub repository on 19 January 2026. If you have a LightweightMMM model in production, the advice from Google’s own README is to switch to Meridian, and the map below shows where each LightweightMMM function and argument went.
I spent a lot of time with this library. When I built the original Vexpower MMM path, LightweightMMM was the Python counterpart to Meta’s Robyn, and the legacy simulator for it was called Can we try Google LightweightMMM?, because in 2022 Google was getting into the game with a library that took a completely different approach from Meta’s. I also wrote How Google LightweightMMM Works, published on Recast’s blog (I’ve advised Recast on MMM), and recorded a walkthrough of Google’s LightweightMMM on YouTube; the archived README still lists both under my name in its community spotlight. So this page is partly a eulogy, but mostly a practical guide for anyone who has to move a LightweightMMM model somewhere else.
What LightweightMMM was
LightweightMMM appeared on GitHub in February 2022, with version 0.1.1 on PyPI that month and nine more releases up to 0.1.9 in May 2023. It was always labeled “not an official Google product”, and it was written by a group of Google engineers and data scientists (the citation lists Pablo Duque, Dirk Nachbar, Yuka Abe, Christiane Ahlheim, Mike Anderson, Yan Sun, Omri Goldstein and Tim Eck). The methods came straight from Google’s 2017 research papers: the carryover and shape effects paper by Jin and colleagues, the geo-level hierarchical model by Sun and colleagues, and Chan and Perry’s survey of MMM’s challenges. Where Robyn searched over thousands of ridge regressions, LightweightMMM fitted one Bayesian model with MCMC and gave you posterior distributions for every parameter.
The “lightweight” was real. You passed NumPy arrays of media, extra features, costs and the target, scaled them yourself, called fit(), and got a model you could plot, use for prediction and optimize a budget with. The whole API fitted on one page of the README:
import jax.numpy as jnp
from lightweight_mmm import lightweight_mmm, optimize_media, preprocessing
media_scaler = preprocessing.CustomScaler(divide_operation=jnp.mean)
media_data_train = media_scaler.fit_transform(media_data_train)
# ...the same for target, extra features and costs...
mmm = lightweight_mmm.LightweightMMM(model_name="hill_adstock")
mmm.fit(media=media_data_train, media_prior=costs, target=target_train,
extra_features=extra_features_train,
number_warmup=1000, number_samples=1000, number_chains=2)
media_effect_hat, roi_hat = mmm.get_posterior_metrics()
solution = optimize_media.find_optimal_budgets(
n_time_periods=extra_features_test.shape[0], media_mix_model=mmm,
budget=40, extra_features=extra_features_test, prices=prices)
That’s adapted from the archived README, which is the last word on the API since nothing will change now.
How the LightweightMMM model worked
The model was a regression of the KPI on an intercept, a trend, seasonality, transformed media and extra features. The trend was a power curve in time (a coefficient coef_trend times time raised to expo_trend, with the exponent’s prior between 0.5 and 1.5), and seasonality was a set of sine and cosine terms with two degrees and a 52-week period by default, plus optional day-of-week effects for daily data. Media coefficients had a HalfNormal prior whose scale was the channel’s cost, which you passed as media_prior, so channels you spent more on were allowed larger effects. For geo data you passed 3-D arrays and the media coefficients became hierarchical across regions.
The interesting choice was the media transformation, picked with model_name. There were three, and the README recommended trying all of them and keeping the one with the best out-of-sample fit:
- adstock: Geometric carryover with a
lag_weight(prior Beta(2, 1)), followed by raising the result to anexponent(prior Beta(9, 1)) for diminishing returns. - hill_adstock (the default): The same geometric adstock, followed by a Hill saturation curve with a
half_max_effective_concentrationand aslope, both with Gamma(1, 1) priors. - carryover: The delayed-peak carryover from the Jin paper, where the weight on lag l is
ad_effect_retention_rateraised to the power (l −peak_effect_delay)², over 13 lags, followed by an exponent.
The geometric adstock was normalized by default (the output was multiplied by one minus the lag weight), and the optimizer, find_optimal_budgets(), held total spend fixed and let each channel move 20 percent either way by default, given a budget, a number of future periods and the price per media unit. It also needed a media_gap argument if there was a gap between the end of your training data and the period you were planning, so the adstock could be computed correctly. If those names are familiar, the map below tells you what replaced each of them.
LightweightMMM deprecated: the timeline
The end came in three steps, all recorded in the repository. On 7 March 2024, the README announced that Google had released Meridian under limited availability for selected advertisers and that LightweightMMM would be sunset once Meridian reached general availability. On 29 January 2025, the README was updated to say Meridian was available to everyone on GitHub, that “LMMM is not supported anymore”, and that Google highly recommends switching. The last commits followed in mid-2025, and on 19 January 2026 the repository was archived, which on GitHub means read-only: no new issues, pull requests or fixes.
In practice that means three things if you still depend on it. The package still installs from PyPI at 0.1.9, but it was written against the JAX and NumPyro versions of 2023 and won’t be updated as they change. Nobody will answer the issues you file. And any methodology questions from a finance team (“is this still the method Google recommends?”) now have an awkward answer. If you only need to reproduce an old result, pin your environment and keep it; for anything you’ll refresh, plan the move.
How LightweightMMM maps to Meridian
Google’s migration guide says the input data for the two projects is the same, and the conceptual core survives: Both are Bayesian models with geometric adstock and Hill saturation, sampled with NUTS, with a hierarchical option for geo data. What changes is almost everything around that core, and some of it changes your results, not just your code. Search the map for any function or argument you used:
44 mappings
pip install lightweight_mmmpip install google-meridian (add [and-cuda] on Linux with an NVIDIA GPU)Meridian needs Python 3.11 to 3.13. Install it in a fresh environment rather than on top of an old LightweightMMM one.
NumPyro on JAXTensorFlow Probability; JAX backend by default since 2.0 (TensorFlow still supported)Distributions come from tensorflow_probability.substrates.jax in the current demo, not numpyro.distributions.
Status: archived 19 Jan 2026, last release 0.1.9 (May 2023)Active: 2.1.0 (September 2026)LightweightMMM’s README says it is not supported anymore and recommends Meridian.
numpyro.set_host_device_count(n) to run chains in parallel on CPURun on a GPU where you can (Google tests on a T4 with 16 GB RAM)Meridian runs on CPU too, but geo models with many regions are slow without a GPU.
utils.simulate_dummy_data(data_size, n_media_channels, n_extra_features, geos)Simulated CSVs in meridian/data/simulated_data/csv (for example geo_all_channels.csv)The getting-started notebook reads geo_all_channels.csv straight from GitHub.
media array, 2-D (time, channel) or 3-D (time, channel, geo)DataFrameInputDataBuilder(...).with_media(df, media_cols, media_spend_cols, media_channels)Long-format DataFrame with time and (optionally) geo columns instead of NumPy arrays.
targetbuilder.with_kpi(df) with kpi_type="revenue" or "non_revenue"For non-revenue KPIs, add with_revenue_per_kpi(df) so ROI can be expressed in money.
extra_featuresbuilder.with_controls(df, control_cols=[...])Variables you control and might change, such as promotions or price, can go in with_non_media_treatments instead.
media_prior (cost per channel, also used as the HalfNormal scale of the media coefficient)media_spend_cols in with_mediaMeridian uses spend to turn ROI priors into coefficients, rather than using cost as a prior scale.
preprocessing.CustomScaler(divide_operation=jnp.mean) for media, target, costs and featuresAutomaticGoogle’s migration table lists input scaling as manual in LightweightMMM and automatic in Meridian. Drop your scalers.
No population inputbuilder.with_population(df)One population per geo. Meridian uses it to scale the KPI and media across geos of different sizes.
LightweightMMM(model_name="hill_adstock") (the default)model.Meridian(input_data=data, model_spec=spec.ModelSpec(...))Meridian’s default is adstock then Hill, the closest match. Set hill_before_adstock=True to reverse the order.
model_name="adstock" (adstock plus an exponent for saturation)No direct equivalentMeridian only supports Hill saturation or none (saturation_spec="hill" or "none"), so expect a different curve shape.
model_name="carryover" (ad_effect_retention_rate, peak_effect_delay, number_lags=13)No delayed-peak adstock; adstock_decay_spec="geometric" (default) or "binomial", max_lag=8 by defaultIf a delayed peak mattered in your LightweightMMM results, check the response timing carefully after migrating.
lag_weight prior Beta(2, 1)alpha_m prior Uniform(0, 1)Meridian’s decay prior is uninformative by default; LightweightMMM’s leaned towards long carryover.
half_max_effective_concentration prior Gamma(1, 1)ec_m prior TruncatedNormal(0.8, 0.8, 0.1, 10)Both are the half-saturation point of the Hill curve on scaled media.
slope prior Gamma(1, 1)slope_m Deterministic(1)Meridian fixes the paid-media Hill slope at one by default, so curves are concave, never S-shaped. You can override the prior.
Media coefficient HalfNormal(scale = media_prior)roi_m prior LogNormal(0.2, 0.9) by default; paid_media_prior_type="coefficient" gives beta_m HalfNormal(5)The biggest conceptual change: Meridian puts priors on ROI by default, which is what makes experiment calibration straightforward.
custom_priors={"intercept": numpyro.distributions.Uniform(1, 5)}prior_distribution.PriorDistribution(...) passed as ModelSpec(prior=...)Parameter names differ (roi_m, alpha_m, ec_m, slope_m, beta_m, gamma_c, sigma and so on).
Trend: coef_trend and expo_trend (a power curve in time)Knots: ModelSpec(knots=...) or enable_aks=TrueGoogle’s table: LightweightMMM uses a straight line plus sinusoids, Meridian uses knots.
degrees_seasonality=2, seasonality_frequency=52Knots, plus controls for any known seasonal eventsThere are no Fourier seasonality arguments; more knots let the baseline follow seasonal shape.
weekday_seasonality=True (daily data)No dedicated argumentMy suggestion rather than Google’s: add day-of-week indicators as controls if you model daily data.
Geo hierarchy with no extra free parameterseta_m (spread of media effects across geos); controls hierarchical via xi_cMeridian’s hierarchy can be normal or log-normal (media_effects_dist) and estimates how much geos differ.
No reach and frequencybuilder.with_reach(df, reach_cols, frequency_cols, rf_spend_cols, rf_channels)R&F channels get their own Hill-then-adstock treatment and an optimal frequency.
Search query volume as a confounder: possible but manualA control variable, with documented guidance for paid searchFrom Google’s migration table.
Experiment calibration: possible but manual, through custom priorsROI priors, ModelSpec(roi_calibration=...), CalibrationBuilder, Meridian GeoXMake experiment priors wider than the test’s standard error, since a test’s ROI and the model’s ROI aren’t defined identically.
mmm.fit(media=..., media_prior=..., target=..., extra_features=..., number_warmup=1000, number_samples=1000, number_chains=2)mmm.sample_prior(500); mmm.sample_posterior(n_chains=10, n_adapt=1000, n_burnin=500, n_keep=1000, seed=0)Settings from Google’s demo. Data goes in when you build InputData, not at fit time.
target_accept_prob=.85sample_posterior(..., max_tree_depth=10, ...)Meridian exposes its own NUTS tuning arguments (max_tree_depth, init_step_size and others) rather than a target acceptance rate.
utils.save_model(mmm, file_path) / utils.load_model(file_path)meridian_serde.save_meridian(mmm, path) / meridian_serde.load_meridian(path)Import from meridian.schema.serde. Models save as .binpb protocol buffers.
mmm.print_summary() (r_hat under 1.1)Analyzer(...).rhat_summary(), visualizer.ModelDiagnostics(mmm).plot_rhat_boxplot(), reviewer.ModelReviewer(mmm).run()Meridian’s docs treat r-hat under 1.2 as approximate convergence; I’d still aim for close to 1.0.
plot.plot_model_fit(media_mix_model=mmm, target_scaler=...)visualizer.ModelFit(mmm).plot_model_fit()No scaler to pass, since Meridian unscales for you.
mmm.predict(...) and plot.plot_out_of_sample_model_fit(...)ModelSpec(holdout=...) and Analyzer.expected_vs_actual_data() / ModelFitHoldouts are declared in the model spec and excluded from fitting.
plot.plot_media_channel_posteriors(...) / plot.plot_prior_and_posterior(...)visualizer.ModelDiagnostics(mmm).plot_prior_and_posterior_distribution()Comparing prior and posterior tells you which channels the data actually informed.
media_effect_hat, roi_hat = mmm.get_posterior_metrics()visualizer.MediaSummary(mmm).summary_table() or Analyzer(...).summary_metrics() / .roi()Meridian also reports marginal ROI, effectiveness and cost per incremental KPI, with credible intervals.
plot.plot_bars_media_metrics(metric=roi_hat, channel_names=...)visualizer.MediaSummary(mmm).plot_roi_bar_chart()plot.plot_response_curves(media_mix_model=mmm, media_scaler=..., target_scaler=...)visualizer.MediaEffects(mmm).plot_response_curves()plot_hill_curves() and plot_adstock_decay() show the two transformations separately.
plot.plot_media_baseline_contribution_area_plot(...)visualizer.MediaSummary(mmm).plot_channel_contribution_area_chart()plot_contribution_waterfall_chart() is the other common view.
No built-in reportsummarizer.Summarizer(mmm).output_model_results_summary(filename, filepath, start_date, end_date)A two-page HTML report; the Scenario Planner turns a saved model into a dashboard.
optimize_media.find_optimal_budgets(n_time_periods, media_mix_model, budget, prices, extra_features, ...)optimizer.BudgetOptimizer(mmm).optimize(budget=..., fixed_budget=True, ...)With no arguments Meridian optimises the historical period at historical total spend.
bounds_lower_pct=.2, bounds_upper_pct=.2spend_constraint_lower / spend_constraint_upper (default 0.3 each for a fixed budget)Same idea: each channel can move a percentage either way from its current share.
prices (cost per media unit)Derived from media and spend in the data; pass new_data for a future periodFor a forecast scenario, new_data carries future media, spend and revenue per KPI.
media_gap (media between the end of training and the optimisation period)Carryover from earlier media is included automaticallyMeridian’s optimizer counts lagged effects of media run before the start date.
plot.plot_pre_post_budget_allocation_comparison(...)results.plot_budget_allocation(), results.plot_spend_delta(), results.output_optimization_summary(filename, filepath)No target-ROI scenariooptimize(fixed_budget=False, target_roi=... or target_mroi=...)Flexible-budget scenarios let total spend change to hit an efficiency target.
Four of those changes matter more than the rest. The first is priors on ROI. LightweightMMM scaled the media coefficient’s prior by cost; Meridian, by default, puts a LogNormal(0.2, 0.9) prior directly on each channel’s ROI, which is easier to reason about and to calibrate with experiments, but it means your channels start from a different place, and on thin data that shows up in the answer. The second is the time effects. LightweightMMM’s power-curve trend and sinusoidal seasonality are replaced by knots, a piecewise baseline, so how much of your sales gets attributed to the baseline versus media can shift. The third is curve shapes: Meridian has no exponent-only model and no delayed-peak adstock, and it fixes the Hill slope at one by default, so curves are always concave. If your LightweightMMM model chose carryover because TV peaked a few weeks after airing, check the response timing after migrating. The fourth is scaling, which Meridian now does for you, so drop your CustomScaler objects rather than feeding Meridian pre-scaled data.
Migrating a LightweightMMM model, step by step
This is the order I’d do it in, so that you can tell which differences come from the method and which from mistakes in the move:
- Freeze the old model’s outputs: ROI by channel with intervals, the decomposition over time, response curves and the last budget recommendation, all in original units.
- Reshape the data into one long DataFrame with a time column, a geo column if you had geo data, and one column per KPI, media metric, spend and control. Add population per geo for geo models.
- Build the input with
DataFrameInputDataBuilder(with_kpi,with_media,with_controls, andwith_populationorwith_revenue_per_kpias needed), and check the data summaries before modeling. - Fit Meridian with its default priors first, and a sensible number of knots, so you see what the data says under Meridian’s assumptions before you add your own.
- Compare against the frozen outputs channel by channel. Differences in baseline share usually trace back to knots versus trend and seasonality; differences in a single channel usually trace back to the prior or the curve shape.
- Only then add informative ROI priors from experiments, with standard deviations wider than the tests’ standard errors, and rerun the comparison.
- Rebuild the optimization with
BudgetOptimizerand the same spend bounds you used before (LightweightMMM’s default was ±20 percent; Meridian’s is ±30 percent for a fixed budget).
The Meridian page has a complete, tested script for steps three, four and seven, run on the same practice data used across this library.
Should you still use LightweightMMM?
For new work, no. Its methods were sound, and they live on in Meridian, which is the natural home for anyone who liked LightweightMMM’s Bayesian approach and Google’s research lineage. If what you liked was the small, transparent API and the freedom to choose your own transformations, PyMC-Marketing is the closer spiritual successor: Its default model is also adstock plus a saturation curve, with priors you set on the transformation parameters, and it offers the delayed-peak adstock that Meridian dropped. My comparison of open-source MMM libraries covers the rest of the options, and if you’re learning the method rather than migrating a model, start with what marketing mix modeling is or the Vexpower MMM course.
LightweightMMM taught a lot of marketers, me included, that a marketing mix model could be a few dozen lines of Python rather than a six-figure consultancy project. That lesson outlived the library, and it’s the reason the move to Meridian, for most teams, is an afternoon of reshaping data and a week of checking results rather than starting over.
