misscp / src /sepsis_mcp /appendix_score_diagnostics.py
Anonymous
Initial anonymous MissCP release
32f5a65
Raw
History Blame Contribute Delete
17.9 kB
from __future__ import annotations
import argparse
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import ks_2samp
from sepsis_mcp.conformal import binary_nonconformity_scores
from sepsis_mcp.gossis import build_hospital_disjoint_split_with_selection, load_gossis_dataset
from sepsis_mcp.gossis_experiment import (
GossisRunConfig,
_build_structured_grouping,
_encode_feature_splits,
_normalize_json_value,
_sanitize_encoded_features,
)
from sepsis_mcp.mimic4_experiment import (
DEFAULT_MIN_UNIT_SIZE,
_leave_one_out_assignments,
_limit_frame_rows,
_selection_features,
load_mimic4_dataset,
)
from sepsis_mcp.modeling import ProbabilityEstimator
@dataclass(frozen=True)
class GossisScoreDiagnosticsConfig:
data_root: Path
min_hospital_admissions: int = 500
selection_fraction: float = 0.1
train_fraction: float = 0.6
calibration_fraction: float = 0.1
random_state_grid: tuple[int, ...] = (0,)
model_type_grid: tuple[str, ...] = ("xgboost",)
alpha: float = 0.1
missingness_grouping_strategy: str = "coverage_gap_variable"
@dataclass(frozen=True)
class MimicScoreDiagnosticsConfig:
csv_path: Path
min_unit_size: int = DEFAULT_MIN_UNIT_SIZE
rotations: int = 1
max_assignments: int | None = 1
model_type_grid: tuple[str, ...] = ("xgboost",)
alpha: float = 0.1
min_variable_missing_fraction: float = 0.1
max_variable_missing_fraction: float = 0.9
min_selection_group_rows: int = 100
max_selection_rows: int | None = None
max_calibration_rows: int | None = None
max_test_rows: int | None = None
def _parse_int_grid(value: str) -> tuple[int, ...]:
return tuple(int(item.strip()) for item in value.split(",") if item.strip())
def _parse_str_grid(value: str) -> tuple[str, ...]:
return tuple(item.strip() for item in value.split(",") if item.strip())
def _score_records(
*,
dataset: str,
model_type: str,
seed: int,
split_kind: str,
scores: np.ndarray,
groups: pd.DataFrame,
) -> pd.DataFrame:
rows: list[dict[str, Any]] = []
group_labels = groups["group_label"].astype(str).to_numpy()
group_ids = groups["group"].astype(int).to_numpy()
for score, group_id, group_label in zip(np.asarray(scores, dtype=float), group_ids, group_labels, strict=True):
rows.append(
{
"dataset": dataset,
"model_type": model_type,
"seed": int(seed),
"split_kind": split_kind,
"group": int(group_id),
"group_label": str(group_label),
"score": float(score),
}
)
return pd.DataFrame(rows)
def summarize_score_distribution_shifts(score_records: pd.DataFrame) -> pd.DataFrame:
rows: list[dict[str, Any]] = []
group_keys = ["dataset", "model_type", "seed"]
for key, frame in score_records.groupby(group_keys, sort=True):
context = dict(zip(group_keys, key if isinstance(key, tuple) else (key,), strict=False))
calibration = frame[frame["split_kind"] == "calibration"].copy()
pooled_scores = calibration["score"].to_numpy(dtype=float)
for group_id, group_frame in calibration.groupby("group", sort=True):
group_scores = group_frame["score"].to_numpy(dtype=float)
pooled_ks = float(ks_2samp(group_scores, pooled_scores, method="auto").statistic)
test_group = frame[(frame["split_kind"] == "test") & (frame["group"] == group_id)]
calibration_vs_test_ks = float("nan")
if not test_group.empty:
calibration_vs_test_ks = float(
ks_2samp(group_scores, test_group["score"].to_numpy(dtype=float), method="auto").statistic
)
rows.append(
{
**context,
"group": int(group_id),
"group_label": str(group_frame["group_label"].iloc[0]),
"pooled_ks": pooled_ks,
"calibration_vs_test_ks": calibration_vs_test_ks,
"n_calibration": int(len(group_frame)),
"n_test": int(len(test_group)),
}
)
return pd.DataFrame(rows)
def _ecdf(values: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
ordered = np.sort(np.asarray(values, dtype=float))
if ordered.size == 0:
return np.array([]), np.array([])
return ordered, np.arange(1, ordered.size + 1, dtype=float) / float(ordered.size)
def render_score_distribution_panels(score_records: pd.DataFrame, output_path: Path) -> None:
panels = list(score_records.groupby(["dataset", "model_type"], sort=True))
n_panels = max(len(panels), 1)
fig, axes = plt.subplots(1, n_panels, figsize=(4.8 * n_panels, 3.4), squeeze=False)
colors = ["#2171b5", "#e6550d", "#31a354", "#756bb1", "#636363"]
for axis, ((dataset, model_type), frame) in zip(axes[0], panels, strict=False):
calibration = frame[frame["split_kind"] == "calibration"]
test = frame[frame["split_kind"] == "test"]
pooled_x, pooled_y = _ecdf(calibration["score"].to_numpy(dtype=float))
axis.plot(pooled_x, pooled_y, color="#111111", linewidth=2, label="Calibration pooled")
for idx, (group_id, group_frame) in enumerate(calibration.groupby("group", sort=True)):
color = colors[idx % len(colors)]
label = f"Cal {group_frame['group_label'].iloc[0]}"
x, y = _ecdf(group_frame["score"].to_numpy(dtype=float))
axis.plot(x, y, color=color, linewidth=1.8, label=label)
test_group = test[test["group"] == group_id]
if not test_group.empty:
x_test, y_test = _ecdf(test_group["score"].to_numpy(dtype=float))
axis.plot(x_test, y_test, color=color, linewidth=1.4, linestyle="--", label=f"Test {group_frame['group_label'].iloc[0]}")
axis.set_title(f"{dataset.upper()} / {model_type}")
axis.set_xlabel("Nonconformity score")
axis.set_ylabel("ECDF")
axis.legend(frameon=False, fontsize=8)
fig.tight_layout()
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=200)
plt.close(fig)
def run_gossis_score_diagnostics(config: GossisScoreDiagnosticsConfig) -> pd.DataFrame:
dataset = load_gossis_dataset(config.data_root, min_hospital_admissions=config.min_hospital_admissions)
frames: list[pd.DataFrame] = []
for seed in config.random_state_grid:
split = build_hospital_disjoint_split_with_selection(
dataset.frame,
train_fraction=config.train_fraction,
selection_fraction=config.selection_fraction,
calibration_fraction=config.calibration_fraction,
random_state=seed,
)
train_features, calibration_features, test_features = _encode_feature_splits(
split.train_frame,
split.calibration_frame,
split.test_frame,
dataset.feature_columns,
)
selection_features = (
_sanitize_encoded_features(pd.get_dummies(split.selection_frame[dataset.feature_columns], dummy_na=True))
.reindex(columns=train_features.columns, fill_value=0.0)
.astype("float32")
)
for model_type in config.model_type_grid:
estimator = ProbabilityEstimator(random_state=seed, model_type=model_type).fit(
train_features,
split.train_frame["label"],
)
selection_probabilities = estimator.predict_positive_proba(selection_features)
calibration_probabilities = estimator.predict_positive_proba(calibration_features)
test_probabilities = estimator.predict_positive_proba(test_features)
grouping = _build_structured_grouping(
config=GossisRunConfig(
data_root=config.data_root,
alpha=config.alpha,
train_fraction=config.train_fraction,
selection_fraction=config.selection_fraction,
calibration_fraction=config.calibration_fraction,
min_hospital_admissions=config.min_hospital_admissions,
random_state=seed,
model_type=model_type,
missingness_grouping_strategy=config.missingness_grouping_strategy,
),
feature_columns=dataset.feature_columns,
selection_frame=split.selection_frame,
calibration_frame=split.calibration_frame,
test_frame=split.test_frame,
selection_probabilities=selection_probabilities,
calibration_probabilities=calibration_probabilities,
test_probabilities=test_probabilities,
)
calibration_scores = binary_nonconformity_scores(
split.calibration_frame["label"].to_numpy(dtype=int),
calibration_probabilities.to_numpy(dtype=float),
)
test_scores = binary_nonconformity_scores(
split.test_frame["label"].to_numpy(dtype=int),
test_probabilities.to_numpy(dtype=float),
)
frames.append(
_score_records(
dataset="gossis",
model_type=model_type,
seed=seed,
split_kind="calibration",
scores=calibration_scores,
groups=grouping.calibration_groups,
)
)
frames.append(
_score_records(
dataset="gossis",
model_type=model_type,
seed=seed,
split_kind="test",
scores=test_scores,
groups=grouping.test_groups,
)
)
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
def run_mimic_score_diagnostics(config: MimicScoreDiagnosticsConfig) -> pd.DataFrame:
dataset = load_mimic4_dataset(config.csv_path, min_unit_size=config.min_unit_size)
assignments = _leave_one_out_assignments(dataset.units, rotations=config.rotations)
if config.max_assignments is not None:
assignments = assignments[: config.max_assignments]
frames: list[pd.DataFrame] = []
for assignment in assignments:
seed = int(assignment["rotation"])
frame = dataset.frame
train_frame = frame[frame["hospital_id"].isin(assignment["train_units"])].copy()
selection_frame = _limit_frame_rows(
frame[frame["hospital_id"].isin(assignment["selection_units"])].copy(),
config.max_selection_rows,
random_state=seed + 201,
)
calibration_frame = _limit_frame_rows(
frame[frame["hospital_id"] == assignment["calibration_unit"]].copy(),
config.max_calibration_rows,
random_state=seed + 203,
)
test_frame = _limit_frame_rows(
frame[frame["hospital_id"] == assignment["test_unit"]].copy(),
config.max_test_rows,
random_state=seed + 207,
)
train_features, calibration_features, test_features = _encode_feature_splits(
train_frame,
calibration_frame,
test_frame,
dataset.feature_columns,
)
selection_features = _selection_features(
selection_frame,
train_columns=train_features.columns,
feature_columns=dataset.feature_columns,
)
for model_type in config.model_type_grid:
estimator = ProbabilityEstimator(random_state=seed, model_type=model_type).fit(
train_features,
train_frame["label"],
)
selection_probabilities = estimator.predict_positive_proba(selection_features)
calibration_probabilities = estimator.predict_positive_proba(calibration_features)
test_probabilities = estimator.predict_positive_proba(test_features)
grouping = _build_structured_grouping(
config=GossisRunConfig(
data_root=Path("."),
alpha=config.alpha,
selection_fraction=0.1,
model_type=model_type,
random_state=seed,
missingness_grouping_strategy="coverage_gap_variable",
min_variable_missing_fraction=config.min_variable_missing_fraction,
max_variable_missing_fraction=config.max_variable_missing_fraction,
min_selection_group_rows=config.min_selection_group_rows,
),
feature_columns=dataset.feature_columns,
selection_frame=selection_frame,
calibration_frame=calibration_frame,
test_frame=test_frame,
selection_probabilities=selection_probabilities,
calibration_probabilities=calibration_probabilities,
test_probabilities=test_probabilities,
)
calibration_scores = binary_nonconformity_scores(
calibration_frame["label"].to_numpy(dtype=int),
calibration_probabilities.to_numpy(dtype=float),
)
test_scores = binary_nonconformity_scores(
test_frame["label"].to_numpy(dtype=int),
test_probabilities.to_numpy(dtype=float),
)
frames.append(
_score_records(
dataset="mimic4",
model_type=model_type,
seed=seed,
split_kind="calibration",
scores=calibration_scores,
groups=grouping.calibration_groups,
)
)
frames.append(
_score_records(
dataset="mimic4",
model_type=model_type,
seed=seed,
split_kind="test",
scores=test_scores,
groups=grouping.test_groups,
)
)
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
def run_score_diagnostics(
*,
output_dir: Path,
gossis_config: GossisScoreDiagnosticsConfig | None = None,
mimic_config: MimicScoreDiagnosticsConfig | None = None,
) -> dict[str, Path]:
output_dir.mkdir(parents=True, exist_ok=True)
frames: list[pd.DataFrame] = []
config_payload: dict[str, Any] = {}
if gossis_config is not None:
frames.append(run_gossis_score_diagnostics(gossis_config))
config_payload["gossis"] = asdict(gossis_config)
if mimic_config is not None:
frames.append(run_mimic_score_diagnostics(mimic_config))
config_payload["mimic4"] = asdict(mimic_config)
score_records = pd.concat([frame for frame in frames if not frame.empty], ignore_index=True) if frames else pd.DataFrame()
score_summary = summarize_score_distribution_shifts(score_records) if not score_records.empty else pd.DataFrame()
records_path = output_dir / "score_records.csv"
summary_path = output_dir / "score_summary.csv"
figure_path = output_dir / "score_ecdf_panels.png"
config_path = output_dir / "score_diagnostics_config.json"
score_records.to_csv(records_path, index=False)
score_summary.to_csv(summary_path, index=False)
if not score_records.empty:
render_score_distribution_panels(score_records, figure_path)
config_path.write_text(json.dumps(_normalize_json_value(config_payload), indent=2, sort_keys=True), encoding="utf-8")
return {
"score_records": records_path,
"score_summary": summary_path,
"score_figure": figure_path,
"config": config_path,
}
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Appendix score distribution diagnostics for GOSSIS and MIMIC-IV")
parser.add_argument("--gossis-data-root", type=Path, default=None)
parser.add_argument("--mimic-csv-path", type=Path, default=None)
parser.add_argument("--min-hospital-admissions", type=int, default=500)
parser.add_argument("--min-unit-size", type=int, default=DEFAULT_MIN_UNIT_SIZE)
parser.add_argument("--random-state-grid", type=_parse_int_grid, default=(0,))
parser.add_argument("--model-type-grid", type=_parse_str_grid, default=("xgboost",))
parser.add_argument("--rotations", type=int, default=1)
parser.add_argument("--max-assignments", type=int, default=1)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args(argv)
gossis_config = None
if args.gossis_data_root is not None:
gossis_config = GossisScoreDiagnosticsConfig(
data_root=args.gossis_data_root,
min_hospital_admissions=args.min_hospital_admissions,
random_state_grid=tuple(args.random_state_grid),
model_type_grid=tuple(args.model_type_grid),
)
mimic_config = None
if args.mimic_csv_path is not None:
mimic_config = MimicScoreDiagnosticsConfig(
csv_path=args.mimic_csv_path,
min_unit_size=args.min_unit_size,
rotations=args.rotations,
max_assignments=args.max_assignments,
model_type_grid=tuple(args.model_type_grid),
)
run_score_diagnostics(output_dir=args.output_dir, gossis_config=gossis_config, mimic_config=mimic_config)
if __name__ == "__main__":
main()