Value of Information for Security Decisions#
Part 0 introduced EVPI and EVSI with a simple EDR decision tree. This notebook extends Value of Information (VOI) to realistic security scenarios: when is a pentest, threat assessment, or additional data collection worth paying for?
The core idea: information only has value when it can change your decision. If you would deploy the same controls regardless of what a pentest finds, the pentest has zero decision value – no matter how technically thorough it is.
This notebook covers:
EVPI as the ceiling on what any information source is worth
EVSI for an imperfect signal (pentest with known TPR/FPR)
When information has zero value
VOI as a function of test quality
Practical pitfalls
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from decision_security.synth import make_rng, sample
from decision_security.montecarlo import (
simulate_aggregate_losses, make_lognormal_severity, var_es
)
from decision_security.voi import evpi
rng = make_rng(42)
plt.rcParams.update({
"font.family": "serif",
"font.size": 10,
"axes.labelsize": 11,
"axes.titlesize": 12,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"legend.fontsize": 9,
"figure.dpi": 150,
"axes.spines.top": False,
"axes.spines.right": False,
})
PRIMARY = "#1A1A1A"
ACCENT = "#E74C3C"
DARK_BG = "#34495E"
LIGHT_GRAY = "#95A5A6"
MED_GRAY = "#7F8C8D"
VERY_LIGHT = "#BDC3C7"
1. The VOI Question#
Before spending $50K on a penetration test, ask: will the results change my
decision?
Value of Information (VOI) is the expected improvement in decision quality from obtaining new data before deciding. It depends on three things:
How uncertain you are about the state of the world (e.g., breach probability)
How sensitive the decision is to that uncertainty (e.g., whether a different probability would flip your choice)
How accurate the information source is (e.g., pentest detection rate)
If any of these is near zero, VOI is near zero.
2. EVPI: The Ceiling on What Information Is Worth#
EVPI (Expected Value of Perfect Information) is the maximum you should pay for
any information source – perfect or imperfect. If a crystal ball that tells
you exactly which scenario will occur is worth $X, then no pentest, threat
assessment, or vulnerability scan can be worth more than $X.
Scenario: A CISO is deciding how to handle data exfiltration risk:
(A) Full DLP –
$300K, reduces exfiltration loss by 85%(B) Endpoint-only DLP –
$120K, reduces exfiltration loss by 50%(C) Accept risk – no cost, full exposure
We simulate 10,000 scenarios with an 8% annual exfiltration probability and lognormal severity.
n = 10_000
severity = make_lognormal_severity(meanlog=13.0, sdlog=1.5)
p_exfil = 0.08
has_exfil = rng.random(n) < p_exfil
exfil_loss = np.where(has_exfil, severity(n, rng), 0)
cost_full_dlp = 300_000
cost_endpoint = 120_000
reduction_full = 0.85
reduction_endpoint = 0.50
# Total cost per option per scenario
loss_A = cost_full_dlp + exfil_loss * (1 - reduction_full)
loss_B = cost_endpoint + exfil_loss * (1 - reduction_endpoint)
loss_C = exfil_loss
loss_matrix = np.column_stack([loss_A, loss_B, loss_C])
# EVPI: expected saving from acting on perfect foreknowledge (positive)
evpi_val = evpi(loss_matrix)
ev_each = loss_matrix.mean(axis=0)
best_idx = np.argmin(ev_each)
options = ["Full DLP ($300K)", "Endpoint DLP ($120K)", "Accept risk"]
print("Expected cost per option:")
for name, ev in zip(options, ev_each):
print(f" {name}: ${ev:,.0f}")
print(f"\nBest option by EV: {options[best_idx]}")
print(f"EVPI: ${evpi_val:,.0f} (what a perfect oracle saves in expectation)")
print()
exp_loss = exfil_loss.mean()
opt_costs = [cost_full_dlp, cost_endpoint, 0]
opt_reductions = [reduction_full, reduction_endpoint, 0.0]
print(f"Expected annual exfiltration loss: ~${exp_loss:,.0f}")
print("Net benefit of each option (avoided loss minus cost):")
for name, c, r in zip(options, opt_costs, opt_reductions):
print(f" {name}: ${exp_loss * r:,.0f} avoided - ${c:,.0f} cost = ${exp_loss * r - c:,.0f}")
if opt_reductions[best_idx] == 0:
print("Every control costs more than the loss it avoids, so carrying the risk is cheaper.")
else:
runner_up = int(np.argsort(ev_each)[1])
print(f"It beats the next-best option ({options[runner_up]}) by "
f"${ev_each[runner_up] - ev_each[best_idx]:,.0f} in expected cost.")
Expected cost per option:
Full DLP ($300K): $318,369
Endpoint DLP ($120K): $181,230
Accept risk: $122,459
Best option by EV: Accept risk
EVPI: $86,325 (what a perfect oracle saves in expectation)
Expected annual exfiltration loss: ~$122,459
Net benefit of each option (avoided loss minus cost):
Full DLP ($300K): $104,090 avoided - $300,000 cost = $-195,910
Endpoint DLP ($120K): $61,230 avoided - $120,000 cost = $-58,770
Accept risk: $0 avoided - $0 cost = $0
Every control costs more than the loss it avoids, so carrying the risk is cheaper.
3. EVSI: What Is a Pentest Actually Worth?#
A penetration test is not a crystal ball. It has a detection rate (true positive rate, TPR) – how often it correctly flags a real vulnerability that would lead to exfiltration – and a false alarm rate (FPR) – how often it flags a problem that would not actually lead to a loss event.
EVSI (Expected Value of Sample Information) is the expected value of this imperfect signal:
We model a pentest with 75% TPR and 10% FPR. For each scenario, the pentest produces either a “high risk” or “low risk” signal. We then compute the optimal decision conditional on each signal and compare to the unconditional decision.
tpr = 0.75 # P(pentest flags | exfiltration would occur)
fpr = 0.10 # P(pentest flags | no exfiltration)
signal_positive = np.where(
has_exfil,
rng.random(n) < tpr, # true positive
rng.random(n) < fpr, # false positive
)
ev_no_info = loss_matrix.mean(axis=0).min()
ev_pos = loss_matrix[signal_positive].mean(axis=0)
ev_neg = loss_matrix[~signal_positive].mean(axis=0)
best_if_pos = ev_pos.min()
best_if_neg = ev_neg.min()
# EV with information: weighted average of conditional optima
p_pos = signal_positive.mean()
ev_with_info = p_pos * best_if_pos + (1 - p_pos) * best_if_neg
evsi = ev_no_info - ev_with_info
option_labels = ["Full DLP", "Endpoint DLP", "Accept risk"]
print(f"Without pentest: always choose {option_labels[np.argmin(loss_matrix.mean(axis=0))]}")
print(f" EV = ${ev_no_info:,.0f}")
print(f"\nWith pentest (TPR={tpr}, FPR={fpr}):")
print(f" If pentest flags HIGH risk ({p_pos:.0%} of the time): choose {option_labels[np.argmin(ev_pos)]}")
print(f" If pentest flags LOW risk ({1-p_pos:.0%} of the time): choose {option_labels[np.argmin(ev_neg)]}")
print(f" EV = ${ev_with_info:,.0f}")
print(f"\nEVSI = ${evsi:,.0f}")
print(f"EVPI = ${evpi_val:,.0f}")
if evpi_val > 0:
print(f"The pentest captures {evsi/evpi_val:.0%} of the value of perfect information")
print()
idx_uncond = int(np.argmin(loss_matrix.mean(axis=0)))
idx_pos, idx_neg = int(np.argmin(ev_pos)), int(np.argmin(ev_neg))
# EVSI decomposes exactly into the two branch contributions, because ev_no_info is
# the same weighted average of the *unconditional* choice across both branches.
contrib_pos = p_pos * (ev_pos[idx_uncond] - ev_pos[idx_pos])
contrib_neg = (1 - p_pos) * (ev_neg[idx_uncond] - ev_neg[idx_neg])
for label, mass, idx_b, contrib in [
("HIGH", p_pos, idx_pos, contrib_pos),
("LOW", 1 - p_pos, idx_neg, contrib_neg),
]:
if idx_b != idx_uncond:
print(f"On the {mass:.0%} of the time the pentest flags {label} risk, the choice flips")
print(f"from {option_labels[idx_uncond]} to {option_labels[idx_b]}, "
f"contributing ${contrib:,.0f} of the EVSI.")
else:
print(f"On the {mass:.0%} of the time the pentest flags {label} risk, the decision is")
print(f"unchanged ({option_labels[idx_uncond]}), so it contributes nothing.")
# Bootstrap: resample scenarios with replacement
n_boot = 2000
boot_rng = make_rng(7)
boot_evsi = np.empty(n_boot)
for i in range(n_boot):
bi = boot_rng.integers(0, n, n)
Mb, sb = loss_matrix[bi], signal_positive[bi]
p_b = sb.mean()
ev_w_b = p_b * Mb[sb].mean(axis=0).min() + (1 - p_b) * Mb[~sb].mean(axis=0).min()
boot_evsi[i] = Mb.mean(axis=0).min() - ev_w_b
lo, hi = np.percentile(boot_evsi, [2.5, 97.5])
print(f"\nBootstrap 95% CI for EVSI: ${lo:,.0f} to ${hi:,.0f}")
Without pentest: always choose Accept risk
EV = $122,459
With pentest (TPR=0.75, FPR=0.1):
If pentest flags HIGH risk (15% of the time): choose Full DLP
If pentest flags LOW risk (85% of the time): choose Accept risk
EV = $96,304
EVSI = $26,155
EVPI = $86,325
The pentest captures 30% of the value of perfect information
On the 15% of the time the pentest flags HIGH risk, the choice flips
from Accept risk to Full DLP, contributing $26,155 of the EVSI.
On the 85% of the time the pentest flags LOW risk, the decision is
unchanged (Accept risk), so it contributes nothing.
Bootstrap 95% CI for EVSI: $15,143 to $42,571
4. When Information Has Zero Value#
Sometimes the optimal decision does not change regardless of the test result. This happens when:
One option dominates across all plausible scenarios
The decision is insensitive to the specific parameter the test measures
Prior uncertainty is already low enough that this test’s signal cannot flip the choice
We demonstrate by dropping the exfiltration probability to 2%. At this level, even a positive pentest result cannot raise the posterior risk high enough to justify the DLP investment.
p_exfil_low = 0.02
has_exfil_low = rng.random(n) < p_exfil_low
exfil_loss_low = np.where(has_exfil_low, severity(n, rng), 0)
loss_A2 = cost_full_dlp + exfil_loss_low * (1 - reduction_full)
loss_B2 = cost_endpoint + exfil_loss_low * (1 - reduction_endpoint)
loss_C2 = exfil_loss_low
loss_matrix_2 = np.column_stack([loss_A2, loss_B2, loss_C2])
# Same pentest characteristics
signal_pos_2 = np.where(
has_exfil_low,
rng.random(n) < tpr,
rng.random(n) < fpr,
)
ev_no_info_2 = loss_matrix_2.mean(axis=0).min()
ev_pos_2 = loss_matrix_2[signal_pos_2].mean(axis=0)
ev_neg_2 = loss_matrix_2[~signal_pos_2].mean(axis=0)
p_pos_2 = signal_pos_2.mean()
ev_with_info_2 = p_pos_2 * ev_pos_2.min() + (1 - p_pos_2) * ev_neg_2.min()
evsi_2 = ev_no_info_2 - ev_with_info_2
if abs(evsi_2) < 1.0: # scrub floating-point negative zero
evsi_2 = 0.0
print(f"Low-risk scenario (P(exfil) = {p_exfil_low}):")
print(f" Best option WITHOUT pentest: {option_labels[np.argmin(loss_matrix_2.mean(axis=0))]}")
print(f" Best option IF positive: {option_labels[np.argmin(ev_pos_2)]}")
print(f" Best option IF negative: {option_labels[np.argmin(ev_neg_2)]}")
print(f" EVSI = ${evsi_2:,.0f}")
idx_uncond_2 = int(np.argmin(loss_matrix_2.mean(axis=0)))
idx_pos_2, idx_neg_2 = int(np.argmin(ev_pos_2)), int(np.argmin(ev_neg_2))
if evsi_2 == 0.0:
print(f"\nThe pentest does not change the decision -- its value is zero.")
rest = ", ".join(
f"{option_labels[j]} (${ev_pos_2[j]:,.0f})"
for j in np.argsort(ev_pos_2)[1:]
)
print(f"Even conditional on a POSITIVE signal, {option_labels[idx_pos_2]}'s expected cost")
print(f"(${ev_pos_2[idx_pos_2]:,.0f}) stays below {rest}: at a {p_exfil_low:.0%} base rate")
print(f"this pentest's signal does not lift the posterior expected loss enough to justify switching.")
else:
print(f"\nThe pentest is worth ${evsi_2:,.0f} here -- the signal does move the choice.")
for label, mass, idx_b in [
("POSITIVE", p_pos_2, idx_pos_2),
("NEGATIVE", 1 - p_pos_2, idx_neg_2),
]:
if idx_b != idx_uncond_2:
print(f" On a {label} signal ({mass:.0%} of the time) the choice flips from "
f"{option_labels[idx_uncond_2]} to {option_labels[idx_b]}.")
Low-risk scenario (P(exfil) = 0.02):
Best option WITHOUT pentest: Accept risk
Best option IF positive: Accept risk
Best option IF negative: Accept risk
EVSI = $0
The pentest does not change the decision -- its value is zero.
Even conditional on a POSITIVE signal, Accept risk's expected cost
($156,160) stays below Endpoint DLP ($198,080), Full DLP ($323,424): at a 2% base rate
this pentest's signal does not lift the posterior expected loss enough to justify switching.
5. VOI Across Test Quality#
We sweep TPR from 0.5 to 1.0 (holding FPR at 10%) and plot EVSI at each level, using common random numbers which reduce (but do not remove) Monte Carlo noise between test qualities.
The value rises steadily – linearly across this range, with no diminishing returns. The pentest cannot see loss size, so each extra point of TPR adds the same expected value; any upturn in the curve is Monte Carlo noise. Even at a perfect TPR of 1.0 the EVSI stays below the EVPI ceiling, because of the 10% false-positive rate and because the signal says nothing about loss size.
tprs = np.linspace(0.5, 1.0, 20)
evsis = []
# Common random numbers: one uniform draw per scenario, thresholded at each TPR.
# Raising TPR only adds true positives; the FPR branch stays fixed.
u = rng.random(n)
ev_no = loss_matrix.mean(axis=0).min()
for t in tprs:
sig = np.where(has_exfil, u < t, u < fpr)
p_s = sig.mean()
if 0 < p_s < 1:
ev_w = (
p_s * loss_matrix[sig].mean(axis=0).min()
+ (1 - p_s) * loss_matrix[~sig].mean(axis=0).min()
)
else:
ev_w = ev_no
evsis.append(ev_no - ev_w)
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(tprs, evsis, "o-", color=DARK_BG, markersize=4, label="EVSI (common random numbers)")
ax.axhline(
evpi_val, color=ACCENT, linestyle="--",
label=f"EVPI ceiling = ${evpi_val:,.0f}",
)
ax.set_xlabel("Pentest Detection Rate (TPR)")
ax.set_ylabel("EVSI ($)")
ax.set_title("Value of Information vs Test Quality")
ax.legend()
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}"))
plt.tight_layout()
plt.show()
6. Pitfalls#
If the decision does not change, the information is worthless. Before commissioning any assessment, ask: “What would I do differently if the result were X vs Y?” If the answer is “nothing,” save the money.
EVPI is the ceiling, not the target. Budget for EVSI, not EVPI.
VOI depends on the decision context, not the test quality alone. A mediocre pentest in a close-call scenario can be worth more than a perfect pentest in an obvious-choice scenario.
Repeated assessments usually have declining marginal value. The second pentest of the same scope usually adds less than the first, as prior uncertainty shrinks – but not always: a single weak test can be worth nothing while two together flip the decision.
Sunk cost trap with assessments. The
$50Kyou already spent on last year’s assessment is irrelevant to whether this year’s assessment is worth$50K.