Budget Optimisation
Purpose
DSAMbayes provides a decision-layer budget optimisation engine that operates on fitted model posteriors. Given a channel scenario with spend bounds, response-transform specifications, and an objective function, the engine searches for the allocation that maximises the chosen objective while respecting channel-level constraints. This page defines the inputs, objectives, risk scoring, response-scale handling, and output structure.
Overview
Budget optimisation is separate from parameter estimation. It takes a fitted model and a scenario specification, then:
- Extracts posterior coefficient draws for the scenario’s channel terms.
- Generates feasible candidate allocations within channel bounds that sum to the total budget.
- Evaluates each candidate across all posterior draws to obtain a distribution of KPI outcomes.
- Ranks candidates by the configured objective and risk scoring function.
- Returns the best allocation, channel-level summaries, response curves, and impact breakdowns.
Response-surface contract
The allocator’s response curves are scenario-authored: each channel’s
response specification supplies the identity, atan, log1p, or Hill curve
used to score allocations. optimise_budget() records this as
response_surface = "scenario_authored" in the returned object and its summary
artefact.
For models fitted with probabilistic adstock/Hill media transforms, these decision-layer curves are not the fitted Stan response. They do not reuse posterior adstock decay, posterior Hill half-saturation, historical pacing, or carry-over state. Treat them as an explicit scenario model, not as a model-sourced marginal-response estimate.
Entry point
The optimize_budget() alias is also available for American English convention.
Scenario specification
The scenario is a structured list with the following top-level keys:
channels
A list of channel definitions, each containing:
| Key | Required | Default | Description |
|---|---|---|---|
term |
Yes | n/a | Model formula term name for this channel |
name |
No | Same as term |
Human-readable channel label |
spend_col |
No | Same as name |
Data column used for reference spend lookup |
bounds.min |
No | 0 |
Minimum allowed spend for this channel |
bounds.max |
No | Inf |
Maximum allowed spend for this channel |
response |
No | {type: "identity"} |
Response transform specification |
currency_col |
No | null |
Data column for currency-unit conversion |
Channel names and terms must be unique across the scenario.
budget_total
Total budget to allocate across all channels. All feasible allocations sum to this value.
reference_spend
Optional named list of per-channel reference spend values. If not provided, reference spend is estimated from the mean of the spend_col in the model’s original data.
objective
Defines the optimisation target and risk scoring:
| Key | Values | Description |
|---|---|---|
target |
kpi_uplift, profit |
What to maximise |
value_per_kpi |
numeric (required for profit) |
Currency value of one KPI unit |
risk.type |
mean, mean_minus_sd, quantile |
Risk scoring function |
risk.lambda |
numeric ≥ 0 (for mean_minus_sd) |
Penalty weight on posterior standard deviation |
risk.quantile |
(0, 1) (for quantile) |
Quantile level for pessimistic scoring |
Response transforms
Each channel can specify a response transform that maps raw spend to the transformed value used in the linear predictor. Supported types:
| Type | Formula | Parameters |
|---|---|---|
identity |
spend |
None |
atan |
atan(spend / scale) |
scale (positive scalar) |
log1p |
log(1 + spend / scale) |
scale (positive scalar) |
hill |
spend^n / (spend^n + k^n) |
k (half-saturation), n (shape) |
The response transform is applied within response_transform_value() and determines the shape of the channel’s response curve.
Objective functions
kpi_uplift
Maximises the expected change in KPI relative to the reference allocation. The metric for each candidate is:
$$\Delta\text{KPI}_d = f(\text{candidate}) - f(\text{reference})$$evaluated across posterior draws $d$.
profit
Maximises expected profit, defined as:
$$\text{profit}_d = \text{value\_per\_kpi} \times \Delta\text{KPI}_d - \Delta\text{spend}$$where $\Delta\text{spend} = \text{candidate total} - \text{reference total}$.
Risk-aware scoring
The risk scoring function determines how the distribution of objective draws is summarised into a single score for ranking candidates:
| Risk type | Score formula | Use case |
|---|---|---|
mean |
$\bar{m}$ | Risk-neutral; maximises expected value |
mean_minus_sd |
$\bar{m} - \lambda \cdot \sigma$ | Penalises uncertainty; higher $\lambda$ is more conservative |
quantile |
$Q_\alpha(m)$ | Optimises the $\alpha$-quantile; directly targets worst-case outcomes |
Coefficient extraction
BLM and pooled models
Coefficient draws are extracted via get_posterior() and indexed by the scenario’s channel terms.
Hierarchical models
For hierarchical MCMC models, the population-level (fixed-effect) beta draws are extracted directly from the Stan posterior. If the model was fitted with scale=TRUE, draws are back-transformed to the original scale before optimisation. This ensures that optimisation operates on the population effect rather than group-specific random-effect totals.
Draw thinning
If max_draws is specified, a random subsample of posterior draws is used for computational efficiency. The subsampling uses the configured seed for reproducibility.
Work-size controls and practical limits
n_candidates must be a finite integer from 10 through
.Machine$integer.max. scenario$posterior$draws must be a finite integer from
1 through .Machine$integer.max; it defaults to 500. Integer-valued doubles
such as 2000 are accepted and normalised. Fractional, missing, non-finite and
overflowing values are rejected before candidate or draw allocation begins.
Runtime grows approximately with candidate allocations multiplied by retained posterior draws. An efficient frontier repeats that work for every feasible budget multiplier. The optimiser keeps scalar search results for each candidate and retains full draw vectors only for the winning candidate, so candidate count no longer multiplies retained draw storage.
The defaults of 2,000 candidates and 500 posterior draws are the routine starting point. Benchmark the deployment host before exceeding any of the following review thresholds:
- 10,000 candidates;
- 2,000 retained posterior draws; or
- seven efficient-frontier levels.
These are measurement thresholds, not hard caps. Channel count, response transforms and host hardware also affect runtime. Run the bounded benchmark from the repository root:
The benchmark runs each case in a fresh process and writes results under the
ignored results/ tree. On Linux it reports whole-process peak resident set
size (RSS) from /usr/bin/time -v. Peak RSS includes package-load overhead, so
compare runs on the same host and software environment; do not interpret it as
an optimiser-only heap estimate.
Response-scale handling
Budget optimisation handles both identity and log response scales:
- Identity response: $\Delta\text{KPI}$ is the difference in linear-predictor draws between candidate and reference allocations.
- Log response: $\Delta\text{KPI}$ is computed via
kpi_delta_from_link_levels(), which correctly accounts for the exponential back-transformation. Ifkpi_baselineis available, the delta is expressed in absolute KPI units; otherwise, it is expressed as a relative change.
The delta_kpi_from_link() and kpi_delta_from_link_levels() functions ensure Jensen-safe conversions by operating draw-wise.
Feasible allocation generation
sample_feasible_allocation() generates random allocations that:
- Respect per-channel lower bounds.
- Respect per-channel upper bounds.
- Sum exactly to
budget_total.
Allocation is performed by distributing remaining budget (after lower bounds) using exponential random weights, iteratively filling channels until the budget is exhausted. project_to_budget() ensures exact budget equality via proportional adjustment.
Output structure
optimise_budget() returns a budget_optimisation object containing:
| Field | Content |
|---|---|
best_spend |
Named numeric vector of optimal per-channel spend |
best_score |
Objective score of the best allocation |
channel_summary |
Tibble with per-channel reference vs optimised spend, response, ROI, CPA, and deltas |
curves |
List of per-channel response curve tibbles (spend grid × mean/lower/p50/upper) |
points |
Tibble of reference and optimised points per channel with confidence intervals |
impact |
Waterfall-style tibble of per-channel KPI contribution and interaction residual |
objective_cfg |
Echo of the objective configuration |
scenario |
Echo of the input scenario |
response_surface |
"scenario_authored"; scenario response functions, not a fitted transformed-media response |
model_metadata |
Model class, response scale, and scale flag |
Runner integration
When allocation.enabled: true in YAML, the runner calls optimise_budget() after fitting and writes artefacts under 80_optimisation/:
| Artefact | Content |
|---|---|
allocation_summary.csv |
Channel summary table |
response_curves.csv |
Response curve data for all channels |
allocation_impact.csv |
Waterfall impact breakdown |
| Plot PNGs | Response curves, ROI/CPA panel, allocation waterfall, and other visual outputs |
Constraints and guardrails
- Budget feasibility: if channel lower bounds sum to more than
budget_total, the engine aborts. - Upper bound capacity: if channel upper bounds cannot accommodate the full budget, the engine aborts.
- Missing terms: if a scenario term is not found in the posterior coefficients, the engine aborts with a descriptive error.
- Offset + scale combination: for
bayes_lm_updatermodels,optimise_budget()aborts ifscale=TRUEand an offset is present.
Cross-references
- Model Classes, fit support and posterior extraction per class
- Response Scale Semantics, KPI-scale conversion for log models
- Priors and Boundaries, boundary interaction with optimisation constraints
- Config Schema,
allocation.*YAML keys - Optimisation Plots, visual outputs from budget optimisation