preprocess-imaging
Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake, resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe before model-scaffold builds the training repo. Emits a declarative preprocessing manife
Install
npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/preprocess-imaging
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
git clone https://github.com/Aperivue/medsci-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Preprocess-Imaging Skill
Purpose
This skill designs and audits the data-preparation stage of a medical-imaging model — the stage before a training repo is built — and proves it is leakage-safe by construction. Data leakage enters one step earlier than the split table can see: a normaliser fit on the whole dataset, a data-fitted transform run before the split exists, or a patient whose slices land in more than one partition. Each silently inflates every downstream metric (Kapoor & Narayanan, Patterns 2023; Varoquaux & Cheplygina, npj Digit Med 2022; CLAIM 2024 data items).
It is the missing first link in the lane: preprocess-imaging (prepare + audit) →
/model-scaffold (build) → /model-validation (validate the split) → /model-evaluation +
/analyze-stats (metrics) → /write-paper + /check-reporting (publish). It integrates
MONAI / TorchIO transforms (referenced in the emitted plan); it does not reimplement them, and it
never executes preprocessing on real patient data.
When to use
- You have a data manifest (one row per image/slice with a patient/subject ID) and want a leakage-safe preprocessing plan + a machine-checkable manifest before scaffolding a model.
- You want to audit an existing preprocessing pipeline for data-stage leakage.
When NOT to use
- Auditing the train/val/test split table itself →
/model-validation(split-leakage gate). - Building the training repo / model code →
/model-scaffold(it consumes this manifest). - Choosing the architecture →
/architecture-zoo. - Held-out metrics / calibration →
/model-evaluationthen/analyze-stats. - Reimplementing MONAI / TorchIO transforms → out of scope (this skill wires and audits them).
Workflow
Phase 1 — Inventory the data and the intended steps
Collect: modality (CT / MR / X-ray / US / path), the data manifest (one row per image/slice with a
patient_id), the intended resample spacing, the intensity transform (fixed HU window vs a fitted
z-score / min-max / histogram match), and the augmentation plan. See
references/preprocessing_guide.md for modality-aware guidance
(what normalisation is standard per modality, which augmentations preserve vs break physiology).
Phase 2 — Decide fit scope and order (the leakage-safe rules)
- Fit dataset-level normalisation on the training split only — never on all/full/test.
- Run any data-fitted transform AFTER the split — before the split there is no train/test distinction, so the fit spans partitions.
- Prefer per-image (per-sample) normalisation where clinically appropriate: it uses only that image's own statistics and is leakage-free even before the split.
- Keep augmentation train-only — augmenting val/test folds undisclosed test-time augmentation into the reported metric.
- Split at the patient level, then map slices to their patient's split (never split slices).
Phase 3 — Emit the preprocessing manifest
Write a declarative JSON manifest that model-scaffold consumes and the gate checks:
{
"split_seed": 42,
"transforms": [
{"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"},
{"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"},
{"name": "flip_rotate", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]}
],
"split_assignment": [
{"patient_id": "P001", "unit_id": "P001_s1", "split": "train"}
]
}
fit_scope: train (OK) · all/full/dataset/test (leak) · sample/per_image/none/fixed
(not data-fitted, leakage-free). stage: before_split / after_split.
Declare the fit scope of resampling too. A target spacing you chose in advance is fixed and
never leaks (fit_scope: fixed). A target derived from the cohort does: nnU-Net sets its target
spacing from a percentile of the dataset fingerprint, so a resample fitted over every case carries
held-out geometry into the training grid exactly as an intensity statistic would. Which one you
have is decided by the fingerprint's scope, not by the word "resample".
Phase 4 — Gate the manifest (deterministic)
python3 scripts/check_preprocessing_leakage.py --manifest preprocessing_manifest.json --strict
That gate asks whether a transform was fit on the right scope. Before an inference run on a cohort the model was not trained on, ask the other question — is that cohort in the intensity domain the trained normaliser assumes?
python3 scripts/check_normalizer_domain.py \
--profile eda/<cohort>_profile.json \
--contract work/nnUNet_results/.../plans.json \
--splits external_mri --out qc/normalizer_domain.json --strict
Verdicts: PREPROCESS_BEFORE_SPLIT, NORMALIZATION_LEAKAGE, PATIENT_CROSS_SPLIT (Major);
AUGMENTATION_ON_EVAL, UNSPECIFIED_FIT_SCOPE, MISSING_SEED (Minor). The verdict is reproduced
by set arithmetic + rule on the manifest, never asserted from prose. A green gate is a precondition
for handing the manifest to /model-scaffold.
Integration
- Feeds
/model-scaffold— the audited manifest is the scaffold's preprocessing input; itssplit_assignmentis the same patient-level split/model-validationlater re-verifies. /self-reviewmodel_developmentprobe audits data-stage leakage in a finished manuscript; this skill produces the leakage-safe pipeline it looks for./check-reporting— the manifest documents the CLAIM 2024 / TRIPOD+AI data-preprocessing items.
Anti-Hallucination
- Never fabricate image statistics, patient IDs, or split assignments. Every value in the manifest comes from the real data manifest and the researcher's declared pipeline — never invented. This skill designs and audits the plan; it does not run preprocessing on real patient data or synthesise the images it describes.
- Never report a preprocessing-audit "pass" without running
check_preprocessing_leakage.py. The leakage verdict is reproduced deterministically (rule + set arithmetic on the manifest), never asserted from prose. - Never label a dataset-fitted transform as per-sample to clear the gate. The manifest's
type/fit_scope/stagemust describe what the code actually does; a mislabelled transform hides a real leak the gate would otherwise catch. - Integrate, don't reimplement. Reference MONAI / TorchIO transforms; do not write a new normalisation/resampling implementation or claim results for one.
Reproducible challenge
scripts/check_normalizer_domain_challenge/ ships a synthetic profile/contract triple: a cohort in
the contract's own domain that must come back clean (the false-positive guard), an arbitrary-unit
cohort that must raise a Major, and an unreadable contract that must refuse rather than pass.
scripts/check_preprocessing_leakage_challenge/ ships a synthetic leak/clean manifest pair with a
network-free verify.sh wired into the skill's validation commands.
Files (medsci-skills)
-
references
-
preprocessing_guide.md 4.9 KB
# Medical-imaging preprocessing — modality-aware guidance Companion to `preprocess-imaging`. This is *produce* knowledge: what preprocessing is standard per modality, which augmentations preserve versus break physiology, and where leakage hides. It wires MONAI / TorchIO transforms by name; it does not reimplement them. ## 1. Intensity normalisation by modality | Modality | Standard intensity handling | Fitted on data? | Leakage risk | |---|---|---|---| | **CT** | Fixed HU window/level per task (e.g. lung −600/1500, soft-tissue 40/400), then scale to [0,1] or [−1,1] | **No** — fixed HU bounds are physical, not fitted | None (fixed transform) | | **MR** | Bias-field correction (N4) → intensity normalisation (z-score, or Nyúl/histogram matching to a reference) | **Yes** — z-score/Nyúl are fitted | Fit the reference/stats on **train only**; per-image z-score is leakage-free | | **X-ray** | Per-image min–max or z-score; optional CLAHE | Per-image = **no**; dataset z-score = yes | Prefer per-image; if dataset-level, train-only | | **Ultrasound** | Per-image normalisation; crop the vendor UI/annotation border | Per-image = **no** | Cropping is fixed; watch for burned-in PHI/annotations | | **Pathology (WSI)** | Stain normalisation (Macenko/Vahadane/Reinhard) to a reference tile | **Yes** — reference is fitted | Choose the reference from the **train** split only | **Rule of thumb:** a *fixed* transform (HU window, resample to a fixed spacing, fixed crop) never leaks. A *fitted* transform (z-score with dataset statistics, histogram/stain matching to a reference, PCA/whitening) leaks unless it is fit on the training split and applied after the split. ## 2. Resampling and geometry - Resample to a **fixed target spacing** (a task constant, not fitted) — e.g. 1×1×1 mm, or the dataset median spacing computed **once on train** and then frozen as a constant. - Register/reorient to a canonical orientation (e.g. RAS) before patch extraction. - Foreground cropping by a fixed intensity threshold is fixed (safe); cropping by a *fitted* body-mask model is a fitted transform — train-only. ## 3. Augmentation appropriateness (physiology-preserving vs breaking) Augmentation must keep the image clinically plausible and label-consistent. | Augmentation | Usually safe | Breaks physiology / label when… | |---|---|---| | Flip (L–R) | Most 2-D texture tasks | **Laterality matters** — cardiac silhouette, situs, side-labelled findings; flipping mislabels side | | Rotation (small) | Most tasks | Large rotations for orientation-dependent tasks (e.g. gravity-dependent effusion/air-fluid levels) | | Elastic / grid deformation | Soft-tissue segmentation | Rigid structures (bone fracture morphology); can invent/erase small lesions | | Intensity scale/shift, gamma | CT/MR within-modality | Beyond clinically plausible HU/contrast range; simulates a nonexistent scanner | | Gaussian noise / blur | Robustness to acquisition | Enough to hide the target finding (micro-nodule, microcalcification) | | Cutout / random erasing | General | Can erase the sole lesion in a positive case → label noise | | MixUp / CutMix | Some classification | Segmentation/detection where mixed pixels have no valid mask/box | **Apply augmentation to the training split only.** Augmenting val/test is test-time augmentation (TTA): legitimate only if pre-specified and disclosed, never folded silently into the headline metric. ## 4. Where leakage hides (the gate's targets) 1. **Normalisation fit on non-train data** — z-score/histogram/stain statistics computed over all/test data. → `NORMALIZATION_LEAKAGE`. Fit on train; apply the frozen statistics to val/test. 2. **Fitted transform before the split** — computing dataset statistics, then splitting. There is no train/test distinction yet, so the fit is cross-partition. → `PREPROCESS_BEFORE_SPLIT`. 3. **Patient slices across splits** — splitting *images* instead of *patients*, so a patient's slices sit in train and test. → `PATIENT_CROSS_SPLIT`. Split at the patient level, then map slices. 4. **Augmentation on eval** — see §3. → `AUGMENTATION_ON_EVAL`. 5. **Undeclared fit scope** — a fitted transform with no stated scope cannot be cleared. → `UNSPECIFIED_FIT_SCOPE`. Declare `fit_scope=train`. ## 5. Library wiring (integrate, don't reimplement) - **MONAI transforms** — `LoadImaged`, `Spacingd`, `ScaleIntensityRanged` (fixed HU window), `NormalizeIntensityd` (z-score; set on the train subset), `RandFlipd`/`RandAffined`/`RandGaussianNoised` (train pipeline only). - **TorchIO** — `Resample`, `ZNormalization`, `HistogramStandardization` (fit `landmarks` on train), `RandomFlip`/`RandomElasticDeformation` (train only). - **Stain normalisation (WSI)** — `torchstain` / `staintools` Macenko/Vahadane; fit the reference on a train tile. Record every step in the preprocessing manifest with its `type`, `fit_scope`, and `stage` so the gate can decide leakage deterministically.
-
-
scripts
-
check_normalizer_domain_challenge
-
expected
-
arbitrary_mismatch.txt 831 B
========================================= Normaliser-Domain Gate (preprocess-imaging) ========================================= contract: CTNormalization (assumes hounsfield), clip [-38, 174] | Check | Severity | Detail | |---|---|---| | NORMALIZER_DOMAIN_MISMATCH | Major | the contract applies CTNormalization, which assumes Hounsfield units, but 0 of 10 case(s) in 'external_mri' contain a voxel at or below -500. Hounsfield units are defined by an air floor near -1000; a cohort that never goes negative is not in them. Per-case minima range 0 to 0. | | NORMALIZER_SPLIT_DIVERGENCE | Flag | 6 of 10 case(s) in 'unlabelled_pool' bottom out near air and the rest do not — mixed modality, or a rescale applied to part of the cohort. One normalisation contract cannot be right for both. | MAJOR candidate: 1 domain issue(s). -
hu_clean.txt 364 B
========================================= Normaliser-Domain Gate (preprocess-imaging) ========================================= contract: CTNormalization (assumes hounsfield), clip [-38, 174] | Check | Severity | Detail | |---|---|---| | (none) | — | every split is in the domain the contract assumes | OK: every split is in the domain the contract assumes.
-
-
fixture
-
plan_ct.json 486 B
{ "_synthetic": "Not a real trained plan. Shape mimics an nnU-Net plans.json.", "configurations": { "3d_fullres": { "normalization_schemes": [ "CTNormalization" ], "spacing": [ 2.5, 0.8, 0.8 ] } }, "foreground_intensity_properties_per_channel": { "0": { "mean": 90.0, "std": 40.0, "percentile_00_5": -38.0, "percentile_99_5": 174.0, "min": -450.0, "max": 1040.0 } } } -
profile_arbitrary.json 4.4 KB
{ "_synthetic": "Not real patients. Ten synthetic MR-like cases with no air floor, plus a deliberately mixed pool where 6 of 10 bottom out near air.", "dataset": "synthetic mixed", "splits": [ { "name": "external_mri", "labelled": true }, { "name": "unlabelled_pool", "labelled": false } ], "cases": [ { "case": "mr_000", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_001", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_002", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_003", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_004", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_005", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_006", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_007", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_008", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mr_009", "split": "external_mri", "intensity": { "min": 0.0, "p01": 0.0, "p50": 120.0, "p99": 640.0, "max": 1180.0 } }, { "case": "mix_000", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_001", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_002", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_003", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_004", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_005", "split": "unlabelled_pool", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -60.0, "p99": 300.0, "max": 1400.0 } }, { "case": "mix_006", "split": "unlabelled_pool", "intensity": { "min": 0.0, "p01": 0.0, "p50": 90.0, "p99": 520.0, "max": 900.0 } }, { "case": "mix_007", "split": "unlabelled_pool", "intensity": { "min": 0.0, "p01": 0.0, "p50": 90.0, "p99": 520.0, "max": 900.0 } }, { "case": "mix_008", "split": "unlabelled_pool", "intensity": { "min": 0.0, "p01": 0.0, "p50": 90.0, "p99": 520.0, "max": 900.0 } }, { "case": "mix_009", "split": "unlabelled_pool", "intensity": { "min": 0.0, "p01": 0.0, "p50": 90.0, "p99": 520.0, "max": 900.0 } } ] } -
profile_hu.json 2.7 KB
{ "_synthetic": "Not real patients. Twelve synthetic CT cases, all with an air floor.", "dataset": "synthetic CT", "splits": [ { "name": "external_ct", "labelled": true } ], "cases": [ { "case": "ct_000", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_001", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_002", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_003", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_004", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_005", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_006", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_007", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_008", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_009", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_010", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } }, { "case": "ct_011", "split": "external_ct", "intensity": { "min": -1024.0, "p01": -1000.0, "p50": -80.0, "p99": 320.0, "max": 1500.0 } } ] }
-
-
problem.md 2.3 KB
# Challenge — the normaliser that was right for the data it was fit on, and wrong for the data it met A segmentation model is trained on CT. Its training plan records a normalisation contract: clip to the CT foreground window, then z-score by the CT mean and standard deviation. That contract travels with the checkpoint into inference, and the inference command has **no argument that declares the modality of the incoming images**. The model is then applied to an MR cohort. Every case returns a file. The job exits 0. ## The question Given a dataset profile with per-case intensity summaries and the trained contract, decide whether the cohort about to be predicted is in the intensity domain that contract assumes. ## What makes it hard The tempting check is the destructive one — "how much of each volume does the clip discard?" — and it is wrong. A CT volume's 99th percentile is bone, and clipping bone above a soft-tissue window is what the contract is *for*. That check fires on 100% of the cohort the plan was fit on. A detector that rejects its own training domain is worse than no detector, and a five-number summary cannot tell "the window is working" from "the window is destroying the image". The check that survives is narrower and decisive: **Hounsfield units are defined by an air floor near −1000.** A cohort in which no case contains a voxel below −500 is not in Hounsfield units, whatever any metadata field claims — and a CT contract applied to it is applying a window that means nothing there. ## What the fixtures encode - A cohort that IS in the contract's domain must come back clean. This is the false-positive guard, and it is the half most detectors of this kind fail. - A cohort with no air floor anywhere must raise a Major. - A split where some cases bottom out near air and some do not cannot be served by one contract, and is flagged. - A contract the loader cannot parse must **refuse**, not score as "assumes arbitrary" and pass. ## Provenance Derived from `demo/05_msd_amos_spleen`, where this exact mismatch cost a measured **~0.28 Dice** — established by two independent counterfactual arms, neither of which retrained the model. The toolkit's own profiler had already recorded the underlying property before training, as a **Minor**, in a directory no later step reads. The gap this card closes is routing and severity, not detection. -
verify.sh 3 KB
#!/usr/bin/env bash # Deterministic verifier for the normaliser-domain challenge card. # Runs check_normalizer_domain.py on two synthetic profiles against one synthetic CT contract and # diffs stdout against expected/. No network, no imaging library, no pixels — every verdict is # decided by comparing five summary numbers per case against the contract. Exit 0 = both match and # both exit codes are correct. # # Fixtures (synthetic only — no real patients, no PII): # profile_hu.json 12 cases, every one with an air floor near -1024. Under a CT contract # this MUST be clean: it is exactly the cohort the contract was fit on, # and a check that rejects its own training domain is worse than none. # profile_arbitrary.json 10 cases with no negative voxel at all (NORMALIZER_DOMAIN_MISMATCH, # Major) plus a pool where 6 of 10 bottom out near air and the rest do # not (NORMALIZER_SPLIT_DIVERGENCE, Flag). # plan_ct.json an nnU-Net-shaped plan declaring CTNormalization. # # The third case this card guards is the contract loader: an unreadable contract must FAIL rather # than score as "assumes arbitrary" and return OK. A gate that passes on input it could not read # looks exactly like a gate that passed. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_normalizer_domain.py" clean="$(python3 "$DET" --profile "$HERE/fixture/profile_hu.json" --contract "$HERE/fixture/plan_ct.json")" bad="$( python3 "$DET" --profile "$HERE/fixture/profile_arbitrary.json" --contract "$HERE/fixture/plan_ct.json")" ok=1 diff -u "$HERE/expected/hu_clean.txt" <(printf '%s\n' "$clean") || { echo "FAIL: HU fixture drifted" >&2; ok=0; } diff -u "$HERE/expected/arbitrary_mismatch.txt" <(printf '%s\n' "$bad") || { echo "FAIL: mismatch fixture drifted" >&2; ok=0; } python3 "$DET" --profile "$HERE/fixture/profile_hu.json" --contract "$HERE/fixture/plan_ct.json" --strict >/dev/null 2>&1 && rc_clean=0 || rc_clean=$? python3 "$DET" --profile "$HERE/fixture/profile_arbitrary.json" --contract "$HERE/fixture/plan_ct.json" --strict >/dev/null 2>&1 && rc_bad=0 || rc_bad=$? [ "$rc_clean" -eq 0 ] || { echo "FAIL: the contract's own CT domain must exit 0 (got $rc_clean)" >&2; ok=0; } [ "${rc_bad:-0}" -eq 1 ] || { echo "FAIL: the domain mismatch must exit 1 under --strict (got ${rc_bad:-0})" >&2; ok=0; } # an unreadable contract must refuse, not quietly pass echo '{"not_a_contract": true}' > "$HERE/.tmp_bad_contract.json" python3 "$DET" --profile "$HERE/fixture/profile_hu.json" --contract "$HERE/.tmp_bad_contract.json" >/dev/null 2>&1 && rc_unread=0 || rc_unread=$? rm -f "$HERE/.tmp_bad_contract.json" [ "${rc_unread:-0}" -ne 0 ] || { echo "FAIL: an unreadable contract must not return success" >&2; ok=0; } if [ "$ok" -eq 1 ]; then echo "PASS: the gate clears its own CT domain, flags an arbitrary-unit cohort and a mixed split, and refuses an unreadable contract." else exit 1 fi
-
-
check_preprocessing_leakage_challenge
-
expected
-
clean.txt 370 B
========================================= Preprocessing-Leakage Gate (preprocess-imaging) ========================================= transforms=4 units=5 patients=5 partitions={'test': 2, 'train': 2, 'val': 1} seed=7 | Check | Severity | Detail | |---|---|---| | (none) | — | preprocessing manifest is leakage-safe | OK: preprocessing manifest is leakage-safe. -
leak.txt 1 KB
========================================= Preprocessing-Leakage Gate (preprocess-imaging) ========================================= transforms=4 units=6 patients=5 partitions={'test': 2, 'train': 3, 'val': 1} seed=42 | Check | Severity | Detail | |---|---|---| | NORMALIZATION_LEAKAGE | Major | data-fitted transform 'hist_match_to_atlas' (histogram_match) is fit on a non-train scope ('all'); test-set statistics leak into training | | PREPROCESS_BEFORE_SPLIT | Major | data-fitted transform 'dataset_zscore' (standardize) runs before the split (stage=before_split); the fit spans train and test | | AUGMENTATION_ON_EVAL | Minor | augmentation 'random_flip' is applied to test; train-time augmentation on an evaluation split folds undisclosed test-time augmentation into the reported metric | | PATIENT_CROSS_SPLIT | Major | 1 of 5 patients have units in >= 2 splits (e.g. 'P03' in test/train); the same patient in train and test inflates every metric. Offenders: P03 | MAJOR candidate: 3 preprocessing-leakage issue(s).
-
-
fixture
-
manifest_clean.json 800 B
{ "split_seed": 7, "transforms": [ {"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"}, {"name": "per_image_norm", "type": "normalization", "fit_scope": "sample", "stage": "before_split"}, {"name": "random_flip", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]}, {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"} ], "split_assignment": [ {"patient_id": "P01", "unit_id": "P01_s1", "split": "train"}, {"patient_id": "P02", "unit_id": "P02_s1", "split": "train"}, {"patient_id": "P03", "unit_id": "P03_s1", "split": "validation"}, {"patient_id": "P04", "unit_id": "P04_s1", "split": "test"}, {"patient_id": "P05", "unit_id": "P05_s1", "split": "test"} ] } -
manifest_leak.json 881 B
{ "split_seed": 42, "transforms": [ {"name": "hist_match_to_atlas", "type": "histogram_match", "fit_scope": "all", "stage": "after_split"}, {"name": "dataset_zscore", "type": "standardize", "fit_scope": "train", "stage": "before_split"}, {"name": "random_flip", "type": "augmentation", "stage": "after_split", "applies_to": ["train", "test"]}, {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"} ], "split_assignment": [ {"patient_id": "P01", "unit_id": "P01_s1", "split": "train"}, {"patient_id": "P02", "unit_id": "P02_s1", "split": "train"}, {"patient_id": "P03", "unit_id": "P03_s1", "split": "train"}, {"patient_id": "P03", "unit_id": "P03_s2", "split": "test"}, {"patient_id": "P04", "unit_id": "P04_s1", "split": "validation"}, {"patient_id": "P05", "unit_id": "P05_s1", "split": "test"} ] }
-
-
problem.md 903 B
# Challenge — preprocessing-leakage gate A medical-imaging preprocessing pipeline can leak the test distribution into training one stage before the split gate can see it. Given a declarative preprocessing manifest (JSON), `check_preprocessing_leakage.py` must decide — by rule and by set arithmetic on the patient IDs, not from prose — whether the pipeline is leakage-safe. ## Task Run the gate on the two synthetic manifests in `fixture/` and reproduce `expected/`: - `manifest_leak.json` → three Major verdicts (`NORMALIZATION_LEAKAGE`, `PREPROCESS_BEFORE_SPLIT`, `PATIENT_CROSS_SPLIT`) plus a Minor `AUGMENTATION_ON_EVAL`; exit 1 under `--strict`. - `manifest_clean.json` → no claims; exit 0. Note the per-image normalisation runs before the split yet does **not** fire: a per-sample transform is leakage-free. ## Verify ```bash bash verify.sh # deterministic, network-free ``` -
verify.sh 2.1 KB
#!/usr/bin/env bash # Deterministic verifier for the preprocessing-leakage challenge card. # Runs check_preprocessing_leakage.py on two synthetic preprocessing manifests and # diffs stdout against expected/. No network, no torch — every leak is decided by # rule + set arithmetic on the manifest. Exit 0 = both match and exit codes correct. # # Fixtures (synthetic only — no real patients, no PII): # manifest_leak.json — 4 leaks: a histogram_match fit on 'all' (NORMALIZATION_LEAKAGE), # a dataset standardize before the split (PREPROCESS_BEFORE_SPLIT), # augmentation applied to test (AUGMENTATION_ON_EVAL), and patient # P03 in train+test (PATIENT_CROSS_SPLIT). # manifest_clean.json — train-only z-score after split, per-image norm (leakage-free even # before split), augmentation on train only, fixed HU window, and a # disjoint patient split. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_preprocessing_leakage.py" leak="$(python3 "$DET" --manifest "$HERE/fixture/manifest_leak.json")" clean="$(python3 "$DET" --manifest "$HERE/fixture/manifest_clean.json")" ok=1 if ! diff -u "$HERE/expected/leak.txt" <(printf '%s\n' "$leak"); then echo "FAIL: leak-fixture output drifted from expected/leak.txt" >&2; ok=0 fi if ! diff -u "$HERE/expected/clean.txt" <(printf '%s\n' "$clean"); then echo "FAIL: clean-fixture output drifted from expected/clean.txt" >&2; ok=0 fi python3 "$DET" --manifest "$HERE/fixture/manifest_leak.json" --strict --quiet >/dev/null 2>&1 && rc_leak=0 || rc_leak=$? python3 "$DET" --manifest "$HERE/fixture/manifest_clean.json" --strict --quiet >/dev/null 2>&1 && rc_clean=0 || rc_clean=$? [ "${rc_leak:-0}" -eq 1 ] || { echo "FAIL: leak fixture should exit 1 under --strict (got ${rc_leak:-0})" >&2; ok=0; } [ "$rc_clean" -eq 0 ] || { echo "FAIL: clean fixture should exit 0 under --strict (got $rc_clean)" >&2; ok=0; } if [ "$ok" -eq 1 ]; then echo "PASS: preprocessing-leakage gate flags the 3 Major leaks + 1 augmentation-on-eval flag and clears the safe manifest." else exit 1 fi
-
-
check_normalizer_domain.py 10.1 KB
#!/usr/bin/env python3 """Normaliser-domain gate: is the cohort you are about to predict in the domain the trained normalisation contract assumes? `check_preprocessing_leakage.py` asks whether a data-fitted transform was fit on the right SCOPE (train only, after the split). This asks a different question, at a different moment: at inference, is the cohort being handed to a trained normaliser in the INTENSITY DOMAIN that normaliser was fit on? Nothing else in this toolkit asks it, and nothing carries a profiling-stage observation forward to the moment of use. **Why this exists.** In demo/05_msd_amos_spleen, a segmentation model trained on CT was applied to MRI. The training plan carried `CTNormalization` into inference and the inference command had no argument declaring the incoming modality, so a Hounsfield-unit clip was applied to arbitrary-unit images. Median Dice was 0.0152. Two independent counterfactual arms — rescale the input, or swap the normaliser — each recovered it to ~0.29 without retraining, so roughly **0.28 Dice** was the domain mismatch alone. The run exited 0 and wrote a plausible-looking segmentation for every case. The profiler had already seen it. `/profile-imaging` flagged `INTENSITY_SCALE_INCONSISTENT` on that cohort *before training* — as a **Minor**, in a directory no later step reads. The gap this gate closes is therefore **routing and severity**, not detection: it re-reads an existing profile against the contract that will actually be applied, at the point where ignoring it costs something. Reads two JSON files and nothing else — no imaging library, no pixels, no model. stdlib only, so it travels with the artifacts. --profile a /profile-imaging profile (`profile_imaging_dataset.py` output) with per-case `intensity` {min, p01, p50, p99, max} and `split` --contract the normalisation contract that will be applied. Either an nnU-Net-style `plans.json` (the scheme and the foreground statistics are read from it) or a small JSON of the form {"scheme": "...", "assumes": "hounsfield"|"arbitrary"|"zero_mean", "clip": [lo, hi], "mean": m, "std": s}. Verdicts: NORMALIZER_DOMAIN_MISMATCH (Major) the contract assumes Hounsfield units and the split contains no negative voxels — HU is defined by an air floor near -1000, so a cohort that never goes negative is not in it NORMALIZER_SPLIT_DIVERGENCE (Flag) splits inside one cohort disagree about the intensity domain, so one contract cannot be right for all of them **A third check was written and deleted.** `NORMALIZER_CLIP_DESTRUCTIVE` compared each split's 99th percentile against the contract's clip ceiling. Run against the cohorts this repository already produces, it fired on **100% of the CT arm and 100% of the MSD training set** — the very data the plan was fit on. Of course it did: a CT volume's p99 is bone, and clipping bone above a soft-tissue window is what `CTNormalization` is *for*. A five-number summary cannot distinguish "the window is doing its job" from "the window is destroying the image", and a check that rejects its own training data is worse than no check. Deleted rather than tuned. """ from __future__ import annotations import argparse import json import sys from pathlib import Path HU_AIR_CEILING = -500.0 # a genuine HU volume has voxels below this (air is near -1000) def load(p: str) -> dict: try: return json.loads(Path(p).read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 print(f"FATAL: cannot read {p}: {exc}", file=sys.stderr) raise SystemExit(2) from exc def contract_from(obj: dict, channel: str = "0") -> dict: """Accept an nnU-Net plans.json or a small hand-written contract.""" if "configurations" in obj: # nnU-Net plans.json cfg = obj["configurations"].get("3d_fullres") or next(iter(obj["configurations"].values())) schemes = cfg.get("normalization_schemes") or [] scheme = schemes[0] if schemes else "unknown" props = (obj.get("foreground_intensity_properties_per_channel") or obj.get("foreground_intensity_properties_by_modality") or {}) s = props.get(channel, {}) return { "scheme": scheme, "assumes": "hounsfield" if "CT" in scheme else "arbitrary", "clip": [s.get("percentile_00_5"), s.get("percentile_99_5")] if s else None, "mean": s.get("mean"), "std": s.get("std"), } c = dict(obj) if "scheme" not in c: # Fail loudly rather than scoring an unparseable contract as "assumes arbitrary" and # returning OK. A gate that passes on input it could not read looks exactly like a gate # that passed — this one refuses instead. raise SystemExit( "FATAL: --contract is neither an nnU-Net plans.json (no 'configurations' key) nor a " "contract object (no 'scheme' key). Refusing to evaluate a contract I cannot read; " "pass the plans.json the model was trained under, or a JSON with at least " '{"scheme": "...", "assumes": "hounsfield"|"arbitrary"}.') c.setdefault("assumes", "hounsfield" if "CT" in str(c.get("scheme", "")) else "arbitrary") return c def by_split(profile: dict) -> dict[str, list[dict]]: out: dict[str, list[dict]] = {} for case in profile.get("cases", []): if "intensity" not in case: continue out.setdefault(case.get("split", "(unsplit)"), []).append(case) return out def main() -> int: ap = argparse.ArgumentParser(description="Does the cohort match the normaliser's assumed domain?") ap.add_argument("--profile", required=True) ap.add_argument("--contract", required=True) ap.add_argument("--channel", default="0") ap.add_argument("--splits", nargs="*", default=None, help="restrict to these split names (default: every labelled split)") ap.add_argument("--out") ap.add_argument("--strict", action="store_true", help="exit 1 when a Major is raised") a = ap.parse_args() profile = load(a.profile) contract = contract_from(load(a.contract), a.channel) groups = by_split(profile) if a.splits: groups = {k: v for k, v in groups.items() if k in a.splits} if not groups: print("FATAL: no per-case intensity found in the profile", file=sys.stderr) return 2 claims, domains = [], {} for split, cases in sorted(groups.items()): mins = [c["intensity"].get("min") for c in cases if c["intensity"].get("min") is not None] if not mins: continue n_air = sum(1 for m in mins if m <= HU_AIR_CEILING) looks_hu = n_air == len(mins) domains[split] = "hounsfield" if looks_hu else ("mixed" if n_air else "non-hounsfield") if contract.get("assumes") == "hounsfield" and n_air == 0: claims.append({ "verdict": "NORMALIZER_DOMAIN_MISMATCH", "severity": "Major", "split": split, "detail": (f"the contract applies {contract.get('scheme')}, which assumes Hounsfield " f"units, but 0 of {len(mins)} case(s) in '{split}' contain a voxel at or " f"below {HU_AIR_CEILING:.0f}. Hounsfield units are defined by an air " f"floor near -1000; a cohort that never goes negative is not in them. " f"Per-case minima range {min(mins):.0f} to {max(mins):.0f}."), }) elif contract.get("assumes") == "hounsfield" and 0 < n_air < len(mins): claims.append({ "verdict": "NORMALIZER_SPLIT_DIVERGENCE", "severity": "Flag", "split": split, "detail": (f"{n_air} of {len(mins)} case(s) in '{split}' bottom out near air and the " f"rest do not — mixed modality, or a rescale applied to part of the " f"cohort. One normalisation contract cannot be right for both."), }) if len({d for d in domains.values() if d != "mixed"}) > 1: claims.append({ "verdict": "NORMALIZER_SPLIT_DIVERGENCE", "severity": "Flag", "split": "(cohort)", "detail": (f"splits disagree about the intensity domain ({domains}); a single trained " f"contract cannot be correct for all of them."), }) n_major = sum(1 for c in claims if c["severity"] == "Major") report = { "detector": "check_normalizer_domain", "profile": a.profile, "contract": a.contract, "contract_read": contract, "splits_examined": {k: {"n": len(v), "domain": domains.get(k)} for k, v in groups.items()}, "claims": claims, "summary": {"n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "OK" if not claims else ("MAJOR" if n_major else "FLAG")}, } print("=" * 41) print(" Normaliser-Domain Gate (preprocess-imaging)") print("=" * 41) print(f" contract: {contract.get('scheme')} (assumes {contract.get('assumes')})" + (f", clip [{contract['clip'][0]:.0f}, {contract['clip'][1]:.0f}]" if contract.get("clip") and None not in contract["clip"] else "")) print("| Check | Severity | Detail |") print("|---|---|---|") if claims: for c in claims: print(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") else: print("| (none) | — | every split is in the domain the contract assumes |") print() print(f"MAJOR candidate: {n_major} domain issue(s)." if n_major else ("FLAG: review the divergence above." if claims else "OK: every split is in the domain the contract assumes.")) if a.out: Path(a.out).parent.mkdir(parents=True, exist_ok=True) Path(a.out).write_text(json.dumps(report, indent=2) + "\n") print(f"\nwrote {a.out}") return 1 if (a.strict and n_major) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_preprocessing_leakage.py 13.8 KB
#!/usr/bin/env python3 """Data-stage preprocessing-leakage gate for a medical-imaging pipeline (preprocess-imaging). `model-validation`'s split-leakage gate proves the train/val/test *split* is patient-disjoint. But leakage also enters one stage earlier — in **preprocessing** — and the split table cannot see it. The three classic data-stage leaks (Kapoor & Narayanan, Patterns 2023; Varoquaux & Cheplygina, npj Digit Med 2022; CLAIM 2024 data-partition/preprocessing items) are: 1. fitting a dataset-level normalisation / scaler on data that is NOT the training split (the test intensity distribution leaks into training); 2. running any data-fitted transform BEFORE the split exists (there is no train/test distinction yet, so the fit is inherently cross-partition); 3. the same patient's slices landing in more than one split (slice-level overlap that a per-image manifest hides). This gate reads a declarative **preprocessing manifest** (JSON — the artifact this skill emits, or one the researcher writes) and decides each of these by rule and by set arithmetic on the patient IDs, not from prose. A *per-image* / *per-sample* transform (each image normalised by its own statistics) is leakage-free and never fires; only a *dataset-fitted* transform can leak. CHECKS (verdicts): 1. PREPROCESS_BEFORE_SPLIT (Major) a data-fitted transform runs before the split (stage=before_split) — the fit spans partitions. 2. NORMALIZATION_LEAKAGE (Major) a data-fitted transform is fit on a non-train scope (all/full/dataset/test/both) after the split. 3. PATIENT_CROSS_SPLIT (Major) a patient_id whose units appear in >= 2 splits. 4. AUGMENTATION_ON_EVAL (Minor) an augmentation is applied to val/test (train-time augmentation folded into evaluation / undisclosed TTA). 5. UNSPECIFIED_FIT_SCOPE (Minor) a data-fitted transform declares no fit_scope — the leak cannot be ruled out; declare train-only. 6. MISSING_SEED (Minor) no split_seed — the split cannot be regenerated. A data-fitted transform is one whose `type` is a fitted operation (normalization, standardize, scaler, min-max, clip_percentile, histogram_match, pca, whitening, feature_selection, resample, …) AND whose `fit_scope` is not per-sample/none/fixed. A genuinely fixed transform (a fixed HU window, a resample to a spacing you chose in advance) is not data-fitted and never leaks — declare `fit_scope: fixed` and it stays silent. Resampling is on that list because the *target* is so often derived rather than chosen: nnU-Net sets its target spacing from a percentile of the dataset fingerprint, so a resample fitted over every case carries held-out geometry into the training grid just as an intensity statistic would. "Resample to a fixed spacing never leaks" is true only when the spacing really is fixed. MANIFEST (JSON) { "split_seed": 42, "transforms": [ {"name": "...", "type": "standardize", "fit_scope": "train", "stage": "after_split"}, {"name": "...", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]} ], "split_assignment": [ {"patient_id": "P001", "unit_id": "P001_s1", "split": "train"}, ... ] } fit_scope : train / training / train_val / dev -> OK; all / full / dataset / test / both / combined -> leak; sample / per_image / instance / none -> not data-fitted. stage : before_split / after_split (synonyms: pre-split / post-split). split : train/val/test synonyms collapse (training/validation/holdout/…). INPUTS --manifest preprocessing manifest JSON (required). OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manifest, n_transforms, n_units, n_patients, partitions{name:count}, seed, claims[{verdict, severity, detail, where}], summary} PREPROCESS_BEFORE_SPLIT / NORMALIZATION_LEAKAGE / PATIENT_CROSS_SPLIT are Major. Stdlib-only (json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import sys from pathlib import Path # Transform types whose parameters are FIT from data (so their fit scope matters). FIT_BASED_TYPES = { "normalization", "normalize", "normalisation", "standardize", "standardise", "standardization", "zscore", "z-score", "z_score", "scaler", "scaling", "minmax", "min-max", "min_max", "clip_percentile", "percentile_clip", "percentile_clipping", "histogram_match", "histogram_matching", "histogram_equalization", "histogram_equalisation", "pca", "whitening", "feature_selection", "intensity_normalization", "intensity_normalisation", "nyul", "zca", # Resampling belongs here whenever the *target* is derived from the data. nnU-Net's # target spacing is a percentile of the dataset fingerprint, so a resample fitted over # all cases carries held-out geometry into the training grid exactly as an intensity # statistic would. A target that is genuinely fixed declares fit_scope=fixed and drops # out in _is_fit_based -- that is the case the docstring used to describe as if it # were the only one. "resample", "resampling", "respacing", "resample_spacing", "target_spacing", "spacing_normalization", "spacing_normalisation", } # fit_scope values that make a transform NOT data-fitted (per-sample or fixed). SAMPLE_SCOPES = { "sample", "per_sample", "per-sample", "per_image", "per-image", "perimage", "instance", "per_instance", "none", "fixed", "self", } # fit_scope values that leak (fit touches non-training data). NON_TRAIN_SCOPES = { "all", "full", "dataset", "entire", "everything", "combined", "both", "test", "testing", "holdout", "hold-out", "trainvaltest", "train_val_test", "train+test", "all_data", "whole", } TRAIN_OK_SCOPES = { "train", "training", "train_val", "trainval", "train+val", "development", "dev", "fold_train", "train_only", } BEFORE_STAGES = {"before_split", "before-split", "pre_split", "pre-split", "before", "pre"} SPLIT_SYNONYM = { "train": "train", "training": "train", "val": "val", "validation": "val", "valid": "val", "dev": "val", "test": "test", "testing": "test", "holdout": "test", "hold-out": "test", "eval": "test", "evaluation": "test", } EVAL_SPLITS = {"val", "test"} def _norm(s) -> str: return str(s).strip().lower() if s is not None else "" def _is_fit_based(t: dict) -> bool: typ = _norm(t.get("type")) scope = _norm(t.get("fit_scope")) if typ not in FIT_BASED_TYPES: return False if scope in SAMPLE_SCOPES: return False return True def check(manifest: dict) -> list[dict]: claims: list[dict] = [] transforms = manifest.get("transforms") or [] for t in transforms: name = t.get("name") or t.get("type") or "(unnamed)" typ = _norm(t.get("type")) scope = _norm(t.get("fit_scope")) stage = _norm(t.get("stage")) if _is_fit_based(t): if stage in BEFORE_STAGES: claims.append({ "verdict": "PREPROCESS_BEFORE_SPLIT", "severity": "Major", "detail": (f"data-fitted transform '{name}' ({typ}) runs before the split " f"(stage=before_split); the fit spans train and test"), "where": name, }) elif scope in NON_TRAIN_SCOPES: claims.append({ "verdict": "NORMALIZATION_LEAKAGE", "severity": "Major", "detail": (f"data-fitted transform '{name}' ({typ}) is fit on a non-train " f"scope ('{scope}'); test-set statistics leak into training"), "where": name, }) elif scope not in TRAIN_OK_SCOPES: # data-fitted, after split, but fit_scope undeclared/unknown -> ambiguous claims.append({ "verdict": "UNSPECIFIED_FIT_SCOPE", "severity": "Minor", "detail": (f"data-fitted transform '{name}' ({typ}) declares no train-only " f"fit_scope ('{scope or 'missing'}'); declare fit_scope=train so " f"leakage can be ruled out"), "where": name, }) if typ in ("augmentation", "augment", "aug"): applies = [_norm(x) for x in (t.get("applies_to") or [])] applies = {SPLIT_SYNONYM.get(a, a) for a in applies} leaked = sorted(applies & EVAL_SPLITS) if leaked: claims.append({ "verdict": "AUGMENTATION_ON_EVAL", "severity": "Minor", "detail": (f"augmentation '{name}' is applied to {'/'.join(leaked)}; " f"train-time augmentation on an evaluation split folds " f"undisclosed test-time augmentation into the reported metric"), "where": name, }) # Patient-level cross-split (set arithmetic). rows = manifest.get("split_assignment") or [] pat_to_splits: dict[str, set] = {} for r in rows: pid = r.get("patient_id") or r.get("subject_id") or r.get("patient") or r.get("id") sp = SPLIT_SYNONYM.get(_norm(r.get("split")), _norm(r.get("split"))) if pid is None or not sp: continue pat_to_splits.setdefault(str(pid), set()).add(sp) offenders = sorted(p for p, s in pat_to_splits.items() if len(s) >= 2) if offenders: ex = offenders[0] claims.append({ "verdict": "PATIENT_CROSS_SPLIT", "severity": "Major", "detail": (f"{len(offenders)} of {len(pat_to_splits)} patients have units in " f">= 2 splits (e.g. '{ex}' in {'/'.join(sorted(pat_to_splits[ex]))}); " f"the same patient in train and test inflates every metric. " f"Offenders: {', '.join(offenders)}"), "where": ex, }) # Reproducibility. if manifest.get("split_seed") is None and rows: claims.append({ "verdict": "MISSING_SEED", "severity": "Minor", "detail": "no split_seed recorded; the split cannot be regenerated or re-verified", "where": "split_seed", }) return claims def analyze(manifest_path: str) -> dict: p = Path(manifest_path) if not p.is_file(): sys.stderr.write(f"ERROR: manifest not found: {manifest_path}\n") sys.exit(2) try: manifest = json.loads(p.read_text(encoding="utf-8")) except (json.JSONDecodeError, ValueError) as e: sys.stderr.write(f"ERROR: manifest is not valid JSON: {e}\n") sys.exit(2) if not isinstance(manifest, dict): sys.stderr.write("ERROR: manifest JSON must be an object\n") sys.exit(2) claims = check(manifest) rows = manifest.get("split_assignment") or [] partitions: dict[str, int] = {} patients: set = set() for r in rows: sp = SPLIT_SYNONYM.get(_norm(r.get("split")), _norm(r.get("split"))) if sp: partitions[sp] = partitions.get(sp, 0) + 1 pid = r.get("patient_id") or r.get("subject_id") or r.get("patient") or r.get("id") if pid is not None: patients.add(str(pid)) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manifest": str(p), "n_transforms": len(manifest.get("transforms") or []), "n_units": len(rows), "n_patients": len(patients), "partitions": dict(sorted(partitions.items())), "seed": manifest.get("split_seed"), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | preprocessing manifest is leakage-safe |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Data-stage preprocessing-leakage gate.") ap.add_argument("--manifest", required=True, help="preprocessing manifest JSON") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manifest) if not args.quiet: print("=" * 41) print(" Preprocessing-Leakage Gate (preprocess-imaging)") print("=" * 41) p = result print(f" transforms={p['n_transforms']} units={p['n_units']} " f"patients={p['n_patients']} partitions={p['partitions']} seed={p['seed']}") print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} preprocessing-leakage issue(s).") elif s["n_flag"]: print(f"MINOR flag: {s['n_flag']} preprocessing hygiene issue(s) (see table).") else: print("OK: preprocessing manifest is leakage-safe.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_preprocessing_leakage", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
test_preprocessing_leakage.sh 5 KB
#!/usr/bin/env bash # Regression test for the preprocessing-leakage gate (preprocess-imaging). # Synthetic, PII-free JSON manifests reproduce each verdict class. Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_preprocessing_leakage.py" CH="$HERE/../scripts/check_preprocessing_leakage_challenge" TMP="$(mktemp -d -t preproc_XXXX)" OUT="$TMP/out.json" trap 'rm -rf "$TMP"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } no_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert not any(c['verdict']=='$1' for c in d['claims']), '$1 unexpectedly present' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) leak fixture -> 3 Major verdicts + exit 1 python3 "$SCRIPT" --manifest "$CH/fixture/manifest_leak.json" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (leak manifest)" test "$?" -eq 1 check "PREPROCESS_BEFORE_SPLIT detected" has_verdict PREPROCESS_BEFORE_SPLIT check "NORMALIZATION_LEAKAGE detected" has_verdict NORMALIZATION_LEAKAGE check "PATIENT_CROSS_SPLIT detected" has_verdict PATIENT_CROSS_SPLIT check "AUGMENTATION_ON_EVAL detected" has_verdict AUGMENTATION_ON_EVAL # (2) clean fixture -> exit 0, no Major python3 "$SCRIPT" --manifest "$CH/fixture/manifest_clean.json" --strict --quiet >/dev/null 2>&1 check "exit 0 (clean manifest)" test "$?" -eq 0 # (3) data-fitted transform after split with NO fit_scope -> Minor UNSPECIFIED_FIT_SCOPE, exit 0 cat > "$TMP/unspec.json" <<'EOF' {"split_seed": 1, "transforms": [{"name": "scaler", "type": "minmax", "stage": "after_split"}], "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]} EOF python3 "$SCRIPT" --manifest "$TMP/unspec.json" --out "$OUT" --quiet >/dev/null 2>&1 check "UNSPECIFIED_FIT_SCOPE detected" has_verdict UNSPECIFIED_FIT_SCOPE python3 "$SCRIPT" --manifest "$TMP/unspec.json" --strict --quiet >/dev/null 2>&1 check "unspecified-scope is Minor (exit 0 under --strict)" test "$?" -eq 0 # (4) split present but no split_seed -> Minor MISSING_SEED cat > "$TMP/noseed.json" <<'EOF' {"transforms": [{"name": "z", "type": "standardize", "fit_scope": "train", "stage": "after_split"}], "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]} EOF python3 "$SCRIPT" --manifest "$TMP/noseed.json" --out "$OUT" --quiet >/dev/null 2>&1 check "MISSING_SEED detected" has_verdict MISSING_SEED # (5) per-image normalisation BEFORE split -> NOT a leak (sample scope is leakage-free) cat > "$TMP/persample.json" <<'EOF' {"split_seed": 3, "transforms": [{"name": "perimg", "type": "normalization", "fit_scope": "sample", "stage": "before_split"}], "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]} EOF python3 "$SCRIPT" --manifest "$TMP/persample.json" --out "$OUT" --quiet >/dev/null 2>&1 check "per-image transform before split does NOT fire PREPROCESS_BEFORE_SPLIT" no_verdict PREPROCESS_BEFORE_SPLIT python3 "$SCRIPT" --manifest "$TMP/persample.json" --strict --quiet >/dev/null 2>&1 check "exit 0 on per-image-safe manifest" test "$?" -eq 0 # (6) resampling. A target spacing chosen in advance is fixed and never leaks; a target # derived from the cohort (nnU-Net takes a percentile of the dataset fingerprint) is a # fitted parameter, and fitting it over every case carries held-out geometry into the # training grid exactly as an intensity statistic would. cat > "$TMP/resample_fitted.json" <<'EOF' {"split_seed": 42, "transforms": [{"name": "resample_to_fingerprint_median", "type": "resample", "fit_scope": "all", "stage": "before_split"}], "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]} EOF python3 "$SCRIPT" --manifest "$TMP/resample_fitted.json" --out "$OUT" --quiet >/dev/null 2>&1 check "data-derived resample target before split fires PREPROCESS_BEFORE_SPLIT" \ has_verdict PREPROCESS_BEFORE_SPLIT cat > "$TMP/resample_fixed.json" <<'EOF' {"split_seed": 42, "transforms": [{"name": "resample_to_1mm_iso", "type": "resample", "fit_scope": "fixed", "stage": "before_split"}], "split_assignment": [{"patient_id": "A", "split": "train"}, {"patient_id": "B", "split": "test"}]} EOF python3 "$SCRIPT" --manifest "$TMP/resample_fixed.json" --out "$OUT" --quiet >/dev/null 2>&1 check "resample to a declared fixed spacing does NOT fire PREPROCESS_BEFORE_SPLIT" \ no_verdict PREPROCESS_BEFORE_SPLIT python3 "$SCRIPT" --manifest "$TMP/resample_fixed.json" --strict --quiet >/dev/null 2>&1 check "exit 0 on fixed-spacing resample" test "$?" -eq 0 # (7) the shipped challenge card passes check "challenge verify.sh passes" bash "$CH/verify.sh" echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail"
-
-
SKILL.md 8.1 KB
--- name: preprocess-imaging description: > Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake, resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe before model-scaffold builds the training repo. Emits a declarative preprocessing manifest and a deterministic data-stage leakage gate that catches the leaks a split table cannot see: a dataset-level normaliser fit on non-train data, any data-fitted transform run before the split, and the same patient's slices crossing splits. Integrates MONAI / TorchIO transforms; it does not reimplement them, and it never runs preprocessing on real patient data. triggers: preprocess imaging, preprocessing, data pipeline, DICOM, NIfTI, resample, spacing, intensity normalization, intensity normalisation, windowing, HU window, z-score, histogram matching, augmentation, augmentation plan, TorchIO, MONAI transforms, data leakage, normalization leakage, preprocessing manifest, fit on train, per-image normalization, patient-level split, slice-level leakage, imaging data prep tools: Read, Write, Edit, Bash, Grep, Glob model: inherit --- # Preprocess-Imaging Skill ## Purpose This skill designs and audits the **data-preparation stage** of a medical-imaging model — the stage *before* a training repo is built — and proves it is **leakage-safe by construction**. Data leakage enters one step earlier than the split table can see: a normaliser fit on the whole dataset, a data-fitted transform run before the split exists, or a patient whose slices land in more than one partition. Each silently inflates every downstream metric (Kapoor & Narayanan, *Patterns* 2023; Varoquaux & Cheplygina, *npj Digit Med* 2022; CLAIM 2024 data items). It is the **missing first link** in the lane: **preprocess-imaging (prepare + audit)** → `/model-scaffold` (build) → `/model-validation` (validate the split) → `/model-evaluation` + `/analyze-stats` (metrics) → `/write-paper` + `/check-reporting` (publish). It **integrates** MONAI / TorchIO transforms (referenced in the emitted plan); it does not reimplement them, and it never executes preprocessing on real patient data. ## When to use - You have a data manifest (one row per image/slice with a patient/subject ID) and want a leakage-safe preprocessing plan + a machine-checkable manifest before scaffolding a model. - You want to audit an existing preprocessing pipeline for data-stage leakage. ## When NOT to use - Auditing the train/val/test split table itself → `/model-validation` (split-leakage gate). - Building the training repo / model code → `/model-scaffold` (it consumes this manifest). - Choosing the architecture → `/architecture-zoo`. - Held-out metrics / calibration → `/model-evaluation` then `/analyze-stats`. - Reimplementing MONAI / TorchIO transforms → out of scope (this skill wires and audits them). ## Workflow ### Phase 1 — Inventory the data and the intended steps Collect: modality (CT / MR / X-ray / US / path), the data manifest (one row per image/slice with a `patient_id`), the intended resample spacing, the intensity transform (fixed HU window vs a fitted z-score / min-max / histogram match), and the augmentation plan. See [`references/preprocessing_guide.md`](references/preprocessing_guide.md) for modality-aware guidance (what normalisation is standard per modality, which augmentations preserve vs break physiology). ### Phase 2 — Decide fit scope and order (the leakage-safe rules) - **Fit dataset-level normalisation on the training split only** — never on all/full/test. - **Run any data-fitted transform AFTER the split** — before the split there is no train/test distinction, so the fit spans partitions. - **Prefer per-image (per-sample) normalisation** where clinically appropriate: it uses only that image's own statistics and is leakage-free even before the split. - **Keep augmentation train-only** — augmenting val/test folds undisclosed test-time augmentation into the reported metric. - **Split at the patient level**, then map slices to their patient's split (never split slices). ### Phase 3 — Emit the preprocessing manifest Write a declarative JSON manifest that `model-scaffold` consumes and the gate checks: ```json { "split_seed": 42, "transforms": [ {"name": "hu_window", "type": "clip", "fit_scope": "none", "stage": "before_split"}, {"name": "train_zscore", "type": "standardize", "fit_scope": "train", "stage": "after_split"}, {"name": "flip_rotate", "type": "augmentation", "stage": "after_split", "applies_to": ["train"]} ], "split_assignment": [ {"patient_id": "P001", "unit_id": "P001_s1", "split": "train"} ] } ``` `fit_scope`: `train` (OK) · `all`/`full`/`dataset`/`test` (leak) · `sample`/`per_image`/`none`/`fixed` (not data-fitted, leakage-free). `stage`: `before_split` / `after_split`. **Declare the fit scope of resampling too.** A target spacing you chose in advance is fixed and never leaks (`fit_scope: fixed`). A target *derived* from the cohort does: nnU-Net sets its target spacing from a percentile of the dataset fingerprint, so a resample fitted over every case carries held-out geometry into the training grid exactly as an intensity statistic would. Which one you have is decided by the fingerprint's scope, not by the word "resample". ### Phase 4 — Gate the manifest (deterministic) ```bash python3 scripts/check_preprocessing_leakage.py --manifest preprocessing_manifest.json --strict ``` That gate asks whether a transform was fit on the right **scope**. Before an *inference* run on a cohort the model was not trained on, ask the other question — is that cohort in the intensity **domain** the trained normaliser assumes? ```bash python3 scripts/check_normalizer_domain.py \ --profile eda/<cohort>_profile.json \ --contract work/nnUNet_results/.../plans.json \ --splits external_mri --out qc/normalizer_domain.json --strict ``` Verdicts: `PREPROCESS_BEFORE_SPLIT`, `NORMALIZATION_LEAKAGE`, `PATIENT_CROSS_SPLIT` (Major); `AUGMENTATION_ON_EVAL`, `UNSPECIFIED_FIT_SCOPE`, `MISSING_SEED` (Minor). The verdict is reproduced by set arithmetic + rule on the manifest, never asserted from prose. A green gate is a precondition for handing the manifest to `/model-scaffold`. ## Integration - **Feeds `/model-scaffold`** — the audited manifest is the scaffold's preprocessing input; its `split_assignment` is the same patient-level split `/model-validation` later re-verifies. - **`/self-review`** `model_development` probe audits data-stage leakage in a finished manuscript; this skill *produces* the leakage-safe pipeline it looks for. - **`/check-reporting`** — the manifest documents the CLAIM 2024 / TRIPOD+AI data-preprocessing items. ## Anti-Hallucination - **Never fabricate image statistics, patient IDs, or split assignments.** Every value in the manifest comes from the real data manifest and the researcher's declared pipeline — never invented. This skill designs and audits the plan; it does not run preprocessing on real patient data or synthesise the images it describes. - **Never report a preprocessing-audit "pass" without running `check_preprocessing_leakage.py`.** The leakage verdict is reproduced deterministically (rule + set arithmetic on the manifest), never asserted from prose. - **Never label a dataset-fitted transform as per-sample to clear the gate.** The manifest's `type` / `fit_scope` / `stage` must describe what the code actually does; a mislabelled transform hides a real leak the gate would otherwise catch. - **Integrate, don't reimplement.** Reference MONAI / TorchIO transforms; do not write a new normalisation/resampling implementation or claim results for one. ## Reproducible challenge `scripts/check_normalizer_domain_challenge/` ships a synthetic profile/contract triple: a cohort in the contract's own domain that must come back **clean** (the false-positive guard), an arbitrary-unit cohort that must raise a Major, and an unreadable contract that must **refuse** rather than pass. `scripts/check_preprocessing_leakage_challenge/` ships a synthetic leak/clean manifest pair with a network-free `verify.sh` wired into the skill's validation commands. -
skill.yml 3.4 KB
schema_version: 2 name: preprocess-imaging layer: D owner_domain: model_validation maturity: official when_to_use: "Design or audit the data-preparation stage of a medical-imaging model — DICOM/NIfTI intake, resampling and intensity normalisation, and the augmentation plan — so the pipeline is leakage-safe BEFORE model-scaffold builds the training repo. Emits a declarative preprocessing manifest and a deterministic data-stage leakage gate that catches the leaks the split table cannot see: a dataset-level normaliser fit on non-train data, any data-fitted transform run before the split, and the same patient's slices crossing splits." when_NOT_to_use: "Auditing the train/val/test split table itself (use model-validation's split-leakage gate); scaffolding the training repo or model code (use model-scaffold — it consumes this manifest); choosing the architecture (use architecture-zoo); computing held-out metrics or calibration (use model-evaluation then analyze-stats); LLM/MLLM data handling (use mllm-eval); reimplementing MONAI/TorchIO transforms (out of scope — this skill wires and audits them, never rebuilds them)." inputs: - "data manifest (one row per image/slice with a patient/subject ID) and modality" - "intended preprocessing steps (resample spacing, intensity window/normalisation, augmentation plan)" - "split-assignment (patient/subject ID + train/val/test), when available, for the slice-level leakage check" outputs: - "a declarative preprocessing manifest (JSON: transforms with type/fit_scope/stage + split_assignment + split_seed) that model-scaffold consumes" - "preprocessing-leakage audit JSON (deterministic)" - "modality-aware augmentation-appropriateness notes (physiology-preserving vs breaking) and a normalisation fit-scope recommendation" deterministic_scripts: - scripts/check_preprocessing_leakage.py side_effects: - writes_decision_notes downstream_consumers: - model-scaffold - model-validation - check-reporting - self-review forbidden_actions: - fabricate_image_statistics_or_split_assignments - approve_a_pipeline_that_fits_normalisation_on_non_train_data - report_a_preprocessing_audit_pass_without_running_the_detector - reimplement_MONAI_or_TorchIO_transforms # v2.1 quality card purpose: "Catch data-stage leakage in a medical-imaging pipeline — a normaliser fit on the test distribution, a data-fitted transform run before the split, or a patient's slices spread across splits — before it silently inflates every downstream metric." safety_boundaries: - "Advisory plus deterministic-audit only: never alters images, statistics, or split assignments." - "The leakage verdict is reproduced by a stdlib script (rule + set arithmetic on the manifest), never asserted from prose." - "Integrates MONAI / TorchIO transforms by reference; it does not reimplement them and never runs preprocessing on real patient data." known_limitations: - "Audits the declared manifest, not the executed code; a transform mislabelled in the manifest (e.g. a dataset normaliser tagged fit_scope=sample) can hide a real leak." - "A clean data-stage audit is necessary, not sufficient — the split table (model-validation) and held-out evaluation (model-evaluation) still apply." validation_commands: - "python3 scripts/check_preprocessing_leakage.py --manifest <preprocessing_manifest.json> --strict" - "bash scripts/check_preprocessing_leakage_challenge/verify.sh # deterministic, network-free" evidence_surface: ci_validator
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.