Optimise a budget

Use the Python optimiser for an explicit objective, horizon and set of feasible allocations. The runner currently has no validated optimisation YAML block: its 70_optimisation directory is reserved and that stage is skipped. Use manual scenario recipes when you need a retained configuration-driven comparison without a solver.

Monetary-channel example

This continues the fitted ordinary MMM from the Python quickstart and assumes its tv and social inputs are monetary spend. The two-period window starts immediately after training. total_budget and the returned allocation are per-period amounts; the window total is twice that amount in this example.

import pandas as pd

start = X["date"].max() + pd.Timedelta(weeks=1)
end = start + pd.Timedelta(weeks=1)
budget_per_period = 80.0
optimizer = model.budget_optimizer(start_date=start, end_date=end)
result = optimizer.allocate_budget(
    total_budget=budget_per_period,
    budget_bounds={"tv": (10.0, 70.0), "social": (10.0, 70.0)},
    callback=True,
)
print(result.budgets)
print(result.scipy_result.success, result.scipy_result.message)
samples = model.sample_response_distribution(
    allocation_strategy=result.budgets, start_date=start, end_date=end,
    noise_level=0.0, include_last_observations=True, include_carryover=True,
    include_observation=False,
)
allocation_table = optimizer.summary.allocation_roas(samples=samples)

BudgetOptimizationResult exposes budgets, scipy_result, optimized_vars, spend_var_allocations and optional callback diagnostics. Two-value unpacking also yields budgets and the SciPy result. noise_level=0.0 prevents perturbing the evaluation allocation. Keep factual carry-in and trailing carryover consistent between optimisation and evaluation, and score the same response variable.

For non-monetary channels, supply the optimiser’s cost-per-unit conversion and verify the evaluation inputs on the same units and dates. Do not assume this monetary-channel snippet handles impressions merely by renaming the columns.

Constraints and additional variables

Default bounds are zero to total budget for each optimised cell. A budgets_to_optimize mask excludes cells by fixing their budgets to zero; it does not preserve historical spend automatically. Labelled bounds require (*budget_dims, "bound") coordinates. Dict bounds are for the one-dimensional channel case.

The low-level optimiser can include monetary spend_vars and non-monetary optimizable_vars levers. Both can appear in optimized_vars, but only monetary variables belong in a spend total. Include spend_var_allocations when checking budget conservation. Custom Constraint objects can replace or extend the budget rule; verify which constraints are active. Declare meaningful bounds for levers in their own units.

Objective and decision evidence

The default utility averages the configured response over posterior draws. Under a log link, the default media response is a conditional-median contrast, not expected revenue. Media-dependent custom effects can carry response outside that default variable; an explicit objective such as total_response_original_scale may be required. Inspect its graph definition and units before using it.

The default SLSQP solver is local. Compare multiple feasible starts, solver status, objective values and independently calculated constraint residuals. Returning a failed result with return_if_fail=True does not make it feasible. Agreement across starts is useful numerical evidence, not proof of global optimality.

Before acting, review sampling precision for the decision contrast, held-out performance, causal assumptions, spend support and operational limits. Evaluate posterior differences against the current feasible plan, including downside risk, rather than optimising an attractive point estimate alone. Retain the model identity, objective, inputs, constraints and decision rationale. FE and CRE reject fixed-budget optimisation under their current contracts.

Independently check allocations and compare starts

This continues the monetary-channel example above, using only channel budgets, the default total-budget constraint and its stated bounds. It checks feasibility without relying on the solver’s success flag, then compares three feasible starts using the same optimiser objective. Additional spend variables or custom constraints require their own checks (src/ammm/mmm/budget_optimizer.py:704).

import numpy as np
import pandas as pd
import xarray as xr

bounds = {"tv": (10.0, 70.0), "social": (10.0, 70.0)}
channels = list(model.channel_columns)
records = []
for tv_budget in (20.0, 40.0, 60.0):
    initial = xr.DataArray(
        [tv_budget if c == "tv" else budget_per_period - tv_budget for c in channels],
        dims="channel", coords={"channel": channels},
    )
    candidate = optimizer.allocate_budget(
        total_budget=budget_per_period, budget_bounds=bounds, x0=initial,
    )
    amounts = candidate.budgets.sel(channel=channels)
    assert bool(np.isfinite(amounts).all())
    assert np.isclose(float(amounts.sum()), budget_per_period, rtol=0, atol=1e-6)
    for channel, (lower, upper) in bounds.items():
        amount = float(amounts.sel(channel=channel))
        assert lower - 1e-6 <= amount <= upper + 1e-6
    records.append({
        "initial_tv": tv_budget,
        "solver_success": bool(candidate.scipy_result.success),
        "minimised_objective": float(candidate.scipy_result.fun),
        "tv": float(amounts.sel(channel="tv")),
        "social": float(amounts.sel(channel="social")),
    })
comparison = pd.DataFrame(records)
print(comparison)

The numerical objective is the minimised solver objective, so preserve its sign and utility definition rather than relabelling it as revenue. Similar feasible solutions provide evidence about local numerical stability only; use paired posterior scenario contrasts against the current plan for decision uncertainty. Retain the complete constraint set, tolerances and any failed starts, and inspect extrapolation before using an apparently better allocation (src/ammm/mmm/budget_optimizer.py:431).

Implementation reference at 7cb7f20: src/ammm/mmm/budget_optimizer.py:704, src/ammm/mmm/mmm.py:2168, src/ammm/mmm/_budget_optimizer_results.py:35.