heormodel 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- heormodel/__init__.py +43 -0
- heormodel/calibrate/__init__.py +16 -0
- heormodel/calibrate/abc.py +182 -0
- heormodel/cea/__init__.py +25 -0
- heormodel/cea/ceac.py +130 -0
- heormodel/cea/frontier.py +134 -0
- heormodel/cea/nb.py +62 -0
- heormodel/dsa/__init__.py +18 -0
- heormodel/dsa/design.py +182 -0
- heormodel/models/__init__.py +26 -0
- heormodel/models/_accrual.py +142 -0
- heormodel/models/des.py +497 -0
- heormodel/models/markov.py +298 -0
- heormodel/models/microsim.py +442 -0
- heormodel/models/outcomes.py +221 -0
- heormodel/models/protocol.py +51 -0
- heormodel/params/__init__.py +37 -0
- heormodel/params/distributions.py +307 -0
- heormodel/params/inputs.py +174 -0
- heormodel/params/mix.py +90 -0
- heormodel/params/sampling.py +211 -0
- heormodel/py.typed +0 -0
- heormodel/report/__init__.py +31 -0
- heormodel/report/plots.py +403 -0
- heormodel/report/provenance.py +141 -0
- heormodel/run/__init__.py +11 -0
- heormodel/run/_progress.py +134 -0
- heormodel/run/diagnostics.py +49 -0
- heormodel/run/runner.py +209 -0
- heormodel/run/seeds.py +81 -0
- heormodel/voi/__init__.py +27 -0
- heormodel/voi/_metamodel.py +89 -0
- heormodel/voi/evpi.py +51 -0
- heormodel/voi/evppi.py +107 -0
- heormodel/voi/evsi.py +134 -0
- heormodel-0.6.0.dist-info/METADATA +154 -0
- heormodel-0.6.0.dist-info/RECORD +39 -0
- heormodel-0.6.0.dist-info/WHEEL +5 -0
- heormodel-0.6.0.dist-info/top_level.txt +1 -0
heormodel/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""heval: health economic evaluation in Python.
|
|
2
|
+
|
|
3
|
+
One parameter draw matrix flows through swappable model engines into a
|
|
4
|
+
shared analysis layer. Engines differ internally but share a contract on
|
|
5
|
+
their outputs, the `Outcomes` schema indexed by
|
|
6
|
+
``(strategy, iteration)``, which makes cost-effectiveness and
|
|
7
|
+
value-of-information analysis engine-agnostic. Outputs from any external
|
|
8
|
+
model enter the same pipeline via `as_outcomes`.
|
|
9
|
+
|
|
10
|
+
Subpackages:
|
|
11
|
+
- `heval.params`: distributions and correlated PSA sampling
|
|
12
|
+
- `heval.models`: engines behind the output contract
|
|
13
|
+
- `heval.run`: seeds, run loop, bring-your-own-outputs ingestion
|
|
14
|
+
- `heval.cea`: incremental analysis, frontier, NMB/NHB, CEAC/CEAF
|
|
15
|
+
- `heval.dsa`: one-way, one-at-a-time, and grid deterministic sensitivity designs
|
|
16
|
+
- `heval.voi`: EVPI, EVPPI, EVSI
|
|
17
|
+
- `heval.calibrate`: ABC calibration (optional ``pyabc`` extra)
|
|
18
|
+
- `heval.report`: plots and reproducibility scaffolding
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import PackageNotFoundError
|
|
22
|
+
from importlib.metadata import version as _version
|
|
23
|
+
|
|
24
|
+
from heormodel.models import ModelEngine, ModelFn, Outcomes
|
|
25
|
+
from heormodel.params import ParameterSet, mix_draws
|
|
26
|
+
from heormodel.run import SeedManager, as_outcomes, run_psa
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = _version("heval")
|
|
30
|
+
except PackageNotFoundError: # pragma: no cover - not installed, e.g. running from source
|
|
31
|
+
__version__ = "0.0.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"ModelEngine",
|
|
35
|
+
"ModelFn",
|
|
36
|
+
"Outcomes",
|
|
37
|
+
"ParameterSet",
|
|
38
|
+
"SeedManager",
|
|
39
|
+
"__version__",
|
|
40
|
+
"as_outcomes",
|
|
41
|
+
"mix_draws",
|
|
42
|
+
"run_psa",
|
|
43
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Model calibration (`heval.calibrate`).
|
|
2
|
+
|
|
3
|
+
Approximate Bayesian computation via ``pyabc`` (optional dependency):
|
|
4
|
+
translate ``heval`` priors, calibrate a simulator to observed targets, and
|
|
5
|
+
get back a posterior parameter draw matrix that flows into the standard
|
|
6
|
+
PSA pipeline through the shared iteration index.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from heormodel.calibrate.abc import (
|
|
10
|
+
CalibrationResult,
|
|
11
|
+
TargetSimulator,
|
|
12
|
+
abc_calibrate,
|
|
13
|
+
to_pyabc_prior,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = ["CalibrationResult", "TargetSimulator", "abc_calibrate", "to_pyabc_prior"]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Model calibration via approximate Bayesian computation (pyabc).
|
|
2
|
+
|
|
3
|
+
Bridges ``heval`` parameter specs to ``pyabc`` priors, runs ABC-SMC against
|
|
4
|
+
observed calibration targets, and returns the posterior as an
|
|
5
|
+
equally-weighted parameter draw matrix carrying the standard ``iteration``
|
|
6
|
+
index, so calibrated draws flow through `heval.run.run_psa` and the
|
|
7
|
+
analysis layer exactly like draws from `heval.params.ParameterSet.sample`.
|
|
8
|
+
|
|
9
|
+
``pyabc`` is an optional dependency: install with ``uv pip install
|
|
10
|
+
'heval[calibration]'``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tempfile
|
|
16
|
+
from collections.abc import Callable, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
from heormodel.params.distributions import (
|
|
25
|
+
Beta,
|
|
26
|
+
Dirichlet,
|
|
27
|
+
Distribution,
|
|
28
|
+
Gamma,
|
|
29
|
+
LogNormal,
|
|
30
|
+
Normal,
|
|
31
|
+
Uniform,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: A calibration simulator: parameter values -> simulated calibration targets.
|
|
35
|
+
TargetSimulator = Callable[[dict[str, float]], dict[str, float]]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _require_pyabc() -> Any:
|
|
39
|
+
try:
|
|
40
|
+
import pyabc
|
|
41
|
+
except ImportError as err: # pragma: no cover
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"Calibration requires pyabc; install it with uv pip install 'heval[calibration]'."
|
|
44
|
+
) from err
|
|
45
|
+
return pyabc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_pyabc_prior(distributions: Mapping[str, Distribution | Dirichlet]) -> Any:
|
|
49
|
+
"""Translate ``heval`` distribution specs into a ``pyabc`` prior.
|
|
50
|
+
|
|
51
|
+
Supported: `Beta`, `Gamma`, `LogNormal`,
|
|
52
|
+
`Normal`, `Uniform`. Dirichlet and Fixed parameters cannot
|
|
53
|
+
be calibrated directly; hold them constant inside the simulator instead.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
>>> from heormodel.calibrate import to_pyabc_prior # doctest: +SKIP
|
|
57
|
+
>>> from heormodel.params import Beta
|
|
58
|
+
>>> prior = to_pyabc_prior({"p": Beta(2, 8)}) # doctest: +SKIP
|
|
59
|
+
"""
|
|
60
|
+
pyabc = _require_pyabc()
|
|
61
|
+
rvs: dict[str, Any] = {}
|
|
62
|
+
for name, dist in distributions.items():
|
|
63
|
+
if isinstance(dist, Beta):
|
|
64
|
+
rvs[name] = pyabc.RV("beta", dist.alpha, dist.beta)
|
|
65
|
+
elif isinstance(dist, Gamma):
|
|
66
|
+
rvs[name] = pyabc.RV("gamma", dist.shape, scale=dist.scale)
|
|
67
|
+
elif isinstance(dist, LogNormal):
|
|
68
|
+
rvs[name] = pyabc.RV("lognorm", dist.sigma, scale=float(np.exp(dist.mu)))
|
|
69
|
+
elif isinstance(dist, Normal):
|
|
70
|
+
rvs[name] = pyabc.RV("norm", dist.mean_, dist.sd_)
|
|
71
|
+
elif isinstance(dist, Uniform):
|
|
72
|
+
rvs[name] = pyabc.RV("uniform", dist.low, dist.high - dist.low)
|
|
73
|
+
else:
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"Parameter {name!r}: {type(dist).__name__} priors are not supported "
|
|
76
|
+
"for ABC calibration; hold it constant inside the simulator."
|
|
77
|
+
)
|
|
78
|
+
return pyabc.Distribution(**rvs)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CalibrationResult:
|
|
83
|
+
"""Posterior draws and diagnostics from an ABC-SMC calibration.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
posterior: Equally-weighted posterior draw matrix with a
|
|
87
|
+
``RangeIndex`` named ``iteration``, ready for
|
|
88
|
+
`heval.run.run_psa`.
|
|
89
|
+
weighted: The raw weighted particle population (columns = parameters,
|
|
90
|
+
plus a ``weight`` column).
|
|
91
|
+
n_populations: Number of ABC-SMC populations run.
|
|
92
|
+
final_epsilon: Acceptance threshold of the final population.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
posterior: pd.DataFrame
|
|
96
|
+
weighted: pd.DataFrame
|
|
97
|
+
n_populations: int
|
|
98
|
+
final_epsilon: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def abc_calibrate(
|
|
102
|
+
simulator: TargetSimulator,
|
|
103
|
+
priors: Mapping[str, Distribution | Dirichlet],
|
|
104
|
+
observed: Mapping[str, float],
|
|
105
|
+
*,
|
|
106
|
+
population_size: int = 200,
|
|
107
|
+
max_populations: int = 8,
|
|
108
|
+
min_epsilon: float = 0.0,
|
|
109
|
+
n_posterior: int | None = None,
|
|
110
|
+
seed: int | None = None,
|
|
111
|
+
db_path: str | Path | None = None,
|
|
112
|
+
) -> CalibrationResult:
|
|
113
|
+
"""Calibrate model parameters to observed targets with ABC-SMC.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
simulator: Maps a parameter dict to simulated calibration targets
|
|
117
|
+
(same keys as ``observed``).
|
|
118
|
+
priors: Parameter priors as ``heval`` distribution specs.
|
|
119
|
+
observed: Observed calibration target values.
|
|
120
|
+
population_size: Particles per ABC-SMC population.
|
|
121
|
+
max_populations: Maximum number of populations.
|
|
122
|
+
min_epsilon: Stop once the acceptance threshold reaches this value.
|
|
123
|
+
n_posterior: Rows in the returned equally-weighted posterior matrix
|
|
124
|
+
(default: the final population size).
|
|
125
|
+
seed: Seed for the weighted-to-equal resampling step. (The ABC run
|
|
126
|
+
itself uses pyabc's internal randomness.)
|
|
127
|
+
db_path: Where to store pyabc's bookkeeping database (default: a
|
|
128
|
+
temporary file).
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
A `CalibrationResult` whose ``posterior`` plugs directly into
|
|
132
|
+
the PSA pipeline.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
>>> from heormodel.calibrate import abc_calibrate # doctest: +SKIP
|
|
136
|
+
>>> from heormodel.params import Uniform
|
|
137
|
+
>>> result = abc_calibrate( # doctest: +SKIP
|
|
138
|
+
... simulator=lambda p: {"prevalence": p["risk"] * 0.5},
|
|
139
|
+
... priors={"risk": Uniform(0.0, 1.0)},
|
|
140
|
+
... observed={"prevalence": 0.15},
|
|
141
|
+
... )
|
|
142
|
+
"""
|
|
143
|
+
pyabc = _require_pyabc()
|
|
144
|
+
from pyabc.sampler import SingleCoreSampler
|
|
145
|
+
|
|
146
|
+
prior = to_pyabc_prior(priors)
|
|
147
|
+
keys = sorted(observed)
|
|
148
|
+
|
|
149
|
+
def model(parameter: Mapping[str, float]) -> dict[str, float]:
|
|
150
|
+
return dict(simulator(dict(parameter)))
|
|
151
|
+
|
|
152
|
+
distance = pyabc.PNormDistance(p=2)
|
|
153
|
+
abc = pyabc.ABCSMC(
|
|
154
|
+
model,
|
|
155
|
+
prior,
|
|
156
|
+
distance,
|
|
157
|
+
population_size=population_size,
|
|
158
|
+
sampler=SingleCoreSampler(),
|
|
159
|
+
)
|
|
160
|
+
if db_path is None:
|
|
161
|
+
db_file = Path(tempfile.mkdtemp()) / "heval_abc.db"
|
|
162
|
+
else:
|
|
163
|
+
db_file = Path(db_path)
|
|
164
|
+
abc.new("sqlite:///" + str(db_file), {k: float(observed[k]) for k in keys})
|
|
165
|
+
history = abc.run(minimum_epsilon=min_epsilon, max_nr_populations=max_populations)
|
|
166
|
+
|
|
167
|
+
particles, weights = history.get_distribution()
|
|
168
|
+
weighted = particles.copy()
|
|
169
|
+
weighted["weight"] = weights
|
|
170
|
+
rng = np.random.default_rng(seed)
|
|
171
|
+
n_out = n_posterior or len(particles)
|
|
172
|
+
picks = rng.choice(len(particles), size=n_out, p=np.asarray(weights) / np.sum(weights))
|
|
173
|
+
posterior = particles.iloc[picks].reset_index(drop=True)
|
|
174
|
+
posterior.index = pd.RangeIndex(n_out, name="iteration")
|
|
175
|
+
posterior.columns.name = None
|
|
176
|
+
epsilons = history.get_all_populations()["epsilon"]
|
|
177
|
+
return CalibrationResult(
|
|
178
|
+
posterior=posterior,
|
|
179
|
+
weighted=weighted,
|
|
180
|
+
n_populations=history.max_t + 1,
|
|
181
|
+
final_epsilon=float(epsilons.iloc[-1]),
|
|
182
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cost-effectiveness analysis (`heval.cea`).
|
|
2
|
+
|
|
3
|
+
Engine-agnostic decision analysis on the standard outcome schema:
|
|
4
|
+
incremental analysis with dominance and extended dominance, the efficiency
|
|
5
|
+
frontier, net monetary/health benefit, acceptability curves (CEAC), the
|
|
6
|
+
acceptability frontier (CEAF), and cost-effectiveness-plane data.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from heormodel.cea.ceac import ce_plane, ceac, ceaf
|
|
10
|
+
from heormodel.cea.frontier import STATUS_D, STATUS_ED, STATUS_ND, frontier, icer_table
|
|
11
|
+
from heormodel.cea.nb import expected_nmb, nhb, nmb
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"STATUS_D",
|
|
15
|
+
"STATUS_ED",
|
|
16
|
+
"STATUS_ND",
|
|
17
|
+
"ce_plane",
|
|
18
|
+
"ceac",
|
|
19
|
+
"ceaf",
|
|
20
|
+
"expected_nmb",
|
|
21
|
+
"frontier",
|
|
22
|
+
"icer_table",
|
|
23
|
+
"nhb",
|
|
24
|
+
"nmb",
|
|
25
|
+
]
|
heormodel/cea/ceac.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Cost-effectiveness acceptability curves, frontier, and CE-plane data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from numpy.typing import ArrayLike
|
|
10
|
+
|
|
11
|
+
from heormodel.models.outcomes import Outcomes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ceac(
|
|
15
|
+
outcomes: Outcomes, wtp: ArrayLike | Sequence[float], *, effect: str | None = None
|
|
16
|
+
) -> pd.DataFrame:
|
|
17
|
+
"""Cost-effectiveness acceptability curve for every strategy.
|
|
18
|
+
|
|
19
|
+
For each willingness-to-pay value, the probability (share of PSA
|
|
20
|
+
iterations) that each strategy has the highest net monetary benefit.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
outcomes: Standard PSA outcomes.
|
|
24
|
+
wtp: Grid of willingness-to-pay values.
|
|
25
|
+
effect: Effect column (default: the primary effect).
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
DataFrame indexed by ``wtp`` with one probability column per
|
|
29
|
+
strategy; rows sum to 1.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> import pandas as pd
|
|
33
|
+
>>> from heormodel.models import Outcomes
|
|
34
|
+
>>> from heormodel.cea import ceac
|
|
35
|
+
>>> c = pd.DataFrame({"A": [0.0, 0.0], "B": [10.0, 10.0]})
|
|
36
|
+
>>> e = pd.DataFrame({"A": [0.0, 0.0], "B": [1.0, -1.0]})
|
|
37
|
+
>>> float(ceac(Outcomes.from_wide(c, e), wtp=[100.0]).loc[100.0, "B"])
|
|
38
|
+
0.5
|
|
39
|
+
"""
|
|
40
|
+
wtp_grid = np.atleast_1d(np.asarray(wtp, dtype=np.float64))
|
|
41
|
+
costs = outcomes.costs_wide().to_numpy(dtype=np.float64)
|
|
42
|
+
effects = outcomes.effects_wide(effect).to_numpy(dtype=np.float64)
|
|
43
|
+
n, d = costs.shape
|
|
44
|
+
probs = np.empty((len(wtp_grid), d), dtype=np.float64)
|
|
45
|
+
for i, lam in enumerate(wtp_grid):
|
|
46
|
+
winners = np.argmax(lam * effects - costs, axis=1)
|
|
47
|
+
probs[i] = np.bincount(winners, minlength=d) / n
|
|
48
|
+
return pd.DataFrame(probs, index=pd.Index(wtp_grid, name="wtp"), columns=outcomes.strategies)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ceaf(
|
|
52
|
+
outcomes: Outcomes, wtp: ArrayLike | Sequence[float], *, effect: str | None = None
|
|
53
|
+
) -> pd.DataFrame:
|
|
54
|
+
"""Cost-effectiveness acceptability frontier.
|
|
55
|
+
|
|
56
|
+
At each willingness-to-pay value, identifies the strategy with the
|
|
57
|
+
highest **expected** NMB (the optimal choice for a risk-neutral decision
|
|
58
|
+
maker) and reports its acceptability-curve probability.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
DataFrame indexed by ``wtp`` with columns ``strategy`` (the optimal
|
|
62
|
+
strategy) and ``prob`` (its probability of being cost-effective).
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> import pandas as pd
|
|
66
|
+
>>> from heormodel.models import Outcomes
|
|
67
|
+
>>> from heormodel.cea import ceaf
|
|
68
|
+
>>> c = pd.DataFrame({"A": [0.0, 0.0], "B": [10.0, 10.0]})
|
|
69
|
+
>>> e = pd.DataFrame({"A": [0.0, 0.0], "B": [1.0, 1.0]})
|
|
70
|
+
>>> ceaf(Outcomes.from_wide(c, e), wtp=[100.0]).loc[100.0, "strategy"]
|
|
71
|
+
'B'
|
|
72
|
+
"""
|
|
73
|
+
wtp_grid = np.atleast_1d(np.asarray(wtp, dtype=np.float64))
|
|
74
|
+
curve = ceac(outcomes, wtp_grid, effect=effect)
|
|
75
|
+
costs = outcomes.costs_wide().to_numpy(dtype=np.float64)
|
|
76
|
+
effects = outcomes.effects_wide(effect).to_numpy(dtype=np.float64)
|
|
77
|
+
mean_cost = costs.mean(axis=0)
|
|
78
|
+
mean_eff = effects.mean(axis=0)
|
|
79
|
+
strategies = np.asarray(outcomes.strategies, dtype=object)
|
|
80
|
+
optimal = [strategies[int(np.argmax(lam * mean_eff - mean_cost))] for lam in wtp_grid]
|
|
81
|
+
prob = [curve.iloc[i][opt] for i, opt in enumerate(optimal)]
|
|
82
|
+
return pd.DataFrame({"strategy": optimal, "prob": prob}, index=pd.Index(wtp_grid, name="wtp"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ce_plane(
|
|
86
|
+
outcomes: Outcomes, *, comparator: str | None = None, effect: str | None = None
|
|
87
|
+
) -> pd.DataFrame:
|
|
88
|
+
"""Incremental cost and effect per iteration versus a comparator.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
outcomes: Standard PSA outcomes.
|
|
92
|
+
comparator: Reference strategy (default: the first strategy).
|
|
93
|
+
effect: Effect column (default: the primary effect).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tidy DataFrame with columns ``strategy``, ``iteration``,
|
|
97
|
+
``inc_cost`` and ``inc_effect`` for every non-comparator strategy,
|
|
98
|
+
ready to scatter on the cost-effectiveness plane.
|
|
99
|
+
|
|
100
|
+
Example:
|
|
101
|
+
>>> import pandas as pd
|
|
102
|
+
>>> from heormodel.models import Outcomes
|
|
103
|
+
>>> from heormodel.cea import ce_plane
|
|
104
|
+
>>> c = pd.DataFrame({"A": [0.0], "B": [10.0]})
|
|
105
|
+
>>> e = pd.DataFrame({"A": [0.0], "B": [0.5]})
|
|
106
|
+
>>> float(ce_plane(Outcomes.from_wide(c, e))["inc_cost"][0])
|
|
107
|
+
10.0
|
|
108
|
+
"""
|
|
109
|
+
ref = comparator or outcomes.strategies[0]
|
|
110
|
+
if ref not in outcomes.strategies:
|
|
111
|
+
raise KeyError(f"Unknown comparator strategy: {ref!r}.")
|
|
112
|
+
costs = outcomes.costs_wide()
|
|
113
|
+
effects = outcomes.effects_wide(effect)
|
|
114
|
+
frames = []
|
|
115
|
+
for s in outcomes.strategies:
|
|
116
|
+
if s == ref:
|
|
117
|
+
continue
|
|
118
|
+
frames.append(
|
|
119
|
+
pd.DataFrame(
|
|
120
|
+
{
|
|
121
|
+
"strategy": s,
|
|
122
|
+
"iteration": costs.index,
|
|
123
|
+
"inc_cost": (costs[s] - costs[ref]).to_numpy(),
|
|
124
|
+
"inc_effect": (effects[s] - effects[ref]).to_numpy(),
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
if not frames:
|
|
129
|
+
raise ValueError("ce_plane needs at least two strategies.")
|
|
130
|
+
return pd.concat(frames, ignore_index=True)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Incremental cost-effectiveness analysis on the efficiency frontier.
|
|
2
|
+
|
|
3
|
+
Implements the standard decision-analytic algorithm: order strategies by
|
|
4
|
+
cost, remove strongly dominated strategies (more costly and no more
|
|
5
|
+
effective than another), then iteratively remove extendedly dominated
|
|
6
|
+
strategies (whose ICER exceeds that of the next more effective strategy)
|
|
7
|
+
until ICERs increase monotonically along the frontier.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from heormodel.models.outcomes import Outcomes
|
|
16
|
+
|
|
17
|
+
#: Status labels: not dominated (on the frontier), dominated, extendedly dominated.
|
|
18
|
+
STATUS_ND = "ND"
|
|
19
|
+
STATUS_D = "D"
|
|
20
|
+
STATUS_ED = "ED"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mean_table(source: Outcomes | pd.DataFrame, effect: str | None) -> pd.DataFrame:
|
|
24
|
+
if isinstance(source, Outcomes):
|
|
25
|
+
eff = effect or source.effect
|
|
26
|
+
summary = source.summary()
|
|
27
|
+
return pd.DataFrame({"cost": summary["cost"], "effect": summary[eff]})
|
|
28
|
+
df = source.copy()
|
|
29
|
+
eff = effect or ("effect" if "effect" in df.columns else "qaly")
|
|
30
|
+
missing = [c for c in ("cost", eff) if c not in df.columns]
|
|
31
|
+
if missing:
|
|
32
|
+
raise ValueError(f"Mean outcome table is missing columns: {missing}.")
|
|
33
|
+
return pd.DataFrame({"cost": df["cost"], "effect": df[eff]})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def icer_table(source: Outcomes | pd.DataFrame, *, effect: str | None = None) -> pd.DataFrame:
|
|
37
|
+
"""Full incremental analysis: dominance, extended dominance, and ICERs.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
source: PSA `Outcomes` (means are taken per strategy) or a
|
|
41
|
+
per-strategy mean table indexed by strategy with columns
|
|
42
|
+
``cost`` and the effect column.
|
|
43
|
+
effect: Effect column name (defaults to the outcomes' primary
|
|
44
|
+
effect, or ``"effect"`` for plain tables).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
DataFrame indexed by strategy, sorted by cost, with columns
|
|
48
|
+
``cost``, ``effect``, ``inc_cost``, ``inc_effect``, ``icer`` and
|
|
49
|
+
``status`` (``"ND"`` on the frontier, ``"D"`` strongly dominated,
|
|
50
|
+
``"ED"`` extendedly dominated). ICERs are computed between adjacent
|
|
51
|
+
frontier strategies; the cheapest frontier strategy has no ICER.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> import pandas as pd
|
|
55
|
+
>>> from heormodel.cea import icer_table
|
|
56
|
+
>>> means = pd.DataFrame(
|
|
57
|
+
... {"cost": [0.0, 100.0, 400.0], "effect": [0.0, 0.5, 1.0]},
|
|
58
|
+
... index=["A", "B", "D"],
|
|
59
|
+
... )
|
|
60
|
+
>>> t = icer_table(means)
|
|
61
|
+
>>> float(t.loc["D", "icer"])
|
|
62
|
+
600.0
|
|
63
|
+
"""
|
|
64
|
+
means = _mean_table(source, effect)
|
|
65
|
+
means = means.sort_values(["cost", "effect"], ascending=[True, False])
|
|
66
|
+
cost = means["cost"].to_numpy(dtype=np.float64)
|
|
67
|
+
eff = means["effect"].to_numpy(dtype=np.float64)
|
|
68
|
+
n = len(means)
|
|
69
|
+
status = np.array([STATUS_ND] * n, dtype=object)
|
|
70
|
+
|
|
71
|
+
# strong dominance: another strategy is no more costly and no less
|
|
72
|
+
# effective, strictly better on at least one axis; exact duplicates keep
|
|
73
|
+
# only the first occurrence in cost order
|
|
74
|
+
for i in range(n):
|
|
75
|
+
for j in range(n):
|
|
76
|
+
if i == j:
|
|
77
|
+
continue
|
|
78
|
+
weak = cost[j] <= cost[i] and eff[j] >= eff[i]
|
|
79
|
+
strict = cost[j] < cost[i] or eff[j] > eff[i]
|
|
80
|
+
duplicate = j < i and cost[j] == cost[i] and eff[j] == eff[i]
|
|
81
|
+
if (weak and strict) or duplicate:
|
|
82
|
+
status[i] = STATUS_D
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
# extended dominance: remove strategies until ICERs are monotone
|
|
86
|
+
while True:
|
|
87
|
+
nd = [i for i in range(n) if status[i] == STATUS_ND]
|
|
88
|
+
if len(nd) < 3:
|
|
89
|
+
break
|
|
90
|
+
icers = [
|
|
91
|
+
(cost[nd[k]] - cost[nd[k - 1]]) / (eff[nd[k]] - eff[nd[k - 1]])
|
|
92
|
+
for k in range(1, len(nd))
|
|
93
|
+
]
|
|
94
|
+
for k in range(len(icers) - 1):
|
|
95
|
+
if icers[k] > icers[k + 1]:
|
|
96
|
+
status[nd[k + 1]] = STATUS_ED
|
|
97
|
+
break
|
|
98
|
+
else:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
inc_cost = np.full(n, np.nan)
|
|
102
|
+
inc_eff = np.full(n, np.nan)
|
|
103
|
+
icer = np.full(n, np.nan)
|
|
104
|
+
nd = [i for i in range(n) if status[i] == STATUS_ND]
|
|
105
|
+
for k in range(1, len(nd)):
|
|
106
|
+
prev, cur = nd[k - 1], nd[k]
|
|
107
|
+
inc_cost[cur] = cost[cur] - cost[prev]
|
|
108
|
+
inc_eff[cur] = eff[cur] - eff[prev]
|
|
109
|
+
icer[cur] = inc_cost[cur] / inc_eff[cur]
|
|
110
|
+
|
|
111
|
+
result = means.copy()
|
|
112
|
+
result["inc_cost"] = inc_cost
|
|
113
|
+
result["inc_effect"] = inc_eff
|
|
114
|
+
result["icer"] = icer
|
|
115
|
+
result["status"] = status
|
|
116
|
+
result.index.name = "strategy"
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def frontier(source: Outcomes | pd.DataFrame, *, effect: str | None = None) -> list[str]:
|
|
121
|
+
"""Strategies on the cost-effectiveness efficiency frontier, cheapest first.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
>>> import pandas as pd
|
|
125
|
+
>>> from heormodel.cea import frontier
|
|
126
|
+
>>> means = pd.DataFrame(
|
|
127
|
+
... {"cost": [0.0, 10.0, 5.0], "effect": [0.0, 1.0, -1.0]},
|
|
128
|
+
... index=["A", "B", "C"],
|
|
129
|
+
... )
|
|
130
|
+
>>> frontier(means)
|
|
131
|
+
['A', 'B']
|
|
132
|
+
"""
|
|
133
|
+
table = icer_table(source, effect=effect)
|
|
134
|
+
return [str(s) for s in table.index[table["status"] == STATUS_ND]]
|
heormodel/cea/nb.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Net monetary and net health benefit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from heormodel.models.outcomes import Outcomes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def nmb(outcomes: Outcomes, wtp: float, *, effect: str | None = None) -> pd.DataFrame:
|
|
11
|
+
"""Net monetary benefit per iteration and strategy: ``wtp * effect - cost``.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
outcomes: Standard PSA outcomes.
|
|
15
|
+
wtp: Willingness to pay per unit of effect.
|
|
16
|
+
effect: Effect column (default: the primary effect).
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
DataFrame (iterations x strategies) of NMB values.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> import pandas as pd
|
|
23
|
+
>>> from heormodel.models import Outcomes
|
|
24
|
+
>>> from heormodel.cea import nmb
|
|
25
|
+
>>> c = pd.DataFrame({"A": [100.0]})
|
|
26
|
+
>>> e = pd.DataFrame({"A": [0.01]})
|
|
27
|
+
>>> float(nmb(Outcomes.from_wide(c, e), wtp=50_000)["A"][0])
|
|
28
|
+
400.0
|
|
29
|
+
"""
|
|
30
|
+
return wtp * outcomes.effects_wide(effect) - outcomes.costs_wide()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def nhb(outcomes: Outcomes, wtp: float, *, effect: str | None = None) -> pd.DataFrame:
|
|
34
|
+
"""Net health benefit per iteration and strategy: ``effect - cost / wtp``.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
>>> import pandas as pd
|
|
38
|
+
>>> from heormodel.models import Outcomes
|
|
39
|
+
>>> from heormodel.cea import nhb
|
|
40
|
+
>>> c = pd.DataFrame({"A": [100.0]})
|
|
41
|
+
>>> e = pd.DataFrame({"A": [0.01]})
|
|
42
|
+
>>> float(nhb(Outcomes.from_wide(c, e), wtp=50_000)["A"][0])
|
|
43
|
+
0.008
|
|
44
|
+
"""
|
|
45
|
+
if wtp <= 0:
|
|
46
|
+
raise ValueError("wtp must be positive for net health benefit.")
|
|
47
|
+
return outcomes.effects_wide(effect) - outcomes.costs_wide() / wtp
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def expected_nmb(outcomes: Outcomes, wtp: float, *, effect: str | None = None) -> pd.Series:
|
|
51
|
+
"""Expected (mean over iterations) NMB per strategy.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
>>> import pandas as pd
|
|
55
|
+
>>> from heormodel.models import Outcomes
|
|
56
|
+
>>> from heormodel.cea import expected_nmb
|
|
57
|
+
>>> c = pd.DataFrame({"A": [100.0, 200.0]})
|
|
58
|
+
>>> e = pd.DataFrame({"A": [0.01, 0.01]})
|
|
59
|
+
>>> float(expected_nmb(Outcomes.from_wide(c, e), wtp=50_000)["A"])
|
|
60
|
+
350.0
|
|
61
|
+
"""
|
|
62
|
+
return nmb(outcomes, wtp, effect=effect).mean(axis=0)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Deterministic sensitivity analysis (`heval.dsa`).
|
|
2
|
+
|
|
3
|
+
Where probabilistic analysis answers how uncertain a decision is,
|
|
4
|
+
deterministic sensitivity analysis (DSA) answers which parameters move the
|
|
5
|
+
result and by how much. Both read the same model. This module builds
|
|
6
|
+
scenario designs that run through `heval.run.run_psa` unchanged: a design is
|
|
7
|
+
a draw matrix whose rows are scenarios instead of random draws, paired with a
|
|
8
|
+
descriptor table that names what each scenario varied.
|
|
9
|
+
|
|
10
|
+
Three builders cover the standard forms. `one_way` sweeps a single parameter
|
|
11
|
+
across a set of values. `one_at_a_time` sweeps each parameter in turn, the
|
|
12
|
+
union of one-way sweeps that feeds a tornado diagram. `grid` takes the full
|
|
13
|
+
factorial of two or more parameters, which feeds a two-way heatmap.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from heormodel.dsa.design import Design, grid, one_at_a_time, one_way
|
|
17
|
+
|
|
18
|
+
__all__ = ["Design", "grid", "one_at_a_time", "one_way"]
|