Many optimisation workflows stop at the allocation itself. The reason behind the split is already sitting in the math, for free, and the only thing standing between you and it is one modelling decision that looks completely harmless. This is about not throwing it away.
Start with why the easy version fails. The obvious approach is to score each channel by return per pound and fund the winners. It makes a clean table and a plan nobody can use. Ranking assumes each choice stands alone, but budget allocation is one connected decision. Every pound you give one channel is a pound the others lose. Add a single rule, “keep at least £3,000 here,” and a sorted list has nothing to say.
So it is a constrained optimisation problem, and Linear Programming is the standard tool for those. The reason to reach for it, though, is not the allocation. It is a byproduct almost everyone ignores. When a continuous LP finishes, it can also provide the dual value of its constraints: how much the objective would move if you loosened a rule by one unit. That byproduct is the explanation. And it is only valid while the model stays a continuous LP.
Which is exactly where the harmless-looking decision comes in. A plain LP will dump the whole budget on the single best channel and starve the rest. The natural fix is an on/off switch, a binary “run this channel or not” variable. It solves the dumping. It also turns the model into a mixed-integer program, where the LP shadow prices that made the explanation possible are no longer directly available. You get the split and lose the reason.
The way out is to make diversification without a switch. Slice each channel’s budget into bands, and make each band pay less than the one before it.
```
from dataclasses import dataclass
@dataclass
class Platform:
name: str
productivity: float # historical KPI per unit of spend
min_spend: float = 0.0 # optional policy floor
Successive slices of the total budget, each earning a lower marginal yield.
BRACKETS = [(0.25, 1.00), (0.35, 0.65), (0.40, 0.35)] # (budget fraction, marginal yield)
```
The first slice of a strong channel is worth a lot. Its third slice is worth less than the first slice of a weaker rival, so the optimiser spreads the money on its own. No binary variables, so the familiar LP shadow prices remain available. Diminishing returns become geometry instead of logic.
One caveat on productivity: it is just historical KPI per pound, an observed ratio, not a causal estimate. That keeps the data demand low enough for a small team, at the price of inheriting whatever bias is already in the numbers. A fair trade when it is a deliberate one.
The model stays short. PuLP keeps it close to the plain description of the problem.
import pulp
def allocate(budget, platforms):
model = pulp.LpProblem("budget_allocation", pulp.LpMaximize)
slices = {}
for p in platforms:
slices[p.name] = [
(pulp.LpVariable(f"x_{p.name}_b{i}", lowBound=0, upBound=frac * budget), y)
for i, (frac, y) in enumerate(BRACKETS)
]
model += pulp.lpSum(
p.productivity * y * var for p in platforms for (var, y) in slices[p.name]
)
model += (pulp.lpSum(var for p in platforms for (var, _) in slices[p.name]) <= budget,
"total_budget")
for p in platforms:
if p.min_spend > 0:
model += (pulp.lpSum(var for (var, _) in slices[p.name]) >= p.min_spend,
f"min_{p.name}")
model.solve(pulp.PULP_CBC_CMD(msg=False))
return model, slices
One detail earns its keep at the end: every constraint has a name. An unnamed constraint still binds, it just cannot tell you afterwards that it did. Naming them is what lets the split talk back.
And talking back is the whole point. Each named constraint reports two things: whether it is tight, and its shadow price.
def interpret(model):
binding, shadow = [], {}
for name, con in model.constraints.items():
shadow[name] = round(con.pi, 4) if con.pi is not None else None
if con.slack is not None and abs(con.slack) < 1e-6:
binding.append(name)
return binding, shadow
Read the signs and the plan starts speaking plainly. A binding budget cap is positive: another pound would earn this much more. A binding minimum-spend floor is negative: forcing money onto a weaker channel cost this much. A shadow price of zero is a rule you wrote down that never touched this decision.
Watch it flip. Give it £20,000, two platforms, one lead-gen goal, and a £3,000 floor on each. LinkedIn turned £9,500 into 300 leads, Facebook £8,000 into 150.
platforms = [
Platform("linkedin", productivity=300/9500, min_spend=3000),
Platform("facebook", productivity=150/8000, min_spend=3000),
]
model, slices = allocate(20000, platforms)
They are close, so the split settles near 60/40 toward LinkedIn. Only the budget binds, and both floors sit idle. Productivity drove it; no rule interfered.
Now make LinkedIn clearly stronger, 380 leads on £9,500 against 30 on £4,200, and drop Facebook’s floor to £500. LinkedIn receives £19,500 while Facebook stays at exactly its £500 minimum. That floor now binds, with a negative shadow price. This is the number from the opening: the model, telling you your own safety rule has a cost, and printing it next to the plan instead of hiding it inside.
From Optimiser to Decision Engine
A thirty-line function is not yet a tool. The gap is filled by plainer layers: a check that catches contradictory inputs before the solver runs, since most “infeasible” results are really a floor set above a cap; a pass that re-solves under conservative, base, and optimistic assumptions to see if a recommendation holds up; a forecast that turns the split back into expected outcomes with an uncertainty band sized by how much history backs it; and a step that names the binding constraints and sets a safer, more diversified alternative beside the optimum with its efficiency cost stated. Keeping those apart is what keeps the answer honest, and keeps the explanation trustworthy.
I built these ideas into an open-source project called CLARO (Constrained Linear Allocation and Resource Optimiser). It wraps the optimisation with scenario analysis, forecasting, and constraint interpretation while keeping the LP core reusable for other allocation problems. Everything in this article runs on plain PuLP; CLARO is simply one complete implementation.
pip install claro-engineThe package is available on PyPI, and the source code, tests, and examples are on GitHub.
The on/off switch would have made the model quieter, and it was the one thing worth refusing. Refusing it is what let the plan keep explaining itself: which of your rules shaped it, and what each one cost. That holds anywhere a fixed pool gets split under rules that bite, whether the pool is a budget, a team, compute, or shelf space.
Optimising a budget has become easy. Explaining the optimisation is still surprisingly rare. The explanation was already inside the optimisation model. The only trick was not throwing it away.