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 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"]