prime_py / src /primepy /analysis.py
sdilbaz's picture
Add PRIME UI and analysis code with Turkey data
7b1174f
Raw History Blame Contribute Delete
6 kB
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, List
import numpy as np
import pandas as pd
from scipy.stats import rankdata
@dataclass(slots=True)
class SobolSpec:
params: List[str]
bounds: List[List[float]] # [[low, high], ...]
n_base: int
seed: int = 1
def sobol_sensitivity(
spec: SobolSpec, model: Callable[[np.ndarray], float]
) -> pd.DataFrame:
try:
from SALib.analyze import sobol
from SALib.sample import saltelli
except ImportError as exc:
raise RuntimeError(
"SALib is required for Sobol analysis. Install with: uv add SALib"
) from exc
problem = {
"num_vars": len(spec.params),
"names": spec.params,
"bounds": spec.bounds,
}
# SALib versions < 1.4 do not support the 'seed' kwarg. Seed numpy instead for compatibility.
rng_state = np.random.get_state()
np.random.seed(spec.seed)
try:
X = saltelli.sample(problem, spec.n_base, calc_second_order=False)
finally:
np.random.set_state(rng_state)
Y = np.zeros(X.shape[0])
for i, row in enumerate(X):
Y[i] = float(model(row))
Si = sobol.analyze(problem, Y, calc_second_order=False, print_to_console=False)
out = pd.DataFrame(
{
"Parameter": spec.params,
"S1": Si["S1"],
"S1_conf": Si["S1_conf"],
"ST": Si["ST"],
"ST_conf": Si["ST_conf"],
}
)
return out
def ceac(
effect_samples: np.ndarray,
wtp_grid: np.ndarray,
cost_samples: np.ndarray | None = None,
) -> pd.DataFrame:
"""Compute CEAC from samples.
effect_samples: delta effect (e.g., cases averted); positive is better
cost_samples: delta cost; defaults to 0
WTP grid: values to apply to effect to form NMB = WTP * effect - cost
"""
if cost_samples is None:
cost_samples = np.zeros_like(effect_samples)
out = []
for w in wtp_grid:
nmb = w * effect_samples - cost_samples
prob_ce = float(np.mean(nmb > 0))
out.append({"WTP": float(w), "PrCostEffective": prob_ce})
return pd.DataFrame(out)
@dataclass(slots=True)
class MorrisSpec:
params: List[str]
bounds: List[List[float]] # [[low, high], ...]
levels: int = 4
trajectories: int = 10
seed: int = 1
def morris_sensitivity(
spec: MorrisSpec, model: Callable[[np.ndarray], float]
) -> pd.DataFrame:
"""Run Morris screening (elementary effects) if SALib is available.
Requires a real scalar model. Returns per-parameter mu_star and sigma.
"""
try:
from SALib.analyze import morris as morris_analyze
from SALib.sample import morris as morris_sample
except ImportError as exc:
raise RuntimeError(
"SALib is required for Morris analysis. Install with: uv add SALib"
) from exc
problem = {
"num_vars": len(spec.params),
"names": spec.params,
"bounds": spec.bounds,
}
rng_state = np.random.get_state()
np.random.seed(spec.seed)
try:
X = morris_sample.sample(
problem,
N=spec.trajectories,
num_levels=spec.levels,
optimal_trajectories=None,
)
finally:
np.random.set_state(rng_state)
Y = np.zeros(X.shape[0])
for i, row in enumerate(X):
Y[i] = float(model(row))
Si = morris_analyze.analyze(problem, X, Y, print_to_console=False)
return pd.DataFrame(
{"Parameter": spec.params, "mu_star": Si["mu_star"], "sigma": Si["sigma"]}
)
def prcc(X: np.ndarray, y: np.ndarray, param_names: List[str]) -> pd.DataFrame:
"""Partial Rank Correlation Coefficients.
X: shape (n_samples, n_params) of input samples
y: shape (n_samples,) scalar outputs
Returns PRCC per parameter with signs matching monotone associations.
"""
if X.ndim != 2 or y.ndim != 1 or X.shape[0] != y.shape[0]:
raise ValueError("Shapes must be X:(n, p), y:(n,).")
if X.shape[1] != len(param_names):
raise ValueError("param_names length must match X columns.")
# Rank-transform
Xr = np.column_stack([rankdata(X[:, j]) for j in range(X.shape[1])]).astype(float)
yr = rankdata(y).astype(float)
# Correlation matrix of [Xr | yr]
Z = np.column_stack([Xr, yr])
C = np.corrcoef(Z, rowvar=False)
P = np.linalg.pinv(C) # precision matrix
prcc_vals = []
for j in range(X.shape[1]):
# -P_jy / sqrt(P_jj * P_yy)
val = -P[j, -1] / np.sqrt(P[j, j] * P[-1, -1])
prcc_vals.append(val)
return pd.DataFrame({"Parameter": param_names, "PRCC": prcc_vals})
def cost_effectiveness_plane(
delta_effect: np.ndarray, delta_cost: np.ndarray
) -> pd.DataFrame:
"""Return a DataFrame for the cost-effectiveness plane scatter plot.
Inputs are sample-wise differences (strategy − comparator).
"""
if delta_effect.shape != delta_cost.shape:
raise ValueError("delta_effect and delta_cost must have the same shape")
return pd.DataFrame(
{
"DeltaEffect": delta_effect.astype(float),
"DeltaCost": delta_cost.astype(float),
}
)
def evpi(nmb_samples: np.ndarray) -> float:
"""Compute EVPI at a single WTP from NMB samples (n_samples, n_strategies)."""
exp_max = float(np.mean(np.max(nmb_samples, axis=1)))
max_exp = float(np.max(np.mean(nmb_samples, axis=0)))
return exp_max - max_exp
def evppi(
samples: pd.DataFrame, outcome_col: str, group_cols: List[str]
) -> pd.DataFrame:
# Simple EVPI-like subgroup value (not full Bayesian) as illustration
# EVPI = E[max_j E(NMB|j)] - max_j E(NMB) over decisions j; here we show variance reduction by grouping
overall = samples[outcome_col].mean()
grouped = samples.groupby(group_cols)[outcome_col].mean().reset_index(name="Mean")
return grouped.assign(Overall=overall, Delta=lambda df: df["Mean"] - overall)