Dariusfar commited on
Commit
836ffe9
·
verified ·
1 Parent(s): c761cde

Use Pharmpy VPCs and seed 42

Browse files
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", 161803), "seed", 0, 2**31 - 1)
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": 1,
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
+ }