Value of information

This tutorial shows how to run the full value-of-information workflow, expected value of perfect information (EVPI), of partial perfect information (EVPPI), and of sample information (EVSI), and checks every estimate against a closed form, continuing from the full pipeline tutorial. The model is the Gaussian linear decision model that anchors the regression value-of-information literature (Strong, Oakley & Brennan, 2014, Medical Decision Making 34:311-326; Strong, Oakley, Brennan & Breeze, 2015, Medical Decision Making 35:570-583): its incremental net benefit is Normal, so all three measures have closed forms via the unit normal loss integral, which is what lets this page check each estimate rather than assume it. The measures follow the good-practice framing of Rothery and others (2020, Value in Health 23:277-286). The full script is examples/voi_tutorial.py.

Specifying the decision model

Two interventions, standard care and a new drug, differ by an uncertain incremental effect dq (quality-adjusted life-years, QALYs) and an uncertain incremental cost dc, both Normal. At a 30,000-per-QALY threshold the drug is cost-effective on the means, so value of information asks not which intervention to pick but the value of more evidence to reduce the decision uncertainty.

import numpy as np
import pandas as pd
from scipy.stats import norm
from heormodel.models import Outcomes
from heormodel.params import Normal, ParameterSet
from heormodel.run import run_psa

WTP = 30_000.0
MU_Q, SD_Q = 0.20, 0.30
MU_C, SD_C = 4_000.0, 8_000.0

def new_drug(d: pd.DataFrame) -> Outcomes:
    zero = np.zeros(len(d))
    costs = pd.DataFrame({"Standard care": zero, "New drug": d["dc"]}, index=d.index)
    effects = pd.DataFrame({"Standard care": zero, "New drug": d["dq"]}, index=d.index)
    return Outcomes.from_wide(costs, effects)

params = ParameterSet({"dq": Normal(MU_Q, SD_Q), "dc": Normal(MU_C, SD_C)})
draws = params.sample(100_000, seed=2026)
outcomes = run_psa(new_drug, draws, sequential=True).outcomes

The closed forms use the unit normal loss integral: the value of resolving uncertainty is the expected upside minus the current decision’s value.

def emax0(m, s):  # E[max(0, X)] for X ~ Normal(m, s**2)
    return m * norm.cdf(m / s) + s * norm.pdf(m / s)

def analytic_voi(s_conditional):
    m = WTP * MU_Q - MU_C
    return emax0(m, s_conditional) - max(0.0, m)

EVPI: the ceiling on research value

EVPI is the difference between a decision made with perfect information and one made with current information, an upper bound on the value of any research. evpi recovers the closed form within Monte Carlo error.

from heormodel.voi import evpi

s_total = np.hypot(WTP * SD_Q, SD_C)
pd.Series({"estimate": evpi(outcomes, WTP), "closed form": analytic_voi(s_total)}).round(0)
estimate       3892.0
closed form    3870.0
dtype: float64

EVPPI: which parameters carry that value

EVPPI is the value of resolving one parameter while the rest stay uncertain. It identifies which parameters drive the decision uncertainty, so evppi_ranking sweeps each and sorts them into a research priority list. The effect dq beats the cost dc here because WTP * SD_Q exceeds SD_C.

from heormodel.voi import evppi, evppi_ranking

ranking = evppi_ranking(outcomes, draws, WTP)
closed = pd.Series({"dq": analytic_voi(WTP * SD_Q), "dc": analytic_voi(SD_C)})
pd.DataFrame({"estimate": ranking, "closed form": closed}).round(0)
estimate closed form
dq 2709.0 2679.0
dc 2313.0 2291.0

The ranking is threshold-specific: below about 28,000 per QALY the cost leads instead. The default spline metamodel suits one or two parameters; method="gp" (a Gaussian process) handles groups of many parameters.

EVSI: the value of a specific study

EVSI is the value of a study of finite sample size, not perfect resolution. Consider a two-arm trial measuring the incremental effect, each arm reporting a mean QALY difference with sampling standard deviation tau = 1 / sqrt(n). simulate_summaries draws one trial summary per parameter draw from its true dq, and evsi_regression reads the value off the fitted conditional means, checked here against the closed form.

from heormodel.voi import evsi_regression, simulate_summaries

tau = 1.0 / np.sqrt(200)
summaries = simulate_summaries(
    draws, lambda row, r: {"xbar": row["dq"] + r.normal(0.0, tau)}, seed=200)
s_evsi = WTP * SD_Q**2 / np.hypot(SD_Q, tau)
pd.Series({"estimate": evsi_regression(outcomes, summaries, WTP),
           "closed form": analytic_voi(s_evsi)}).round(0)
estimate       2615.0
closed form    2585.0
dtype: float64

Reported against the willingness-to-pay threshold, EVSI is a family of curves, one per sample size. Each fans out between zero and the effect EVPPI, not the EVPI: this trial measures only the effect, so it cannot resolve the cost uncertainty, which stays in the decision and caps the study’s value below perfect information. A trial that also measured cost would climb toward EVPI instead. Larger trials shrink tau, lifting each curve toward its ceiling.

import matplotlib.pyplot as plt
from matplotlib import cm, colors

grid = np.linspace(0, 120_000, 13)
sizes = [25, 50, 100, 200, 400, 800]
rng = np.random.default_rng(0)
fan = {}
for n in sizes:  # the same study, evaluated across the threshold grid
    xbar = draws["dq"] + rng.normal(0.0, 1.0 / np.sqrt(n), len(draws))
    summ = pd.DataFrame({"xbar": xbar}, index=draws.index)
    fan[n] = [evsi_regression(outcomes, summ, k) for k in grid]

ramp, scale = plt.get_cmap("Purples"), colors.LogNorm(min(sizes), max(sizes))
fig, ax = plt.subplots()
for n in sizes:
    ax.plot(grid, fan[n], color=ramp(0.3 + 0.6 * scale(n)), lw=1.4)
ax.plot(grid, evpi(outcomes, grid).to_numpy(), color="#333333", lw=2, label="EVPI")
ax.plot(grid, [evppi(outcomes, draws, "dq", k) for k in grid],
        color="#eb6834", lw=2, label="EVPPI (effect)")
ax.set(xlabel="willingness to pay", ylabel="value per person")
ax.legend(loc="upper left")
sm = cm.ScalarMappable(norm=scale, cmap=ramp); sm.set_array([])
fig.colorbar(sm, ax=ax, label="trial size (n per arm)");

Two features look odd until you read them off the model. At a zero threshold EVPI is already about 1,600: health is unpriced, so the choice turns on cost alone, and the uncertain cost dc is a saving 31% of the time that perfect information would capture. The EVSI curves start at zero there, since an effect study is worthless when the effect has no value. And nothing peaks: the net benefit standard deviation hypot(WTP * SD_Q, SD_C) grows with the threshold, so the stakes of a wrong choice climb while the chance of one settles near 25%. A model with bounded outcome variance would grow certain at a high threshold instead, its value of information peaking near the tie then decaying.

Expected net benefit of sampling

Per-person EVSI becomes a funding decision once scaled to the population it informs. Multiply by the beneficiary population, 2,000 patients a year over 10 years discounted at 3.5%, then subtract the study cost, a 300,000 setup plus 6,000 per participant (two arms, so 2n). The difference is the expected net benefit of sampling (ENBS), and ENBS at or above zero is the condition for running the study. It peaks at an interior size: smaller is underpowered, larger buys less EVSI than it costs.

i30 = int(np.where(grid == WTP)[0][0])
evsi = pd.Series({n: fan[n][i30] for n in sizes})  # EVSI at the reference threshold
years = np.arange(10)
beneficiaries = 2_000 * (1.035 ** -years).sum()  # discounted future patients
cost = 300_000 + 6_000 * 2 * np.array(sizes)  # 2n participants
enbs = pd.Series(beneficiaries * evsi.to_numpy() - cost, index=pd.Index(sizes, name="n per arm"))
best = enbs.idxmax()
print(f"optimal size: {best} per arm, ENBS {enbs[best]:,.0f}")
enbs.round(0)
optimal size: 200 per arm, ENBS 42,290,935
n per arm
25     35946386.0
50     39959462.0
100    42069547.0
200    42290935.0
400    40775364.0
800    36246797.0
dtype: float64
ax = enbs.plot(marker="o", xlabel="trial size (n per arm)", ylabel="ENBS")
ax.axhline(0, color="0.6", lw=0.8);
ax.plot(best, enbs[best], "r*", ms=14);

What these numbers say

EVPI is the ceiling: no research beats it. EVPPI ranks research targets, a ranking that shifts with the threshold. EVSI prices a concrete design against the uncertainty it can resolve, and ENBS weighs it against cost for a fund-or-not decision with a best sample size. Every estimate above lands within about one percent of its closed form at 100,000 iterations. Next: the calibration workflow feeds a calibrated posterior into the same pipeline.