Spaces:
Paused
Paused
Use Pharmpy VPCs and seed 42
Browse files- README.md +3 -1
- requirements.txt +2 -0
- services/inference/pff_service.py +19 -2
- services/inference/pharmpy_vpc.py +196 -0
README.md
CHANGED
|
@@ -27,7 +27,9 @@ does not consume visitors' daily GPU quota.
|
|
| 27 |
Public requests generate 20 individuals by default and are capped at 30
|
| 28 |
individuals. The demo fixes flow integration to eight Heun steps; solver
|
| 29 |
controls are not exposed. These limits are enforced by both the dashboard and
|
| 30 |
-
the inference service.
|
|
|
|
|
|
|
| 31 |
|
| 32 |
The named Gradio endpoints are `/health` and `/inference`. Inference is
|
| 33 |
serialized to keep memory use bounded; identical requests reuse a response
|
|
|
|
| 27 |
Public requests generate 20 individuals by default and are capped at 30
|
| 28 |
individuals. The demo fixes flow integration to eight Heun steps; solver
|
| 29 |
controls are not exposed. These limits are enforced by both the dashboard and
|
| 30 |
+
the inference service. Pharmpy computes each displayed VPC from that same
|
| 31 |
+
generated pool using design-matched resampled cohorts and equal-number time
|
| 32 |
+
bins.
|
| 33 |
|
| 34 |
The named Gradio endpoints are `/health` and `/inference`. Inference is
|
| 35 |
serialized to keep memory use bounded; identical requests reuse a response
|
requirements.txt
CHANGED
|
@@ -2,5 +2,7 @@ gradio>=6,<7
|
|
| 2 |
huggingface-hub>=1.0,<2
|
| 3 |
lightning>=2.6,<2.7
|
| 4 |
numpy>=1.26,<3
|
|
|
|
|
|
|
| 5 |
pyyaml>=6,<7
|
| 6 |
torch==2.8.0
|
|
|
|
| 2 |
huggingface-hub>=1.0,<2
|
| 3 |
lightning>=2.6,<2.7
|
| 4 |
numpy>=1.26,<3
|
| 5 |
+
pandas>=2.2,<4
|
| 6 |
+
pharmpy-core>=2.1,<2.2
|
| 7 |
pyyaml>=6,<7
|
| 8 |
torch==2.8.0
|
services/inference/pff_service.py
CHANGED
|
@@ -37,6 +37,11 @@ from pff_pk.inference.empirical import ( # noqa: E402
|
|
| 37 |
)
|
| 38 |
from pff_pk.inference.model import load_inference_model # noqa: E402
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
DEFAULT_CONFIG = PFF_ROOT / "configs" / "amarel_v6_protocol_counterfactual_phase2_sparse.yaml"
|
| 41 |
DEFAULT_CHECKPOINT = (
|
| 42 |
PFF_ROOT / "artifacts" / "checkpoints" / "lucid_marten_v6_step750"
|
|
@@ -58,6 +63,9 @@ DEFAULT_GENERATED_INDIVIDUALS = 20
|
|
| 58 |
MAX_GENERATED_INDIVIDUALS = 30
|
| 59 |
DEFAULT_FLOW_STEPS = 8
|
| 60 |
MAX_FLOW_STEPS = 16
|
|
|
|
|
|
|
|
|
|
| 61 |
PYTHIA_MODEL = "pythia"
|
| 62 |
PYTHIA_DOSE_MODEL = "pythia_dose"
|
| 63 |
DEFAULT_MODEL = PYTHIA_DOSE_MODEL
|
|
@@ -317,7 +325,7 @@ class ModelRuntime:
|
|
| 317 |
)
|
| 318 |
cpu_batch = union_query_batch(cpu_batch)
|
| 319 |
query_time = cpu_batch.target_time.numpy()[0, :, 0] * cohort["horizon"]
|
| 320 |
-
seed = bounded_integer(request.get("seed",
|
| 321 |
started = time.perf_counter()
|
| 322 |
chunks = []
|
| 323 |
for start in range(0, n_draws, batch_size):
|
|
@@ -334,6 +342,14 @@ class ModelRuntime:
|
|
| 334 |
physical = inverse_concentration(normalized, repeated).float().cpu().numpy()
|
| 335 |
chunks.append(physical[..., 0])
|
| 336 |
samples = np.concatenate(chunks, axis=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
elapsed = time.perf_counter() - started
|
| 338 |
return {
|
| 339 |
"inferenceId": "",
|
|
@@ -349,6 +365,7 @@ class ModelRuntime:
|
|
| 349 |
},
|
| 350 |
"queryTime": query_time.tolist(),
|
| 351 |
"generatedConcentration": samples.tolist(),
|
|
|
|
| 352 |
"units": {"time": cohort["time_units"], "concentration": cohort["concentration_units"]},
|
| 353 |
"provenance": {
|
| 354 |
"checkpointSha256": self.checkpoint_sha256,
|
|
@@ -423,7 +440,7 @@ def cached_inference(request: dict[str, Any]) -> dict[str, Any]:
|
|
| 423 |
runtime = runtime_for_request(request)
|
| 424 |
runtime.load()
|
| 425 |
cache_key = {
|
| 426 |
-
"schemaVersion":
|
| 427 |
"request": request,
|
| 428 |
"checkpointSha256": runtime.checkpoint_sha256,
|
| 429 |
"configSha256": hashlib.sha256(runtime.config_path.read_bytes()).hexdigest(),
|
|
|
|
| 37 |
)
|
| 38 |
from pff_pk.inference.model import load_inference_model # noqa: E402
|
| 39 |
|
| 40 |
+
try: # module import in tests and the hosted Space
|
| 41 |
+
from services.inference.pharmpy_vpc import pharmpy_vpc_summary # noqa: E402
|
| 42 |
+
except ModuleNotFoundError: # direct execution from services/inference
|
| 43 |
+
from pharmpy_vpc import pharmpy_vpc_summary # type: ignore[no-redef] # noqa: E402
|
| 44 |
+
|
| 45 |
DEFAULT_CONFIG = PFF_ROOT / "configs" / "amarel_v6_protocol_counterfactual_phase2_sparse.yaml"
|
| 46 |
DEFAULT_CHECKPOINT = (
|
| 47 |
PFF_ROOT / "artifacts" / "checkpoints" / "lucid_marten_v6_step750"
|
|
|
|
| 63 |
MAX_GENERATED_INDIVIDUALS = 30
|
| 64 |
DEFAULT_FLOW_STEPS = 8
|
| 65 |
MAX_FLOW_STEPS = 16
|
| 66 |
+
VPC_REPLICATES = 200
|
| 67 |
+
VPC_REQUESTED_BINS = 10
|
| 68 |
+
VPC_SEED_OFFSET = 104729
|
| 69 |
PYTHIA_MODEL = "pythia"
|
| 70 |
PYTHIA_DOSE_MODEL = "pythia_dose"
|
| 71 |
DEFAULT_MODEL = PYTHIA_DOSE_MODEL
|
|
|
|
| 325 |
)
|
| 326 |
cpu_batch = union_query_batch(cpu_batch)
|
| 327 |
query_time = cpu_batch.target_time.numpy()[0, :, 0] * cohort["horizon"]
|
| 328 |
+
seed = bounded_integer(request.get("seed", 42), "seed", 0, 2**31 - 1)
|
| 329 |
started = time.perf_counter()
|
| 330 |
chunks = []
|
| 331 |
for start in range(0, n_draws, batch_size):
|
|
|
|
| 342 |
physical = inverse_concentration(normalized, repeated).float().cpu().numpy()
|
| 343 |
chunks.append(physical[..., 0])
|
| 344 |
samples = np.concatenate(chunks, axis=0)
|
| 345 |
+
vpc = pharmpy_vpc_summary(
|
| 346 |
+
samples,
|
| 347 |
+
query_time,
|
| 348 |
+
cohort,
|
| 349 |
+
replicates=VPC_REPLICATES,
|
| 350 |
+
requested_bins=VPC_REQUESTED_BINS,
|
| 351 |
+
seed=seed + VPC_SEED_OFFSET,
|
| 352 |
+
)
|
| 353 |
elapsed = time.perf_counter() - started
|
| 354 |
return {
|
| 355 |
"inferenceId": "",
|
|
|
|
| 365 |
},
|
| 366 |
"queryTime": query_time.tolist(),
|
| 367 |
"generatedConcentration": samples.tolist(),
|
| 368 |
+
"vpc": vpc,
|
| 369 |
"units": {"time": cohort["time_units"], "concentration": cohort["concentration_units"]},
|
| 370 |
"provenance": {
|
| 371 |
"checkpointSha256": self.checkpoint_sha256,
|
|
|
|
| 440 |
runtime = runtime_for_request(request)
|
| 441 |
runtime.load()
|
| 442 |
cache_key = {
|
| 443 |
+
"schemaVersion": 2,
|
| 444 |
"request": request,
|
| 445 |
"checkpointSha256": runtime.checkpoint_sha256,
|
| 446 |
"configSha256": hashlib.sha256(runtime.config_path.read_bytes()).hexdigest(),
|
services/inference/pharmpy_vpc.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Design-matched Pharmpy VPC summaries for interactive inference."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
from pharmpy.modeling import (
|
| 10 |
+
bin_observations,
|
| 11 |
+
create_basic_pk_model,
|
| 12 |
+
plot_vpc,
|
| 13 |
+
set_dataset,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _effective_bins(model, requested: int) -> int:
|
| 18 |
+
unique_times = int(model.dataset["TIME"].nunique())
|
| 19 |
+
for candidate in range(min(requested, unique_times), 0, -1):
|
| 20 |
+
membership, boundaries = bin_observations(model, "equal_number", candidate)
|
| 21 |
+
if (
|
| 22 |
+
membership.notna().all()
|
| 23 |
+
and membership.nunique() == candidate
|
| 24 |
+
and len(boundaries) == candidate + 1
|
| 25 |
+
and np.all(np.diff(boundaries) > 0.0)
|
| 26 |
+
):
|
| 27 |
+
return candidate
|
| 28 |
+
raise ValueError("Pharmpy could not construct non-empty VPC bins")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _statistics_frame(
|
| 32 |
+
observed: pd.DataFrame,
|
| 33 |
+
simulations: pd.DataFrame,
|
| 34 |
+
*,
|
| 35 |
+
requested_bins: int,
|
| 36 |
+
) -> tuple[pd.DataFrame, int]:
|
| 37 |
+
model = set_dataset(create_basic_pk_model("iv"), observed, datatype="nonmem")
|
| 38 |
+
bins = _effective_bins(model, requested_bins)
|
| 39 |
+
chart = plot_vpc(
|
| 40 |
+
model,
|
| 41 |
+
simulations,
|
| 42 |
+
binning="equal_number",
|
| 43 |
+
nbins=bins,
|
| 44 |
+
qi=0.90,
|
| 45 |
+
ci=0.90,
|
| 46 |
+
)
|
| 47 |
+
required = {
|
| 48 |
+
"obs_central",
|
| 49 |
+
"obs_lower",
|
| 50 |
+
"obs_upper",
|
| 51 |
+
"sim_central",
|
| 52 |
+
"sim_central_lower",
|
| 53 |
+
"sim_central_upper",
|
| 54 |
+
"sim_lower",
|
| 55 |
+
"sim_lower_lower",
|
| 56 |
+
"sim_lower_upper",
|
| 57 |
+
"sim_upper",
|
| 58 |
+
"sim_upper_lower",
|
| 59 |
+
"sim_upper_upper",
|
| 60 |
+
"bin_midpoint",
|
| 61 |
+
"bin_edges_left",
|
| 62 |
+
"bin_edges_right",
|
| 63 |
+
"n_data_points",
|
| 64 |
+
}
|
| 65 |
+
for layer in chart.layer:
|
| 66 |
+
data = getattr(layer, "data", None)
|
| 67 |
+
if isinstance(data, pd.DataFrame) and required <= set(data.columns):
|
| 68 |
+
return data.reset_index(drop=True).copy(), bins
|
| 69 |
+
raise RuntimeError("Pharmpy VPC did not expose its statistics table")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _finite_float(value: Any, label: str) -> float:
|
| 73 |
+
result = float(value)
|
| 74 |
+
if not np.isfinite(result):
|
| 75 |
+
raise ValueError(f"non-finite {label} in Pharmpy VPC output")
|
| 76 |
+
return result
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def pharmpy_vpc_summary(
|
| 80 |
+
generated_concentration: np.ndarray,
|
| 81 |
+
query_time: np.ndarray,
|
| 82 |
+
cohort: dict[str, Any],
|
| 83 |
+
*,
|
| 84 |
+
replicates: int = 200,
|
| 85 |
+
requested_bins: int = 10,
|
| 86 |
+
seed: int = 42,
|
| 87 |
+
) -> dict[str, Any]:
|
| 88 |
+
"""Compute a formal VPC from one finite pool of generated individuals.
|
| 89 |
+
|
| 90 |
+
The neural model is evaluated only for the rows in ``generated_concentration``.
|
| 91 |
+
Each inexpensive simulation replicate then samples one generated curve for each
|
| 92 |
+
empirical individual and evaluates it on that individual's observation schedule.
|
| 93 |
+
Pharmpy owns equal-number binning and all observed/simulated quantiles.
|
| 94 |
+
"""
|
| 95 |
+
pool = np.asarray(generated_concentration, dtype=np.float64)
|
| 96 |
+
times = np.asarray(query_time, dtype=np.float64)
|
| 97 |
+
if pool.ndim != 2 or times.ndim != 1 or pool.shape[1] != len(times):
|
| 98 |
+
raise ValueError("generated pool and query mesh are misaligned")
|
| 99 |
+
if not len(pool) or not len(times) or not np.isfinite(pool).all() or not np.isfinite(times).all():
|
| 100 |
+
raise ValueError("generated pool and query mesh must be finite and non-empty")
|
| 101 |
+
if np.any(pool <= 0.0) or np.any(np.diff(times) <= 0.0):
|
| 102 |
+
raise ValueError("Pharmpy VPC requires positive concentrations and increasing times")
|
| 103 |
+
if replicates < 20:
|
| 104 |
+
raise ValueError("Pharmpy VPC requires at least 20 resampled cohort replicates")
|
| 105 |
+
if requested_bins < 1:
|
| 106 |
+
raise ValueError("requested VPC bins must be positive")
|
| 107 |
+
|
| 108 |
+
observed_rows: list[dict[str, float | int]] = []
|
| 109 |
+
schedules: list[np.ndarray] = []
|
| 110 |
+
tolerance = max(float(cohort["horizon"]) * 2.0e-6, 1.0e-9)
|
| 111 |
+
for individual, curve in enumerate(cohort["subjects"].values(), start=1):
|
| 112 |
+
schedule = np.asarray([point[0] for point in curve], dtype=np.float64)
|
| 113 |
+
values = np.asarray([point[1] for point in curve], dtype=np.float64)
|
| 114 |
+
indices = np.abs(times[:, None] - schedule[None, :]).argmin(axis=0)
|
| 115 |
+
if len(schedule) and np.max(np.abs(times[indices] - schedule)) > tolerance:
|
| 116 |
+
raise ValueError("an empirical observation time is absent from the query mesh")
|
| 117 |
+
schedules.append(indices)
|
| 118 |
+
observed_rows.extend(
|
| 119 |
+
{
|
| 120 |
+
"ID": individual,
|
| 121 |
+
"TIME": float(time),
|
| 122 |
+
"DV": float(value),
|
| 123 |
+
"MDV": 0,
|
| 124 |
+
"AMT": 0.0,
|
| 125 |
+
}
|
| 126 |
+
for time, value in zip(schedule, values, strict=True)
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
observed = pd.DataFrame(observed_rows)
|
| 130 |
+
observed.index = np.arange(1, len(observed) + 1)
|
| 131 |
+
rng = np.random.default_rng(seed)
|
| 132 |
+
simulation_values = np.empty((replicates, len(observed)), dtype=np.float64)
|
| 133 |
+
for replicate in range(replicates):
|
| 134 |
+
selected = rng.choice(
|
| 135 |
+
len(pool),
|
| 136 |
+
size=len(schedules),
|
| 137 |
+
replace=len(pool) < len(schedules),
|
| 138 |
+
)
|
| 139 |
+
cursor = 0
|
| 140 |
+
for draw, indices in zip(selected, schedules, strict=True):
|
| 141 |
+
count = len(indices)
|
| 142 |
+
simulation_values[replicate, cursor : cursor + count] = pool[draw, indices]
|
| 143 |
+
cursor += count
|
| 144 |
+
simulations = pd.DataFrame(
|
| 145 |
+
{
|
| 146 |
+
"SIM": np.repeat(np.arange(1, replicates + 1), len(observed)),
|
| 147 |
+
"index": np.tile(np.arange(1, len(observed) + 1), replicates),
|
| 148 |
+
"DV": simulation_values.reshape(-1),
|
| 149 |
+
}
|
| 150 |
+
).set_index(["SIM", "index"])
|
| 151 |
+
statistics, bins = _statistics_frame(
|
| 152 |
+
observed,
|
| 153 |
+
simulations,
|
| 154 |
+
requested_bins=requested_bins,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
points = []
|
| 158 |
+
for row in statistics.to_dict(orient="records"):
|
| 159 |
+
points.append(
|
| 160 |
+
{
|
| 161 |
+
"time": _finite_float(row["bin_midpoint"], "bin midpoint"),
|
| 162 |
+
"timeLower": _finite_float(row["bin_edges_left"], "left bin edge"),
|
| 163 |
+
"timeUpper": _finite_float(row["bin_edges_right"], "right bin edge"),
|
| 164 |
+
"nObservations": int(row["n_data_points"]),
|
| 165 |
+
"observed": {
|
| 166 |
+
"q05": _finite_float(row["obs_lower"], "observed lower quantile"),
|
| 167 |
+
"q50": _finite_float(row["obs_central"], "observed median"),
|
| 168 |
+
"q95": _finite_float(row["obs_upper"], "observed upper quantile"),
|
| 169 |
+
},
|
| 170 |
+
"simulated": {
|
| 171 |
+
"q05": {
|
| 172 |
+
"center": _finite_float(row["sim_lower"], "simulated lower quantile"),
|
| 173 |
+
"lower": _finite_float(row["sim_lower_lower"], "lower-quantile interval"),
|
| 174 |
+
"upper": _finite_float(row["sim_lower_upper"], "lower-quantile interval"),
|
| 175 |
+
},
|
| 176 |
+
"q50": {
|
| 177 |
+
"center": _finite_float(row["sim_central"], "simulated median"),
|
| 178 |
+
"lower": _finite_float(row["sim_central_lower"], "median interval"),
|
| 179 |
+
"upper": _finite_float(row["sim_central_upper"], "median interval"),
|
| 180 |
+
},
|
| 181 |
+
"q95": {
|
| 182 |
+
"center": _finite_float(row["sim_upper"], "simulated upper quantile"),
|
| 183 |
+
"lower": _finite_float(row["sim_upper_lower"], "upper-quantile interval"),
|
| 184 |
+
"upper": _finite_float(row["sim_upper_upper"], "upper-quantile interval"),
|
| 185 |
+
},
|
| 186 |
+
},
|
| 187 |
+
}
|
| 188 |
+
)
|
| 189 |
+
return {
|
| 190 |
+
"method": "pharmpy",
|
| 191 |
+
"generatedIndividuals": int(len(pool)),
|
| 192 |
+
"simulatedCohortReplicates": int(replicates),
|
| 193 |
+
"requestedBins": int(requested_bins),
|
| 194 |
+
"effectiveBins": int(bins),
|
| 195 |
+
"points": points,
|
| 196 |
+
}
|