Skip to content

Module 5 — LP Optimiser

Hoda Rezvanjoo edited this page May 28, 2026 · 1 revision

File: modules/module5.py (≈1,500 lines — the engine's heart) Result types: Module5LPResult, Module5ScenarioBundle, Module5MonteCarloResult

What it does

Builds a linear program over (platform, goal) cells, solves it under three scenarios, returns allocations + diagnostics + (optionally) Monte Carlo robustness analysis.

Decision variables

One variable per active (p, g) cell:

x_{p,g} = budget allocated to platform p for goal g (£)

…plus bracket variables for the piecewise-linear diminishing-returns structure (see below).

Objective

$$ \max_{x} ;; \sum_{p}\sum_{g}; w_g \cdot \tilde r_{p,g} \cdot x_{p,g} $$

  • w_g — system goal weight: priority-frequency, or value-derived (value × productivity) when goal values are set.
  • \tilde r_{p,g} — per-goal-normalised productivity after shrinkage.

Constraints

Constraint Form Why
Total budget cap Σ x_{p,g} ≤ B × (1 − tl_pct) Test-and-learn carve-out
Per-platform floor Σ_g x_{p,g} ≥ floor_p User-declared minimum spend
Per-goal floor Σ_p x_{p,g} ≥ floor_g User-declared per-objective minimum
Goal validity x_{p,g} = 0 when g ∉ goals_by_platform[p] A platform can't be funded for a goal it doesn't serve
Diminishing-returns brackets 3 slices per cell at 25 / 35 / 40 % of budget at yields 1.0 / 0.65 / 0.35 No single cell can absorb the whole plan

Every constraint is created as a named LpConstraint, so Module5LPResult.binding_constraints returns labels a human can read with their shadow prices attached. "Why is Facebook capped?" has a literal answer.

Productivity normalisation

r_{p,g} = (1 / cpu_{p,g,var})  ·  (1 / Σ_p 1/cpu_{p,g,var})

After normalisation, productivities within a goal sum to 1. Cross- goal comparison becomes scale-free — the goal weights are the control knob, not whether a goal happens to be measured in clicks (large numbers) or leads (small numbers).

Shrinkage

$$ \tilde r_{p,g} = \alpha_p \hat r_{p,g} + (1-\alpha_p) \bar r_g, \qquad \alpha_p = \frac{\text{days}_p}{\text{days}_p + \kappa}, ;; \kappa=30 $$

A 30-day history gets α = 0.5 (half its own data, half the cross-platform prior). A 7-day history α ≈ 0.19. A 365-day history α ≈ 0.92. This is the standard remedy for the small-sample-winner failure mode in allocation problems — without it, a fluky strong week on one platform out-competes a year of mediocre data on another.

Three scenarios

Same LP, three solves:

  • Conservative — multiplier 1/1.2 ≈ 0.83. Per-goal multipliers raise upper-funnel productivity.
  • Base — multiplier 1.0. No goal adjustments.
  • Optimistic — multiplier 1.15. Per-goal multipliers raise conversion productivity.

This labelled-triple format matches how strategists communicate; the opt-in Monte Carlo (below) is for users who want a full distribution.

Monte Carlo robustness

run_module5_montecarlo re-solves the base LP N times (default 200, max 1000) with productivities perturbed by their observed per-cell coefficient of variation (mean-preserving lognormal shocks). Returns:

  • Per-platform allocation mean / std / p5 / p50 / p95
  • unstable_platforms — those with allocation CV above the threshold (DEFAULT_INSTABILITY_CV = 0.20)
  • stability_score1 − mean CV across materially-funded platforms (mean > £1), clamped to [0, 1]. A plan-level scalar a marketer can quote.
  • cell_sigma — the per-cell noise scale used for sampling, so the user can see which assumptions had the most noise

Solves are deterministic given a seed — identical inputs and seed produce identical distributions. Reproducible.

Failure modes

  • Module5ValidationError — infeasible LP (impossible floor combination), all-zero productivities, scenario multiplier ≤ 0.
  • FlowStateError — Module 4 not finalised.

Clone this wiki locally