The Budget Split That Explains Itself

Left alone, the optimizer handed the entire £20,000 budget to a single channel. Add one rule that forced a little onto a second channel, and it complied—but it also returned a quiet negative number beside that rule. That number meant the floor you set was working against you, and it said exactly what that cost.

Many optimization 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 modeling decision that looks completely harmless. This is about not throwing it away.

Why the Easy Version Fails

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 optimization problem, and Linear Programming (LP) 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.

The Harmless-Looking Decision That Breaks It

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: Diversify Without a Switch

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 optimizer spreads the money on its own. No binary variables, so the familiar LP shadow prices remain available. Diminishing returns become geometry instead of logic.

Budget allocation comparison: without diminishing returns, the LP concentrates the entire budget on the highest-performing channel; adding decreasing marginal yields diversifies the allocation.
Without diminishing returns, the LP concentrates the entire budget on the highest-performing channel. Adding decreasing marginal yields lets the same LP model diversify without introducing binary decisions.

A Note on Productivity Data

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

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.minspend,

f"min_{p.name}")


model.solve(pulp.PULPCBCCMD(msg=False))

return model, slices

One final thought: the next time you run a budget optimization, resist the urge to reach for binary variables. The explanation you get from continuous LP shadow prices is often more valuable than the allocation itself. In 2026, with budgets tighter and scrutiny higher, that kind of transparency isn’t just nice to have—it’s a competitive edge.

via Towards Data Science

Related