This section covers Abacus budget optimisation workflows for fitted
PanelMMM models. It explains the low-level optimisation wrapper, how to
inspect optimisation outputs.
For the higher-level planner service and Dash UI, see
Scenario Planning.
Pages
Budget Optimisation - How to run
PanelBudgetOptimizerWrapper, set bounds and masks, and define spend over a
future window.
Interpreting Optimisation - How to read the
allocation output, inspect simulated response samples, and use the pipeline
optimisation artefacts.
Scenario Planning - How to compare current, manual,
and fixed-budget optimised scenarios with the planner service and optional
Dash UI.
Subsections of Optimisation
Budget Optimisation
Use PanelBudgetOptimizerWrapper when you want to optimise spend for a fitted
PanelMMM over a future date window.
The wrapper rejects the named FE, CRE and release-gated RE presets with
EstimatorOperationError. Use the
estimator support matrix
before preparing an optimisation. FE/CRE historical and manual scenarios are
separate supported operations; they do not enable fixed-budget optimisation.
The wrapper builds a synthetic future dataset for the requested window, swaps
the model’s channel_data for an optimisation variable, and then calls the
generic BudgetOptimizer. If you want to compare several plans in total
horizon spend units, see Scenario Planning.
What the optimiser maximises
For PanelBudgetOptimizerWrapper, optimize_budget() defaults to:
The optimiser therefore maximises the average posterior response of the chosen
response variable, subject to your budget bounds and constraints.
Budget units
The low-level wrapper uses per-period spend units.
budget is the total spend across all optimised cells for one model period.
The returned allocation has no date dimension, so Abacus repeats that
allocation across the optimisation window.
If the window has num_periods=8 and you pass budget=100_000, the
simulated spend over the full horizon is 800_000 before any carryover
effects are applied.
This is different from Scenario Planning, which
treats total_budget and manual allocations as total horizon spend and
converts them to per-period units internally.
The structured pipeline now has two YAML paths:
preferred: optimization.budget, which uses total horizon spend and is
converted internally before calling the wrapper
legacy: optimization.total_budget, which keeps the old per-period spend
contract for backward compatibility
Required inputs
Input
What Abacus expects
Notes
model
A fitted PanelMMM with idata.posterior
The optimiser needs posterior draws and model graph variables.
start_date, end_date
A future window at the model’s observed date frequency
Abacus infers num_periods from the training data frequency.
allocation is an xarray.DataArray over the non-date budget dimensions.
For a model with dims=("geo",), the result dims are typically
("geo", "channel").
Bounds and masks
budget_bounds
Use budget_bounds to cap spend for each optimised cell.
If the budget has only one non-date dimension, you can pass a dict such as
{"tv": (0.0, 50_000.0), "search": (0.0, 30_000.0)}.
For panel budgets, pass an xarray.DataArray with dims
(*budget_dims, "bound"), where "bound" contains "lower" and "upper".
If you omit budget_bounds, Abacus warns and uses (0, total_budget) for
every optimised cell.
Abacus reindexes DataArray bounds to the model’s internal coordinate order,
so the input coordinate order does not need to match exactly.
budgets_to_optimize
Use budgets_to_optimize to choose which cells can move.
The mask must have boolean dtype and exactly the budget dimensions.
Each budget dimension must have explicit, unique, non-missing coordinate labels
matching the model’s labels exactly. Abacus aligns label and dimension order
before selecting cells. Missing or extra labels raise ValueError.
Unoptimised cells are fixed at zero in the returned allocation.
If you omit the mask, Abacus optimises every cell where the fitted model has
non-zero historical channel_contribution information.
If your mask includes True for a cell where the model has no information,
Abacus raises ValueError.
Time distribution across the window
Use budget_distribution_over_period to flight each allocation cell over time
instead of repeating the same spend every period.
The object must be an xarray.DataArray with:
exactly the dimensions ("date", *budget_dims), in any order
explicit, unique, non-missing labels for every budget dimension, matching the
model’s coordinate membership exactly
one date weight per optimisation period, retained in the supplied date order
finite, real, non-negative fractions that sum to 1 across date for every
budget cell, including cells disabled by the mask
Abacus aligns channel and other budget labels before converting the profiles
to arrays. Reordered labels are accepted; missing, extra, duplicate or absent
budget labels raise ValueError. Valid zero fractions are accepted. Invalid
fractions are rejected, not clipped or normalised. Sum validation uses relative
tolerance 1e-5 and absolute tolerance 1e-8.
Previously saved allocations are not repaired by this validation. Recompute
allocations produced with misordered profiles or invalid fractions.
The low-level optimiser treats the date axis as an ordered sequence and accepts
implicit positional dates. It does not sort or match calendar dates. The
wrapper’s response-simulation date checks are described below.
Use the same budget_distribution_over_period again when you call
sample_response_distribution(),
otherwise you will optimise one spend path and simulate another.
For response simulation through the wrapper, the date coordinates can be:
integer positions 0 .. num_periods - 1, or
exact dates that match the optimisation window
Constraints and solver controls
default_constraints=True adds the default equality constraint:
sum(allocation) == budget
This is enabled by default and emits a warning so you can see that the default
constraint set is active.
You can also pass:
extra SciPy minimise keyword arguments directly to optimize_budget(...) to
tweak the underlying solver call
callback=True to get a third return value with per-iteration objective,
gradient, and constraint diagnostics
YAML note for the pipeline runner
If you run optimisation through the structured pipeline, configure the
optimization block in YAML:
In this preferred pipeline path, optimization.budget is interpreted as total
horizon spend. Abacus resolves the configured budget, divides by
num_periods, scales any derived bounds to per-period units, and then calls
optimize_budget(...).
then optimization.total_budget continues to mean per-period spend.
Common pitfalls
Passing a total horizon budget to optimize_budget(...). Divide by
wrapper.num_periods first, or use the pipeline optimization.budget block
or Scenario Planning.
Mixing up the preferred horizon-based optimization.budget block and the
legacy per-period optimization.total_budget field.
Passing dict bounds for a panel budget. Dict bounds only work when the budget
dims are just ("channel",).
Omitting a budget dimension from budget_distribution_over_period. The
distribution must include every budget dim, not just the one you want to vary.
Forgetting that response_variable must exist in the fitted optimisation
graph.
Using one budget distribution for optimisation and a different one for
response simulation.
allocation is an xarray.DataArray over the non-date budget dimensions.
Model shape
Typical allocation dims
Meaning
No extra panel dims
("channel",)
One optimised value per channel
dims=("geo",)
("geo", "channel")
One value per (geo, channel) cell
dims=("geo", "brand")
("geo", "brand", "channel")
One value per (geo, brand, channel) cell
The values are in the wrapper’s per-period units. Unoptimised cells are
present and set to zero.
result
result is SciPy’s OptimizeResult. The fields you will usually inspect are:
Field
Meaning
success
Whether the solver converged
status
SciPy status code
message
Human-readable solver message
fun
Final objective value
nit
Number of iterations
x
The optimised flat parameter vector
If success is False, Abacus raises MinimizeException unless you opt in to
return_if_fail=True on the underlying BudgetOptimizer.
callback_info
When callback=True, Abacus records one entry per solver iteration. Each entry
includes:
x
fun
jac
constraint_info when constraints are active
Use this when you need to diagnose solver behaviour rather than just consume the
final allocation.
Simulate the optimised plan
The optimiser itself returns only the allocation. To estimate spend paths and
contributions over the requested window, call sample_response_distribution().
Set noise_level=0.0 when you want the spend path to match the requested
allocation exactly.
What response_samples contains
The wrapper builds a synthetic future dataset, samples posterior predictive
draws, and then merges the requested allocation and simulated spend path back
into the result.
response_samples therefore contains:
Variable
Source
Meaning
allocation
Added by the wrapper
Requested allocation without a date dimension
One variable per channel
Added by the wrapper
Simulated spend path over the future dates
mmm.output_var
Posterior predictive sample
Model output variable
channel_contribution
Posterior predictive sample
Channel contribution on model scale
total_media_contribution_original_scale
Posterior predictive sample
Total media contribution on the original target scale
If you pass additional_var_names, Abacus also includes those variables when
they exist in the model graph.
Carryover and evaluation window
include_carryover=True changes how Abacus builds the synthetic future window.
Abacus extends the generated dates by adstock.l_max periods.
It then zeroes the tail spend rows after the requested window.
The extra dates let posterior predictive sampling include lagged effects from
the planned spend.
This is why the simulated dataset can cover a longer evaluated window than the
requested start_date to end_date range, while still preserving the same
total spend.
Plot the result
The plotting helpers under mmm.plot are designed to work directly with the
response dataset returned by the wrapper.
split_by="geo" or another dimension to create separate subplots
original_scale=True to prefer original-scale contribution variables when
they are available
Example optimisation output:
Read the Stage 70 pipeline artefacts
If you run optimisation through python -m abacus.pipeline.runner, Stage 70
writes both the low-level optimiser output and several interpretation files.
The simulated response dataset for that allocation
optimize_result.json
Solver status, message, objective value, and iteration count
budget_summary.csv
Current versus optimised totals
budget_response_points.csv
Per-channel current versus optimised spend, contribution, and efficiency summaries
budget_impact.csv
Delta between current and optimised channel summaries
budget_bounds_audit.csv
Current spend, scaled reference spend, bounds, optimised spend, and bound checks
budget_roi_cpa.csv
Channel efficiency summaries using the model’s efficiency metric
budget_response_curves.csv
Saturation-only response curve summaries
budget_mroi.csv
Marginal efficiency estimates at the current and optimised spend points
The stage also writes plots for allocation, contribution over time, response
curves, impact, bounds audit, and ROI or CPA summaries.
These Stage 70 spend figures are reported in total horizon spend units so they
can be compared directly to current historical spend over the same reference
window. Abacus still converts to the low-level wrapper’s per-period budget
contract internally.
Practical checks
Before you use an optimised plan, check:
result.success and result.message
whether the allocation matches your intended budget units
whether budget_bounds_audit.csv or your own checks show any bound issues
how much of the gain comes from reallocation versus carryover assumptions
whether the point lies on a sensible part of the response curve, not just on
the edge of a bound
For multi-plan comparison in total horizon units, use
Scenario Planning.