2. Define Injury Severity Target and Leakage-Safe Feature Set
Why the Label and the Feature Set Must Be Decided Together
In occupant injury prediction, the single most common cause of a model that looks excellent in validation and collapses in deployment is target leakage: a feature that is only known after the crash outcome has been determined. Injury severity is recorded by hospital staff, crash investigators, or claims adjusters — all of whom observe the outcome. Any field populated during that observation process is contaminated. This case builds the severity label and the feature set as one coupled decision, because the label definition determines which fields become forbidden.
Step 1 — Construct the Injury Severity Label
Raw occupant datasets rarely ship with a clean severity target. They typically carry a Maximum Abbreviated Injury Scale (MAIS) value per occupant, sometimes a body-region breakdown, and often a coarse police-reported severity code. The label must be derived explicitly and reproducibly.
A defensible construction uses MAIS as the ordinal backbone and collapses it into a small number of classes that the rare-event split in the next case can stratify. A three-class scheme is standard for imbalanced injury work:
| Class | Rule on MAIS | Interpretation | Typical prevalence |
|---|---|---|---|
| 0 — None/Minor | MAIS = 0 or 1 | No injury or minor injury | dominant class |
| 1 — Moderate | MAIS = 2 | Moderate injury | minority class |
| 2 — Severe+ | MAIS ≥ 3 | Serious, severe, critical, or fatal | rare class |
Two rules keep the label honest. First, exclude occupants with unknown MAIS rather than imputing them — imputing the target silently manufactures signal. Second, never mix police-reported severity into the label if that same field will later be considered as a feature; pick one role. The code below derives the label and reports the class distribution so the prevalence claim above is verified against the actual data rather than assumed.
import pandas as pd
import numpy as np
def build_severity_label(df: pd.DataFrame,
mais_col: str = "MAIS",
id_col: str = "occupant_id") -> pd.DataFrame:
"""Derive a 3-class injury severity label from MAIS.
Returns a frame with occupant_id and severity_label only.
Rows with missing MAIS are dropped, not imputed.
"""
out = df[[id_col, mais_col]].copy()
out[mais_col] = pd.to_numeric(out[mais_col], errors="coerce")
n_before = len(out)
out = out.dropna(subset=[mais_col])
n_dropped = n_before - len(out)
def to_class(mais: float) -> int:
if mais <= 1:
return 0 # none / minor
if mais == 2:
return 1 # moderate
return 2 # severe and above (MAIS >= 3)
out["severity_label"] = out[mais_col].apply(to_class).astype("int8")
out = out.rename(columns={id_col: "occupant_id"})[["occupant_id", "severity_label"]]
dist = out["severity_label"].value_counts(normalize=True).sort_index()
print(f"dropped {n_dropped} occupants with unknown MAIS")
print("class prevalence:\n", dist.round(4).to_string())
return out
# labels = build_severity_label(raw_df)
# labels.to_parquet("severity_labels.parquet", index=False)
Expected observable output is a printed prevalence table where class 2 is the smallest share. If class 2 is absent or below roughly 1%, the three-class scheme is too fine for this dataset and the severe threshold should be reconsidered before proceeding — that is a real decision point, not a formality.
Step 2 — Classify Every Candidate Feature by Availability Time
The leakage test is not "does this column look suspicious" but "at what moment does this value become known relative to the crash?" Sort every candidate column into three availability tiers:
- Pre-crash / at-crash (safe): occupant demographics, seating position, restraint use and belt status, vehicle class and model year, impact direction, delta-V, principal direction of force, collision type, road and environmental conditions at impact.
- Post-crash but outcome-independent (borderline): extraction time, transport mode, hospital arrival interval. These are known after the crash but are not derived from the injury itself; they are usually excluded anyway because they are unavailable at prediction time in a real deployment.
- Outcome-derived (forbidden): MAIS and any AIS body-region score, ISS, hospital length of stay, ICU admission, discharge disposition, fatality flag, treatment codes, claims payout, and any field computed by an investigator after examining the occupant.
The forbidden tier is where leakage hides. A field like hospital length of stay is a near-perfect proxy for severity and will inflate validation accuracy to implausible levels while being useless at the roadside. The exclusion must be documented per column, not applied as a blanket drop, so a reviewer can audit the reasoning.
Step 3 — Enforce the Exclusion with an Auditable Manifest
The exclusion should live in code as an explicit, reviewable list rather than an ad-hoc drop call. Each entry carries a reason, so the manifest doubles as the documentation the goal requires.
EXCLUSION_MANIFEST = {
# outcome-derived: forbidden as features
"MAIS": "source of the label; outcome-derived",
"ISS": "injury severity score; computed from AIS, outcome-derived",
"AIS_head": "body-region AIS; outcome-derived",
"AIS_chest": "body-region AIS; outcome-derived",
"hospital_los": "length of stay; post-outcome proxy",
"icu_admit": "ICU admission flag; post-outcome proxy",
"discharge_status":"disposition; post-outcome proxy",
"fatality_flag": "direct outcome indicator",
"treatment_codes": "assigned during treatment; outcome-derived",
# identifiers / non-predictive
"occupant_id": "identifier, not a feature",
"case_id": "identifier, not a feature",
}
def select_safe_features(df: pd.DataFrame,
manifest: dict = EXCLUSION_MANIFEST) -> list:
"""Return the leakage-safe feature columns and log every exclusion."""
excluded = [c for c in df.columns if c in manifest]
safe = [c for c in df.columns if c not in manifest]
print(f"excluded {len(excluded)} columns:")
for c in excluded:
print(f" - {c}: {manifest[c]}")
print(f"retained {len(safe)} safe features")
return safe
# safe_cols = select_safe_features(raw_df)
# X = raw_df[safe_cols]
Run this against the audited schema from the previous case. The printed exclusion log is the deliverable: it states, column by column, why each field was removed. A reviewer can now challenge a specific reason rather than guess at intent.
Verification Before Moving On
Three checks confirm the label and feature set are sound. First, the label prevalence table shows class 2 as the smallest class and no class is empty. Second, the exclusion log lists every outcome-derived field with a stated reason and no forbidden column appears in safe_cols. Third, a quick missingness-versus-label cross-tab on retained features shows no feature whose absence is strongly associated with the severe class. When all three pass, the label and feature set are ready to feed the stratified split in the next case.