Interpreting Optimisation

After you run budget optimisation, you usually work with three outputs:

  • the allocation DataArray
  • the SciPy OptimizeResult
  • a simulated response dataset from sample_response_distribution()

This page explains how to read each one.

Read the optimiser output

PanelBudgetOptimizerWrapper.optimize_budget(...) returns:

allocation, result = wrapper.optimize_budget(...)

If you set callback=True, it returns a third value:

allocation, result, callback_info = wrapper.optimize_budget(..., callback=True)

allocation

allocation is an xarray.DataArray over the non-date budget dimensions.

Model shapeTypical allocation dimsMeaning
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:

FieldMeaning
successWhether the solver converged
statusSciPy status code
messageHuman-readable solver message
funFinal objective value
nitNumber of iterations
xThe optimised flat parameter vector

If success is False, AMMM3 raises MinimizeException unless you opt in to return_if_fail=True on the underlying BudgetOptimizer.

callback_info

When callback=True, AMMM3 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().

response_samples = wrapper.sample_response_distribution(
    allocation_strategy=allocation,
    noise_level=0.0,
    include_last_observations=False,
    include_carryover=True,
    budget_distribution_over_period=budget_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:

VariableSourceMeaning
allocationAdded by the wrapperRequested allocation without a date dimension
One variable per channelAdded by the wrapperSimulated spend path over the future dates
mmm.output_varPosterior predictive sampleModel output variable
channel_contributionPosterior predictive sampleChannel contribution on model scale
total_media_contribution_original_scalePosterior predictive sampleTotal media contribution on the original target scale

If you pass additional_var_names, AMMM3 also includes those variables when they exist in the model graph.

Carryover and evaluation window

include_carryover=True changes how AMMM3 builds the synthetic future window.

  • AMMM3 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.

fig, ax = mmm.plot.budget_allocation(response_samples, original_scale=True)

fig, ax = mmm.plot.allocated_contribution_by_channel_over_time(
    response_samples,
    original_scale=True,
)

Useful options:

  • dims={...} to filter a panel slice
  • 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:

Budget allocation example

Allocated contribution by channel over time

Budget response curves example

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.

FileWhat it contains
optimized_allocation.nc / optimized_allocation.csvThe allocation returned by the optimiser
response_distribution.ncThe simulated response dataset for that allocation
optimize_result.jsonSolver status, message, objective value, and iteration count
budget_summary.csvCurrent versus optimised totals
budget_response_points.csvPer-channel current versus optimised spend, contribution, and efficiency summaries
budget_impact.csvDelta between current and optimised channel summaries
budget_bounds_audit.csvCurrent spend, scaled reference spend, bounds, optimised spend, and bound checks
budget_roi_cpa.csvChannel efficiency summaries using the model’s efficiency metric
budget_response_curves.csvSaturation-only response curve summaries
budget_mroi.csvMarginal 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. AMMM3 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.