Sitemap
William (Bill) Smith, Ph.D.

William (Bill) Smith, Ph.D.

Educator and Operations Research Analyst

Sunk Cost Avoidance

Risk, Value, and the Pareto Front

11 min readJul 21, 2026

--

Every organization will start a project, spend obscene amounts of money on it, and then eventually cancel the project with nothing to show for it. Future Combat Systems, Comanche helicopters, Zumwalt destroyers, airborne lasers, Expeditionary Fighting Vehicles, and many, many more defense projects serve as examples of programs that probably should not have been included in a POM. What if we can somehow minimize risk while maximizing value despite being in a high risk, high reward environment?

Press enter or click to view image in full size
Photo by Jp Valery on Unsplash

Our last article solved the buying problem with a comforting fiction: every program we funded would deliver exactly the value on its tag. Reality is less generous. Programs slip, prototypes fail, the ambitious ones fail most of all. The moment we admit that, a second goal walks into the room and refuses to sit quietly behind the first. We still want maximum value. Now we also want minimum risk. These two are not friends.

Han Solo had a policy on odds. A portfolio manager cannot afford one. The whole job here is to tell you the odds precisely, and then to show you the exact rate at which safety must be traded for capability. That trade, drawn as a curve, is the Pareto front, and it is the single most useful picture in all of decision-making under conflicting goals.

A Second Objective

Give each program a probability of failure p. A natural way to score the danger of a whole portfolio is the expected value lost, the sum over chosen programs of value times probability of failure. Our two objectives are then in open tension:

Press enter or click to view image in full size

The quantity V minus R has a clean reading of its own: the expected value actually delivered, the capability you should bank on once the dice stop rolling. Hold that thought; it turns out to be the hero of the story.

Why not just fold risk into value and solve one knapsack? Because the villains and heroes are frequently the same programs. In our synthetic POM the correlation between a program’s value and its probability of failure is 0.87. The boldest, highest-capability bets are almost exactly the ones most likely to blow up. You cannot cheaply buy safety and capability at once, because they tend to live in the same expensive, dangerous line items.

Press enter or click to view image in full size
Each dot is one program (size proportional to cost). Value and probability of failure march up and to the right together. Safe programs are cheap and modest; the exciting ones are gambles.

You Cannot Serve Two Masters: Pareto Dominance

With two objectives there is usually no single winner, so we need a fair way to compare portfolios. Portfolio A dominates portfolio B if A is at least as good on both counts and strictly better on at least one: as much value for less risk, or more value for no more risk. Domination is the only judgment we can make without smuggling in an opinion about how many value points a percent of safety is worth.

Press enter or click to view image in full size

A portfolio that nobody dominates is Pareto-optimal. The set of all such portfolios is the Pareto front. Every point on it is a legitimate, defensible choice; no point off it ever is, because something on the front beats it outright. The front does not tell you which portfolio to pick. It does something more honest: it fires every portfolio that no rational person should pick, and hands you the shortlist of real trades.

Tracing the Front

Here is the elegant part, and it reaches straight back to the previous article. Pick a price λ that says how many value points one unit of expected loss is worth to you, and fold the two objectives into a single risk-penalized value, value times the quantity one minus λp. Maximize that under the same budget and you are solving an ordinary knapsack, exactly the machine we already built:

Press enter or click to view image in full size

Sweep λ from zero to one and each value is one knapsack solve that returns one Pareto-optimal portfolio. At λ equal to zero we ignore risk entirely and recover the last article’s answer, value 6,140. As λ climbs, the optimizer grows skittish, dumping the glamorous gambles for safe, humble programs, and the portfolio slides down the front toward low value and low risk. Collect the points, discard any that turn out dominated, and the frontier draws itself.

Press enter or click to view image in full size
Every point is an entire portfolio. The grey cloud is 500 random feasible portfolios, all dominated and all foolish. The red curve is the Pareto front; you cannot climb it without sliding right, buying value only by accepting risk. The blue curve adds the category floors from the previous article.

Three key terrain features on the front that are worth mentioning. The far upper right (the black star) is the risk-blind optimum, value 6,140 but carrying 2,952 points of expected loss, so only about 3,188 points are expected to actually show up. The far lower left is the ultra-conservative corner, a placid value of 901 that is so timid it cannot even spend the whole topline. And between them sits the interesting one.

Get William (Bill) Smith, Ph.D.’s stories in your inbox

Join Medium for free to get updates from this writer.

Follow V minus R along the front and it peaks at the green diamond: the portfolio that maximizes expected delivered value. Getting there from the risk-blind optimum means giving up 313 points of headline value, which sounds like a sacrifice until you read the other column. Expected loss falls by 478, and expected delivered value actually rises, from 3,188 to 3,353. The risk-blind portfolio was not merely riskier. It was worse at the only thing that counts, delivering capability, while advertising a bigger number. This is why you draw the curve.

Constraints Beyond the Budget

The category floors have not gone anywhere. Sea, Air, Land, Research, and Personnel each still demand their minimum share of the topline, and those mandates reshape the whole frontier rather than a single point. The weighted-sum knapsack cannot enforce them (one budget axis only), so we trace the constrained front the rigorous way: fix a ceiling on risk, maximize value subject to the budget and every floor, and step the ceiling across its feasible range. This is the epsilon-constraint method, and each step is a small integer program.

The blue curve shows the result. It rides just inside the red one, because a constrained optimizer can never beat an unconstrained one; the vertical gap is the price of the floors, paid at every risk level rather than once. The floors also lop off the timid end of the front entirely: you are no longer allowed to hide in a tiny basket of safe programs, because the money is legally obligated to flow into every component whether or not that is the safe move. Constraints do not just cost value. They can forbid whole strategies.

Try It Yourself: Draw the Front

The tracer is the last article’s knapsack in a loop, with one twist: each program’s value is discounted by λ times its risk before the pack, so dangerous programs look cheaper in capability and drop out as λ grows. Programs whose penalized value goes negative are skipped outright. Python 3.10 and up; NumPy only.

import numpy as np
# programs: cost (scaled $M units), value, risk = p_fail
vlost = value * risk # expected value lost if it fails

def knap(adjval, cost, cap): # 0/1 knapsack; skip dead-weight items
dp = np.zeros(cap + 1); keep = np.zeros((len(cost), cap + 1), bool)
for i in range(len(cost)):
a, c = float(adjval[i]), int(cost[i])
if a <= 0: # a risk-penalized program not worth its dollars
continue
cand = dp[:cap + 1 - c] + a; better = cand > dp[c:]
keep[i, c:] = better; dp[c:] = np.where(better, cand, dp[c:])
b, sel = cap, []
for i in range(len(cost) - 1, -1, -1):
if keep[i, b]: sel.append(i); b -= int(cost[i])
return np.array(sel)

front = []
for lam in np.linspace(0, 5, 120): # sweep the 'price' charged for risk
sel = knap(value * (1 - lam * risk), cost, CAP) # ONE knapsack per lambda
front.append((value[sel].sum(), vlost[sel].sum()))

Where It Fits

Multi-objective optimization is what you reach for whenever the goals genuinely conflict and no exchange rate between them is handed to you: return versus volatility, speed versus accuracy, cost versus emissions, capability versus risk. Two honest tools cover most of it. Scalarization (the weighted sum) is fast and reuses your single-objective solver, but it only finds points on the convex part of the front and quietly hides the rest. The epsilon-constraint method is slower yet finds every Pareto point, convex or not, and slots constraints in naturally. Use the first to sketch, the second to certify.

And resist the urge everyone feels to collapse the front to one number with a tidy weighting, then declare the problem solved. That weighting is exactly the value judgment the whole exercise exists to expose. Draw the front first. Let the decision-maker see what a percent of safety truly costs in capability. Then, and only then, pick the point.

Real portfolios have more masters still: schedule, industrial base, political durability, the interactions between programs that make two systems worth more together than apart. Each new objective adds an axis, and the front becomes a surface. But the lesson does not change. When goals conflict, do not average them into a single fake number and pretend you optimized. Find the frontier, look at the trade, and choose with your eyes open. And speaking of frontiers, we’ll tackle how optimization can help contested logistics in the Pacific in the next few articles.

The following script builds the same seeded POM as yesterday, adds the correlated risk, traces the full front, marks the maximum-expected-delivery knee, and prints the trade you make at each step. Then it re-traces the front with the category floors switched on so you can watch the frontier retreat. Swap the risk objective from “expected value lost” to “worst-case count of failures” and the knee moves; the method does not care which risk you fear.

"""
risk_pareto_exercise.py
============================================================
Article 11 -- "Sunk Cost Avoidance: Risk, Value, and
the Pareto Front"

Article 10 packed the budget as if every program were a sure
thing. Here we admit that programs fail. Each one gets a
probability of failure p, and the boldest, highest-value programs
are the shakiest (value/risk correlation about 0.87 in this data).

Now there are TWO objectives that fight each other:
maximize V = sum(value_i x_i) (headline capability)
minimize R = sum(value_i p_i x_i) (expected value lost)
and V - R is the expected value actually DELIVERED.

We trace the Pareto front by scalarization: for each price lambda
on risk, solve ONE knapsack that maximizes value_i * (1 - lambda p_i).
Sweep lambda, keep the non-dominated portfolios, and the frontier
draws itself. Then (optional, needs PuLP) we re-trace it with the
category minimum-spend floors via the epsilon-constraint method.

Python 3.10+; NumPy only (PuLP optional). Seeds fixed.
============================================================
"""
from __future__ import annotations
import string
import numpy as np

BUDGET_M = 800_000
COMPONENTS = ["Sea", "Air", "Land", "Research", "Personnel"]
COMPONENT_WEIGHTS = [0.25, 0.30, 0.20, 0.10, 0.15]
COMPONENT_MIN_SHARE = {"Sea": 0.20, "Air": 0.25, "Land": 0.15,
"Research": 0.08, "Personnel": 0.12}


def generate_programs(n: int = 200, seed: int = 1138):
"""Same seeded fleet as Article 10, so the two pieces agree."""
rng = np.random.default_rng(seed)
letters = np.array(list(string.ascii_uppercase))
names, comps, cost, value, seen = [], [], [], [], set()
for _ in range(n):
while True:
nm = "".join(rng.choice(letters, 3)) + f"-{int(rng.integers(0, 1000)):03d}"
if nm not in seen:
seen.add(nm); break
names.append(nm)
cost.append(int(rng.integers(200, 40_001)))
value.append(int(rng.integers(20, 101)))
comps.append(COMPONENTS[int(rng.choice(len(COMPONENTS), p=COMPONENT_WEIGHTS))])
return (np.array(names), np.array(comps),
np.array(cost, dtype=np.int64), np.array(value, dtype=np.int64))


def add_risk(value: np.ndarray, seed: int = 2187) -> np.ndarray:
"""Probability of failure, deliberately correlated with value:
ambition and fragility travel together."""
rng = np.random.default_rng(seed)
base = 0.60 * (value - 20) / 80.0
return np.clip(base + rng.normal(0.0, 0.10, size=value.shape) + 0.05, 0.02, 0.95)


def knap(adjval, cost, cap):
"""0/1 knapsack maximizing sum(adjval_i x_i) under a cost cap.
Programs with non-positive adjusted value are dead weight and skipped."""
dp = np.zeros(cap + 1)
keep = np.zeros((len(cost), cap + 1), dtype=bool)
for i in range(len(cost)):
a, c = float(adjval[i]), int(cost[i])
if a <= 0:
continue
cand = dp[:cap + 1 - c] + a
better = cand > dp[c:]
keep[i, c:] = better
dp[c:] = np.where(better, cand, dp[c:])
b, sel = cap, []
for i in range(len(cost) - 1, -1, -1):
if keep[i, b]:
sel.append(i); b -= int(cost[i])
return np.array(sorted(sel))


def pareto_filter(pts):
"""Keep rows (V, R, ...) that no other row dominates (max V, min R)."""
V, R = pts[:, 0], pts[:, 1]
nd = np.array([not np.any((V >= v) & (R <= r) & ((V > v) | (R < r)))
for v, r in zip(V, R)])
return pts[nd][np.argsort(pts[nd][:, 1])]


def trace_front(cost, value, risk, unit=50):
"""Scalarized front: one knapsack per lambda. Cost scaled for speed."""
vlost = value * risk
c_s = np.round(cost / unit).astype(int)
cap = BUDGET_M // unit
seen = {}
for lam in np.concatenate([np.linspace(0, 2, 81), np.linspace(2, 8, 40)]):
sel = knap(value * (1.0 - lam * risk), c_s, cap)
seen[tuple(sel)] = (float(value[sel].sum()), float(vlost[sel].sum()),
float(cost[sel].sum()))
return pareto_filter(np.array(list(seen.values())))


def main() -> None:
names, comps, cost, value = generate_programs()
risk = add_risk(value)
bar = "=" * 62
print(bar)
print(f"Programs : {len(names)}")
print(f"value / risk correlation: {np.corrcoef(value, risk)[0, 1]:.2f} "
f"(ambition and fragility travel together)")
print(bar)

front = trace_front(cost, value, risk)
V, R = front[:, 0], front[:, 1]
ed = V - R
i_blind = int(np.argmax(V)) # lambda = 0 end
i_knee = int(np.argmax(ed)) # best expected delivery
i_safe = 0 # lowest risk on the front

print("Three landmarks on the Pareto front:")
print(f" risk-blind max value : V={V[i_blind]:.0f} R={R[i_blind]:.0f} "
f"E[delivered]={ed[i_blind]:.0f} (this is Article 10's answer)")
print(f" max expected delivery: V={V[i_knee]:.0f} R={R[i_knee]:.0f} "
f"E[delivered]={ed[i_knee]:.0f} <-- the knee")
print(f" most conservative : V={V[i_safe]:.0f} R={R[i_safe]:.0f} "
f"E[delivered]={ed[i_safe]:.0f}")
print("-" * 62)
print(f"Moving risk-blind -> knee: give up {V[i_blind]-V[i_knee]:.0f} headline "
f"value,")
print(f" shed {R[i_blind]-R[i_knee]:.0f} expected loss, and expected DELIVERED "
f"value rises")
print(f" {ed[i_blind]:.0f} -> {ed[i_knee]:.0f}. The flashier portfolio was worse "
f"at the only thing that ships.")
print(bar)

try:
con = trace_front_with_floors(names, comps, cost, value, risk)
print(f"With category floors : {len(con)} Pareto points; "
f"max value {con[:,0].max():.0f}")
print("The floors push the whole frontier inward and forbid the timid")
print("end entirely -- money must flow to every component, safe or not.")
except ImportError:
print("(install pulp to trace the floor-constrained front: pip install pulp)")
print(bar)


def trace_front_with_floors(names, comps, cost, value, risk):
"""Epsilon-constraint front honoring the category minimum-spend floors."""
import pulp
vlost = value * risk
rows = []
for eps in np.linspace(600, 3100, 26):
prob = pulp.LpProblem("pom_r", pulp.LpMaximize)
x = [pulp.LpVariable(f"x{i}", cat="Binary") for i in range(len(names))]
prob += pulp.lpSum(float(value[i]) * x[i] for i in range(len(names)))
prob += pulp.lpSum(int(cost[i]) * x[i] for i in range(len(names))) <= BUDGET_M
prob += pulp.lpSum(float(vlost[i]) * x[i] for i in range(len(names))) <= eps
for k in COMPONENTS:
idx = np.where(comps == k)[0]
prob += pulp.lpSum(int(cost[i]) * x[i] for i in idx) >= COMPONENT_MIN_SHARE[k] * BUDGET_M
prob.solve(pulp.PULP_CBC_CMD(msg=0))
if pulp.LpStatus[prob.status] != "Optimal":
continue
sel = np.array([i for i in range(len(names)) if x[i].value() > 0.5])
rows.append((float(value[sel].sum()), float(vlost[sel].sum()),
float(cost[sel].sum())))
return pareto_filter(np.array(rows))


if __name__ == "__main__":
main()

--

--

William (Bill) Smith, Ph.D.

Written by William (Bill) Smith, Ph.D.

Educator and Operations Research Analyst