pff-pk-api / pff_pk /inference /protocol_validation.py
Dariusfar's picture
Support interactive synthetic cohorts, MLP resampling and configurable binned VPCs
a1a4d73 verified
Raw History Blame Contribute Delete
9.64 kB
"""Fixed-stratum generation and plotting for v6 protocol validation."""
from __future__ import annotations
import hashlib
from dataclasses import fields, replace
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import torch
from pff_pk.tasks.batch import FlowBatch
from pff_pk.tasks.batching import inverse_concentration
CONTEXT_COLOR = "#ff7f0e"
GENERATED_COLOR = "#1f77b4"
TRUTH_COLOR = "#202020"
PROTOCOL_CATEGORIES = {
"identity": (
"reference_identity",
"global_identity",
"add_on_identity",
),
"dose_scaling": (
"reference_to_lower_scale",
"reference_to_higher_scale",
"lower_scale_to_reference",
"higher_scale_to_reference",
"global_scale_cross",
),
"add_on_dose": (
"reference_to_early_add_on",
"reference_to_late_add_on",
"add_on_to_reference",
"add_on_cross",
),
"cross_intervention": (
"global_to_add_on",
"add_on_to_global",
),
}
ACQUISITIONS = ("unscheduled", "exact", "pseudo_scheduled")
def select_representatives(bank: dict) -> list[tuple[str, int, int]]:
"""Select one deterministic bank row for every protocol/acquisition stratum."""
candidates: dict[str, list[tuple[int, int]]] = {}
for record_index, record in enumerate(bank["batches"]):
candidates.setdefault(record["stratum"], []).extend(
(record_index, row) for row in range(len(record["study_id"]))
)
expected = {
f"{case}__{acquisition}"
for cases in PROTOCOL_CATEGORIES.values()
for case in cases
for acquisition in ACQUISITIONS
}
if set(candidates) != expected:
raise ValueError("validation bank does not contain the expected 42 v6 strata")
selected = []
for stratum in sorted(candidates):
digest = hashlib.blake2b(stratum.encode(), digest_size=8).digest()
choices = candidates[stratum]
record_index, row = choices[int.from_bytes(digest, "little") % len(choices)]
selected.append((stratum, record_index, row))
return selected
def protocol_seed(stratum: str) -> int:
"""Return the fixed source-process seed for a protocol stratum."""
digest = hashlib.blake2b(stratum.encode(), digest_size=4).digest()
return 2026 + int.from_bytes(digest, "little")
def _select(batch: FlowBatch, row: int, device: torch.device) -> FlowBatch:
index = torch.tensor([row], dtype=torch.long)
values = {}
for field in fields(batch):
value = getattr(batch, field.name)
if value is None:
values[field.name] = None
elif isinstance(value, torch.Tensor):
values[field.name] = value.index_select(0, index).to(device)
else:
values[field.name] = [value[row]]
return replace(batch, **values)
def _event_arrays(batch: FlowBatch) -> tuple[np.ndarray, np.ndarray]:
assert batch.context_protocol_events is not None
assert batch.context_protocol_event_mask is not None
assert batch.target_protocol_events is not None
assert batch.target_protocol_event_mask is not None
context_mask = batch.context_protocol_event_mask[0, 0].cpu().numpy()
target_mask = batch.target_protocol_event_mask[0].cpu().numpy()
context = batch.context_protocol_events[0, 0].cpu().numpy()[context_mask]
target = batch.target_protocol_events[0].cpu().numpy()[target_mask]
return context, target
def generate_protocol_draws(
model, cpu_batch: FlowBatch, row: int, samples: int, seed: int
) -> dict:
"""Generate persistent conditional draws for one fixed bank row."""
device = next(model.parameters()).device
batch = _select(cpu_batch, row, device)
repeated_values = {}
for field in fields(batch):
value = getattr(batch, field.name)
if value is None:
repeated_values[field.name] = None
elif isinstance(value, torch.Tensor):
repeated_values[field.name] = value.expand(samples, *value.shape[1:]).clone()
else:
repeated_values[field.name] = value * samples
repeated = replace(batch, **repeated_values)
cuda_devices = [device.index] if device.type == "cuda" else []
with torch.random.fork_rng(devices=cuda_devices), torch.inference_mode():
torch.manual_seed(seed)
generated = inverse_concentration(model.sample(repeated), repeated)[..., 0]
context = inverse_concentration(batch.context_value, batch)[0, ..., 0]
target = inverse_concentration(batch.target_value, batch)[0, ..., 0]
context_events, target_events = _event_arrays(batch)
return {
"study_id": batch.study_id[0],
"context_time": batch.context_time[0, ..., 0].cpu(),
"context_value": context.cpu(),
"context_mask": batch.context_mask[0].cpu(),
"target_time": batch.target_time[0, :, 0].cpu(),
"target_value": target.cpu(),
"target_mask": batch.target_mask[0].cpu(),
"generated": generated.cpu(),
"context_events": torch.from_numpy(context_events),
"target_events": torch.from_numpy(target_events),
}
def _schedule(events: torch.Tensor) -> str:
labels = []
for time, amount, duration, oral in events.tolist():
route = "PO" if oral >= 0.5 else "IV"
label = f"{route} {amount:.3g}× @ {time:.3g}"
if duration > 0:
label += f" over {duration:.3g}"
labels.append(label)
return " + ".join(labels) if labels else "no dose"
def _human_case(value: str) -> str:
return value.replace("_", " ").replace("to", "→")
def plot_protocol_category(
records: dict[str, dict],
category: str,
cases: tuple[str, ...],
output: Path,
step: int,
) -> tuple[Path, Path]:
"""Render one protocol category over the three acquisition designs."""
figure, axes = plt.subplots(
len(cases),
len(ACQUISITIONS),
figsize=(15.6, 3.25 * len(cases) + 1.4),
squeeze=False,
constrained_layout=True,
)
legend_handles = None
for row, case in enumerate(cases):
for column, acquisition in enumerate(ACQUISITIONS):
axis = axes[row, column]
record = records[f"{case}__{acquisition}"]
context_time = record["context_time"].numpy()
context_value = record["context_value"].numpy()
context_mask = record["context_mask"].numpy()
for times, values, mask in zip(
context_time, context_value, context_mask, strict=True
):
axis.plot(
times[mask],
values[mask],
color=CONTEXT_COLOR,
linewidth=0.5,
alpha=0.28,
zorder=1,
)
target_mask = record["target_mask"].numpy()
target_time = record["target_time"].numpy()[target_mask]
for generated in record["generated"].numpy():
axis.plot(
target_time,
generated[target_mask],
color=GENERATED_COLOR,
linewidth=1.0,
alpha=0.72,
zorder=3,
)
truth = axis.plot(
target_time,
record["target_value"].numpy()[target_mask],
color=TRUTH_COLOR,
linestyle="--",
linewidth=1.5,
zorder=4,
label="held-out truth",
)[0]
context_line = axis.plot([], [], color=CONTEXT_COLOR, label="context cohort")[0]
generated_line = axis.plot([], [], color=GENERATED_COLOR, label="generated draws")[0]
legend_handles = (context_line, generated_line, truth)
axis.text(
0.02,
0.97,
f"study {record['study_id'][-8:]}\n"
f"context: {_schedule(record['context_events'])}\n"
f"target: {_schedule(record['target_events'])}",
transform=axis.transAxes,
fontsize=7.2,
ha="left",
va="top",
bbox={"facecolor": "white", "alpha": 0.78, "edgecolor": "none", "pad": 2},
zorder=10,
)
if row == 0:
axis.set_title(
acquisition.replace("_", " "),
fontsize=10.5,
fontweight="bold",
pad=8,
)
if column == 0:
axis.set_ylabel(f"{_human_case(case)}\nC / Cref", fontsize=8)
if row == len(cases) - 1:
axis.set_xlabel("dimensionless time tau", fontsize=8)
axis.set_yscale("log")
axis.tick_params(labelsize=7)
axis.grid(alpha=0.13, linewidth=0.4)
figure.suptitle(
f"v6 validation: {category.replace('_', ' ')} | checkpoint step {step}\n"
"one held-out family per protocol/acquisition stratum; five conditional draws",
fontsize=13,
)
if legend_handles is not None:
figure.legend(
legend_handles,
[handle.get_label() for handle in legend_handles],
loc="outside lower center",
ncol=3,
frameon=False,
)
png = output / f"protocol_validation_{category}_step{step:06d}.png"
pdf = png.with_suffix(".pdf")
figure.savefig(png, dpi=220, facecolor="white")
figure.savefig(pdf, facecolor="white")
plt.close(figure)
return png, pdf