| Time | Block |
|---|---|
| 0:00 – 0:40 | 67.1 Decision criteria |
| 0:40 – 1:40 | 67.2 Four options, one set of uncertainties |
| 1:40 – 1:55 | Break |
| 1:55 – 2:40 | 67.3 Risk attitude and certainty equivalents |
| 2:40 – 3:20 | 67.4 What if you notice too late? |
| 3:20 – 3:35 | Break |
| 3:35 – 4:40 | 67.5 Day 67 mini-project: how many customers to coupon? |
| 4:40 – 5:00 | 67.6 Save your work with Git |
67.1 Decision criteria (40 min)
When outcomes are uncertain, "the best option" depends on the criterion:
| Criterion | Choose the option with the... | When it fits |
|---|---|---|
| Expected value | highest average outcome | many similar decisions, losses affordable |
| Probability of loss | lowest chance of losing money | when a loss has consequences beyond its size (a manager's credibility, a covenant) |
| Expected shortfall (average of the worst 5%) | best bad case | when tail losses matter |
| Maximin | best worst case | extreme caution; uncertainty you can't put probabilities on |
| Minimax regret | smallest maximum regret (regret: how much worse than the best option in hindsight) | when being shown up by hindsight is costly |
| Certainty equivalent | highest sure-amount equivalent, given a risk tolerance | a company with a defined appetite for risk |
None is "correct". A good analysis reports several and lets the decision-maker see whether they agree.
67.2 Four options, one set of uncertainties (60 min)
Day 66's uncertain inputs, drawn 10,000 times, evaluated for four options on the same draws (so regret can be computed draw by draw):
- don't build;
- coupons for everyone;
- targeted programme;
- targeted programme with a stop rule: shut it down in the first month it stops paying.
The stop rule is the most realistic option. It's also the one most CBAs forget: a programme isn't a commitment to run for two years whatever happens. Add stop to Day 66's npv function (the version in day66/cba.py above already has it). Create day67/options.py:
# day67/options.py (python -m day67.options)
"""
Decisions under uncertainty: four options for the coupon programme, each evaluated on the SAME 10,000 draws of
the uncertain inputs (Day 66). Different decision criteria can pick different options.
don't build | coupons for everyone | targeted | targeted, with a stop rule (shut down when it stops paying)
Run day66.cba first.
"""
import json
import numpy as np
import polars as pl
from day66.cba import npv
def draw_inputs(base, n=10_000, seed=67):
rng = np.random.default_rng(seed)
for _ in range(n):
yield base | {"fatigue": rng.uniform(0.96, 1.00), "pull_forward": rng.uniform(0.0, 0.30),
"uplift": rng.normal(base["uplift"], base["uplift"] * 0.066), "margin": rng.uniform(0.25, 0.35),
"coupon_cost": rng.uniform(4.0, 6.0), "customers": rng.uniform(300_000, 500_000),
"running_cost": rng.uniform(15_000, 50_000), "build_cost": rng.uniform(100_000, 300_000)}
def option_npvs(base):
"""NPV ($k) of each option in each draw."""
out = {"don't build": [], "coupons for everyone": [], "targeted": [], "targeted + stop rule": []}
for a in draw_inputs(base):
out["don't build"].append(0.0)
scale = base["uplift_everyone"] / base["uplift"]
out["coupons for everyone"].append(npv(a | {"uplift": a["uplift"] * scale, "share": 1.0}, stop=False)[0] / 1000)
out["targeted"].append(npv(a)[0] / 1000)
out["targeted + stop rule"].append(npv(a, stop=True)[0] / 1000)
return {k: np.array(v) for k, v in out.items()}
if __name__ == "__main__":
base = json.load(open("data/day66_inputs.json"))
v = option_npvs(base)
best = np.max(np.column_stack(list(v.values())), axis=1)
rows = []
for name, x in v.items():
worst5 = np.sort(x)[: len(x) // 20].mean()
rows.append({"option": name, "expected NPV": round(x.mean()), "P(loss)": f"{(x < 0).mean():.0%}",
"average of worst 5%": round(worst5), "worst case": round(x.min()),
"expected regret": round((best - x).mean()), "max regret": round((best - x).max())})
with pl.Config(tbl_width_chars=140, fmt_str_lengths=30):
print(pl.DataFrame(rows))
print("All $ figures in $ thousands. Regret: how much less than the best option in the same draw.")
python -m day67.options
│ option ┆ expected NPV ┆ P(loss) ┆ average of worst 5% ┆ worst case ┆ expected regret ┆ max regret │
│ don't build ┆ 0 ┆ 0% ┆ 0 ┆ 0 ┆ 4986 ┆ 29363 │
│ coupons for everyone ┆ -13007 ┆ 94% ┆ -29742 ┆ -41256 ┆ 17993 ┆ 41256 │
│ targeted ┆ 3809 ┆ 26% ┆ -6573 ┆ -13224 ┆ 1176 ┆ 13224 │
│ targeted + stop rule ┆ 4976 ┆ 6% ┆ -185 ┆ -299 ┆ 10 ┆ 299 │
All $ figures in $ thousands. Regret: how much less than the best option in the same draw.
The stop rule wins on every criterion. It's worth $1.2M of expected value over the plain programme, cuts the chance of a loss from 26% to 6%, and limits the worst case to roughly the build cost. That $1.2M is the value of flexibility: being able to stop when the evidence turns.
Notice the difference between "targeted" and "targeted + stop rule". It isn't a better model or more data; it's a monitoring commitment written into the plan. Decision quality often depends more on what you do after launch than on the estimate you launched with.
67.3 Risk attitude and certainty equivalents (45 min)
Concept. A company that can't afford a $10M loss shouldn't treat it as cancelling out a $10M gain. Exponential utility with risk tolerance R gives each option a certainty equivalent (CE): the sure amount the company would take instead of the gamble.
CE = −R × log( mean( exp(−NPV / R) ) )
R is roughly the largest gamble the company would accept at even odds of winning R or losing R/2. For a mid-sized retailer, a few million dollars is typical. Create day67/risk_attitude.py:
# day67/risk_attitude.py (python -m day67.risk_attitude)
"""
Expected value treats a $10M loss and a $10M gain as cancelling out. A company that can't afford the loss
shouldn't. Exponential utility with risk tolerance R (roughly: the size of gamble the company would take at
even odds of winning R or losing R/2) gives each option a CERTAINTY EQUIVALENT: the sure amount the company
would accept instead of the gamble. CE = -R x log(mean(exp(-NPV / R))).
"""
import json
import numpy as np
import polars as pl
from day67.options import option_npvs
base = json.load(open("data/day66_inputs.json"))
v = option_npvs(base)
rows = []
for label, R in [("risk neutral", np.inf), ("R = $20M", 20_000), ("R = $5M", 5_000), ("R = $1M", 1_000)]:
ce = {k: x.mean() if np.isinf(R) else -R * np.log(np.mean(np.exp(-x / R))) for k, x in v.items()}
rows.append({"risk attitude": label} | {k: round(c) for k, c in ce.items()} | {"choice": max(ce, key=ce.get)})
with pl.Config(tbl_width_chars=140, fmt_str_lengths=30):
print(pl.DataFrame(rows))
print("Certainty equivalents in $ thousands.")
python -m day67.risk_attitude
│ risk attitude ┆ don't build ┆ coupons for everyone ┆ targeted ┆ targeted + stop rule ┆ choice │
│ risk neutral ┆ 0 ┆ -13007 ┆ 3809 ┆ 4976 ┆ targeted + stop rule │
│ R = $20M ┆ 0 ┆ -14673 ┆ 3028 ┆ 4512 ┆ targeted + stop rule │
│ R = $5M ┆ 0 ┆ -19484 ┆ 1006 ┆ 3514 ┆ targeted + stop rule │
│ R = $1M ┆ 0 ┆ -33041 ┆ -5543 ┆ 1613 ┆ targeted + stop rule │
Certainty equivalents in $ thousands.
For a very risk-averse company (R = $1M), the plain targeted programme is worth less than nothing (CE −$5.5M), but with the stop rule it's still worth $1.6M. Flexibility matters most to those who can least afford to be wrong.
67.4 What if you notice too late? (40 min)
Try it. A stop rule assumes you see a bad month immediately. In practice, results arrive with a lag. Rerun the options with the stop acting 1, 2 or 4 months after the first loss-making month. Create day67/late_stop.py.
Solution
# day67/late_stop.py (python -m day67.late_stop)
import json
import numpy as np
from day67.options import draw_inputs
def npv_late_stop(a, months=24, lag=2):
"""Like npv(stop=True), but a loss-making month is only noticed `lag` months later."""
rate = (1 + a["discount_rate"]) ** (1 / 12) - 1
total, first_bad = -a["build_cost"], None
for m in range(1, months + 1):
flow = a["customers"] * (a["margin"] * a["uplift"] * (1 - a["pull_forward"]) * a["fatigue"] ** (m - 1)
- a["coupon_cost"] * a["share"]) - a["running_cost"]
if first_bad is None and flow < 0:
first_bad = m
if first_bad is not None and m >= first_bad + lag:
break
total += flow / (1 + rate) ** m
return total / 1000
base = json.load(open("data/day66_inputs.json"))
for lag in [0, 1, 2, 4]:
x = np.array([npv_late_stop(a, lag=lag) for a in draw_inputs(base)])
print(f"stop rule acting {lag} months late: expected NPV ${x.mean():,.0f}k, P(loss) {(x < 0).mean():.0%}")
stop rule acting 0 months late: expected NPV $4,976k, P(loss) 6%
stop rule acting 1 months late: expected NPV $4,967k, P(loss) 7%
stop rule acting 2 months late: expected NPV $4,944k, P(loss) 7%
stop rule acting 4 months late: expected NPV $4,863k, P(loss) 9%
Even a four-month lag keeps almost all the benefit. In this programme, losses build up slowly (the effect fades gradually), so a late stop costs little. With a programme that could fail abruptly, the monitoring lag would matter much more. Ask: "how fast could this go wrong, and how fast would we know?"
67.5 Day 67 mini-project: how many customers to coupon? (65 min)
Finance isn't sure of the margin (25–35%) or the coupon's real cost ($4–6). Candidate policies coupon the top 20%, 35%, 50%, 65% or 80% of customers by the DR-learner's predicted effect. Create day67/how_many.py:
# day67/how_many.py (python -m day67.how_many)
"""
Mini-project: the finance team isn't sure of the margin (25-35%) or the coupon's real cost ($4-6).
Candidate policies: coupon the top 20%, 35%, 50%, 65% or 80% of customers by the DR-learner's predicted effect.
Evaluate each in each of 9 (margin, cost) scenarios on the experiment, then choose by three criteria:
expected value (scenarios equally likely) | maximin (best worst case) | minimax regret.
"""
import itertools
import numpy as np
import polars as pl
from econml.dr import DRLearner
from causalkit.coupons import X_COLS
from causalkit.learners import clf, reg
from causalkit.policy import dr_scores
from day64.uplift_models import experiment
from day65.policy_value import outcome_models
train, test = experiment()
dr = DRLearner(model_propensity=clf(), model_regression=reg(),
model_final=reg(n_estimators=100, num_leaves=4, min_child_samples=300), cv=5,
min_propensity=0.02, random_state=0).fit(train.spend, train.coupon, X=train[X_COLS])
rank = np.argsort(np.argsort(-dr.effect(test[X_COLS]))) / len(test) # 0 = highest predicted effect
y, mu0, mu1 = outcome_models(test, cost=0.0)
s1, s0 = dr_scores(y / 0.30, test.coupon, 0.5, mu0 / 0.30, mu1 / 0.30) # scores for SPEND
uplift = s1 - s0
shares = [0.20, 0.35, 0.50, 0.65, 0.80]
scenarios = list(itertools.product([0.25, 0.30, 0.35], [4.0, 5.0, 6.0]))
value = np.array([[np.mean((rank < q) * (m * uplift - c)) for (m, c) in scenarios] for q in shares])
regret = value.max(axis=0) - value
table = pl.DataFrame({"coupon the top": [f"{q:.0%}" for q in shares],
"expected value": value.mean(1).round(2), "worst case": value.min(1).round(2),
"max regret": regret.max(1).round(2)})
print(table)
for crit, col, best in [("expected value", "expected value", max), ("maximin", "worst case", max),
("minimax regret", "max regret", min)]:
i = table[col].to_list().index(best(table[col].to_list()))
print(f"{crit:<15} chooses: coupon the top {shares[i]:.0%}")
print("Net $ per customer in the base. Best share in each scenario (margin, cost): " +
", ".join(f"({m:.2f}, ${c:.0f}) {shares[int(np.argmax(value[:, j]))]:.0%}" for j, (m, c) in enumerate(scenarios)))
python -m day67.how_many
│ coupon the top ┆ expected value ┆ worst case ┆ max regret │
│ 20% ┆ 1.31 ┆ 0.73 ┆ 1.25 │
│ 35% ┆ 1.78 ┆ 0.84 ┆ 0.43 │
│ 50% ┆ 1.82 ┆ 0.6 ┆ 0.24 │
│ 65% ┆ 1.67 ┆ 0.2 ┆ 0.64 │
│ 80% ┆ 1.28 ┆ -0.4 ┆ 1.24 │
expected value chooses: coupon the top 50%
maximin chooses: coupon the top 35%
minimax regret chooses: coupon the top 50%
Expected value and minimax regret pick 50%; maximin picks 35% (the best worst case, at margin 25% and cost $6). The difference between 35% and 50% is small in expected value ($0.04 per customer) but the 50% policy's worst case is lower. The script's last line shows where the best share changes: 35% is best when the coupon costs $6 or the margin is only 25%, and 50% or more otherwise. A defensible recommendation: "coupon the top 50%; if finance finds the coupon costs closer to $6, or the margin is nearer 25%, coupon the top 35%". A good analysis turns disagreement between criteria into a conditional recommendation.
67.6 Save your work with Git (20 min)
git add .
git commit -m "Day 67: decision criteria, value of flexibility, risk attitude, robust choice"
git push
Day 67 checklist
- I can compare options by expected value, P(loss), expected shortfall, maximin and regret
- I evaluate options on the same simulated draws so regret is meaningful
- I can value flexibility (a stop rule) and explain why it changes the decision
- I can compute certainty equivalents for different risk tolerances
- I can turn disagreement between criteria into a conditional recommendation