feat(mmm): OptimizableMuEffect protocol + DiscountedEventEffect (discount-depth lever)#2621
feat(mmm): OptimizableMuEffect protocol + DiscountedEventEffect (discount-depth lever)#2621PabloRoque wants to merge 3 commits into
Conversation
Introduces two new classes:
- OptimizableMuEffect: abstract base for spend-driven mu effects that
participate in BudgetOptimizer's joint allocation problem. Subclasses
implement replace_for_optimization(), set_budget_for_sampling(), and
budget_channel_names so the optimizer can discover and route budgets
to arbitrary effect types.
- DiscountedEventEffect: concrete implementation for promotional events
(e.g. Black Friday, summer sale) where the lever is discount depth.
Uses the full revenue-retention formula:
lift_k = beta_k * ln(1 + d_k) * (1 - d_k) - d_k * r_k
where r_k = event_revenue_k / n_periods_k (average baseline revenue per
in-sample period, normalised by target_scale). This makes lift(0) = 0
and lift(1) = -r_k (100% discount drives total revenue to zero).
- event_revenue and revenue_per_period are auto-computed from y
- r_k is registered as a pmd.Data node in the normalized model scale
- discount_min / discount_max bound the optimizer's budget range
- budget_bounds returns [(d_min * rev, d_max * rev)] per event
51 tests added covering the full contract: OptimizableMuEffect ABC,
DiscountedEventEffect model variables, replace_for_optimization,
set_budget_for_sampling, BudgetOptimizer integration end-to-end, and
serialization roundtrips.
- BudgetOptimizer discovers OptimizableMuEffect instances on the model
at construction time and extends _budgets_flat with their channels so
all spend types share one flat variable and the total-budget constraint
is meaningful across the full portfolio.
- replace_optimizable_mueffects() is called once during __init__; each
effect's replace_for_optimization() returns {var_name: tensor} dicts
that are merged and passed to a single pm.do() call.
- BudgetOptimizerWrapper.sample_response_distribution() routes per-channel
budgets back to the originating effect via set_budget_for_sampling()
before calling sample_posterior_predictive, and keeps event channels
out of the media channel_contribution coordinate so no NaN expansion
occurs.
- MMM.sample_response_distribution uses a 3-phase approach:
(1) clone model, (2) set media data, (3) set effect data; avoids the
NaN issue that arose when effect channels appeared in the channel dim.
- constraints.py: fix budgets_flat slice to use the full combined length.
- utils.py: relax create_zero_dataset validation for effect-only windows.
- __init__.py: export MuEffect, OptimizableMuEffect, DiscountedEventEffect.
Adds mmm_discounted_events.ipynb demonstrating DiscountedEventEffect end-to-end: - Synthetic DGP with three promotional event windows; ground-truth lift follows beta*ln(1+d)*(1-d) - d*r_k (full revenue-retention formula). - MMM build/fit with DiscountedEventEffect attached as a mu_effect. - Posterior lift curves per event with 94 % HDI bands, including the -d*r_k baseline-cost term so curves can go negative at high discount. - Joint budget optimization across paid media + promotional events. - Discount prescription table snapped to 5 % grid steps. - sample_response_distribution comparison (current vs optimal). gallery.yaml / gallery.md: add entry under Budget Allocation section.
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## v1.0.0 #2621 +/- ##
==========================================
- Coverage 94.04% 93.96% -0.08%
==========================================
Files 97 97
Lines 14532 14714 +182
==========================================
+ Hits 13666 13826 +160
- Misses 866 888 +22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Detailed review —
|
|
@PabloRoque , some initial thoughts from Daimon (I have found these reviews complementary to human ones ;) . You can re-trigger this again whenever you want more feedback :) |
|
I did not fully review but, do we want to make this part of optimozer? It feels, we could keep optimizer agnostic, and add this like helpers. Plus, if Daimon it's right, I'm a bit worry about behavior changes e.g: constraints 🧐 |
|
Just flagging |
Following up on my earlier comment after a proper deep dive. First off: the idea here is incredible and genuinely valuable — joint optimization of discount depth with media budgets is something some users ask for, the lift formula is well thought out, the single- My concern is that the implementation moves in the opposite direction of what we've been building toward. @williambdean's design in #2425 — The good news is that I think we can keep all of the value with none of the optimizer surgery:
So my proposal: keep |
|
Took a look. Let me know if I missed the point. My understanding is we want to optimize other data variables besides media spend (channel data), and you propose we do this via special MuEffects. First question... what about optimizing control variables and other inputs that show elsewhere in the graph? First step seems to be just a way to decide which data-containers / deterministic variables are picked as inputs of the optimization. Then once we add more optimization inputs we need to decide how they affect the cost. This PR suggests converting these inputs into "budget" so they are on a common denomination and handled by the default cost. But as mentioned by the Bot, in your example you ended up double counting it, because it already affected the predicted revenue / response variable. (Aside re: response variable. This PR does response_distribution + optimized mu effects. Fine for default model, wrong for log MMM or non-default response_distribution? IMO the optimizer shouldn't try to craft a custom objective like that, it should be a well defined quantity in the original model). The custom response variable is where the double count materializes, but I don't think it's the issue. The discount should have zero cost (or if you want whatever running the discount campaign costs) if you are modeling revenue (as opposed to #sales or some non monetary quantity), and the optimized variable should be a [0, 1] discount lever. More obvious free lever: email frequency, bounded by fatigue not money. Would be nice to optimize but it would probably be in a MuEffect that has an inflection point, and needs no artificial link to budget? Idea: decouple the lever from its cost. Levers enter the optimizer in native units with their own bounds. A "cost" is then just a symbolic expression of the levers (possibly referencing model variables) that you place in whatever constraint the accounting says: the default budget constraint, another one, or none at all. The default optimizer is the special case lever = media spend, cost = identity, placed in the default budget sum constraint. The discount declares no cost term anywhere — it's already in the response if you model revenue. An extra promo day puts its cash cost in the budget constraint. A #sales model that wants to cap giveaways would constrain a custom response variable. Other notes:
|
Summary
Adds two new public classes and a worked example notebook for optimising promotional discount depth jointly with paid media budgets.
OptimizableMuEffect(abstract)A new protocol layer on top of
MuEffectfor spend-driven effects that participate inBudgetOptimizer's joint allocation. Subclasses implement:replace_for_optimization(budget_slice, num_periods, budget_distribution)— returnpm.do()replacements converting monetary spend to model variables.set_budget_for_sampling(budget_per_item, model)— apply the optimised budget before posterior predictive sampling.budget_channel_names— ordered list of channel/event names matching thebudget_dimcoordinate.The optimizer discovers
OptimizableMuEffectinstances at construction time, extends the flat budget variable with their channels, and routes per-channel allocations back viaset_budget_for_sampling.DiscountedEventEffectConcrete
OptimizableMuEffectfor promotional events (Black Friday, summer sale, …) where the lever is discount depth. Uses the full revenue-retention formula:ln(1+d) * (1-d): hump-shaped volume × price-retention term.-d_k * r_k: margin cost — fractiond_kof the baseline per-period revenuer_kis forgone.r_k = event_revenue / n_periods, normalised bytarget_scale, auto-computed from the observedy. Registered as apmd.Datanode.lift(0) = 0,lift(1) = -r_k(100% discount → zero net revenue per period).Key design decisions:
event_revenueandrevenue_per_periodare always internal — no user column required.discount_min/discount_maxset budget bounds at constructor time.Modelprotocol is not polluted with MMM-specific attributes (scalersaccessed viagetattr).BudgetOptimizer/BudgetOptimizerWrapperintegrationbudget_optimizer.py: extend_budgets_flatwith effect channels; callreplace_for_optimizationfor each effect; route allocations back insample_response_distribution.mmm.py: 3-phasesample_response_distribution(clone → set media data → set effect data) to prevent NaN expansion inchannel_contribution.constraints.py: fixbudgets_flatslice to use full combined length.utils.py: relaxcreate_zero_datasetvalidation for effect-only optimisation windows.__init__.py: exportMuEffect,OptimizableMuEffect,DiscountedEventEffect.Tests
51 tests in
tests/mmm/test_additive_effect.pycovering:OptimizableMuEffectABC contractDiscountedEventEffectmodel variables, forward formula, per-event revenue computationreplace_for_optimization/set_budget_for_samplingcorrectnessBudgetOptimizerflat-variable sizing and end-to-endallocate_budgetsample_response_distributionwith mixed media + event channels (no NaN)Notebook
docs/source/notebooks/mmm/mmm_discounted_events.ipynb— full worked example: synthetic DGP → fit → posterior lift curves → joint budget optimisation → discount prescription table → response distribution comparison.Checklist
ruff check,ruff format,mypypass