Add train-split robust scaling for continuous values

This commit is contained in:
2026-08-01 11:37:21 +08:00
parent 89dcf4b362
commit dfb22adf2d
6 changed files with 419 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ import logging
import sys
import time
import csv
from dataclasses import dataclass
from datetime import datetime
import math
from pathlib import Path
@@ -19,6 +20,134 @@ from dataset import AllFutureHealthDataset, HealthDataset
from models import DeepHealth
@dataclass(frozen=True)
class ContinuousRobustScalerStats:
"""Train-split robust scaling statistics aligned to ``cont_type_ids``."""
cont_type_ids: tuple[int, ...]
center: np.ndarray
scale: np.ndarray
observation_count: np.ndarray
quantile_range: tuple[float, float] = (25.0, 75.0)
def as_metadata(self) -> Dict[str, Any]:
return {
"method": "robust",
"fitted_on": "train_subset",
"quantile_range": [float(x) for x in self.quantile_range],
"cont_type_ids": [int(x) for x in self.cont_type_ids],
"observation_count": [int(x) for x in self.observation_count.tolist()],
"center_buffer": "tokenizer.continuous_value_center",
"scale_buffer": "tokenizer.continuous_value_scale",
}
def fit_continuous_robust_scaler(
dataset: AllFutureHealthDataset,
subset: Subset,
*,
quantile_range: tuple[float, float] = (25.0, 75.0),
scale_epsilon: float = 1e-6,
) -> ContinuousRobustScalerStats:
"""Fit median/IQR statistics using only patients in the training subset.
The prepared arrays remain unchanged. Scaling is performed later inside the
model tokenizer so the fitted center and scale can live in the checkpoint.
"""
if subset.dataset is not dataset:
raise ValueError("subset must reference the dataset used to fit the scaler")
low, high = (float(quantile_range[0]), float(quantile_range[1]))
if not (0.0 <= low < high <= 100.0):
raise ValueError(
"quantile_range must satisfy 0 <= low < high <= 100, got "
f"{quantile_range!r}"
)
if scale_epsilon <= 0:
raise ValueError("scale_epsilon must be > 0")
cont_type_ids = tuple(int(x) for x in dataset.cont_type_ids)
n_cont_types = len(cont_type_ids)
if n_cont_types == 0:
empty = np.zeros(0, dtype=np.float32)
return ContinuousRobustScalerStats(
cont_type_ids=cont_type_ids,
center=empty.copy(),
scale=empty.copy(),
observation_count=np.zeros(0, dtype=np.int64),
quantile_range=(low, high),
)
subset_indices = np.asarray(subset.indices, dtype=np.int64)
if subset_indices.ndim != 1 or subset_indices.size == 0:
raise ValueError("training subset must contain at least one patient")
type_to_column = np.full(int(dataset.n_types), -1, dtype=np.int64)
for column, type_id in enumerate(cont_type_ids):
if type_id <= 0 or type_id >= len(type_to_column):
raise ValueError(
f"continuous type id {type_id} is outside [1, {len(type_to_column)})"
)
type_to_column[type_id] = column
values = np.full(
(int(subset_indices.size), n_cont_types),
np.nan,
dtype=np.float32,
)
for row, patient_index in enumerate(subset_indices.tolist()):
patient = dataset.patients[int(patient_index)]
other_type = np.asarray(patient["other_type"], dtype=np.int64)
other_value = np.asarray(patient["other_value"], dtype=np.float32)
other_kind = np.asarray(patient["other_value_kind"], dtype=np.int64)
continuous = other_kind == 1
if not np.any(continuous):
continue
selected_type = other_type[continuous]
selected_value = other_value[continuous]
valid_type = (selected_type > 0) & (selected_type < len(type_to_column))
columns = np.full(selected_type.shape, -1, dtype=np.int64)
columns[valid_type] = type_to_column[selected_type[valid_type]]
valid = (columns >= 0) & np.isfinite(selected_value)
values[row, columns[valid]] = selected_value[valid]
observation_count = np.isfinite(values).sum(axis=0).astype(np.int64)
missing_types = [
type_id
for type_id, count in zip(cont_type_ids, observation_count.tolist())
if count == 0
]
if missing_types:
raise ValueError(
"Training subset has no finite observations for continuous type ids: "
f"{missing_types}"
)
low_value, center, high_value = np.nanpercentile(
values,
[low, 50.0, high],
axis=0,
)
scale = high_value - low_value
near_constant = (~np.isfinite(scale)) | (np.abs(scale) <= float(scale_epsilon))
scale[near_constant] = 1.0
center = np.asarray(center, dtype=np.float32)
scale = np.asarray(scale, dtype=np.float32)
if not np.isfinite(center).all():
raise RuntimeError("Robust scaler produced non-finite center values")
if not np.isfinite(scale).all() or np.any(scale <= 0):
raise RuntimeError("Robust scaler produced invalid scale values")
return ContinuousRobustScalerStats(
cont_type_ids=cont_type_ids,
center=center,
scale=scale,
observation_count=observation_count,
quantile_range=(low, high),
)
def create_unique_run_dir(name_fn, runs_root: Path = Path("runs")) -> tuple[Path, str]:
while True:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")