Claude Skill

radiomics-ml

Produce or audit a radiomics / tabular clinical-ML study — imaging or clinical features → any classical learner (penalised logistic [LASSO / ridge / elastic-net], SVM, k-NN, naive Bayes, LDA/QDA, decision tree, random forest, gradient boosting [XGBoost / LightGBM / CatBoost], sha

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download aperivue-medsci-skills-skills_radiomics-ml-815765c.zip · 17 KB
Part of aperivue/medsci-skills — 47 skills

Install

skills CLI npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/radiomics-ml
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
Git 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

Radiomics / Classical-ML Skill

Purpose

Radiomics + tree-ensemble studies (features → random forest / XGBoost → a clinical outcome) are the most common solo-doable clinical-ML workflow — no GPU, no engineer — and the most commonly over-optimistic: hundreds-to-thousands of features on tens of patients, hyperparameters tuned on the same folds the performance is reported from, features selected on the whole dataset, unstable features never filtered, and discrimination (AUC) reported without calibration. This skill produces the pipeline correctly and audits an existing one, so the clinical result survives review (Lambin 2017; CLEAR; TRIPOD+AI; PROBAST-AI).

It sits beside the imaging-DL lane: where /model-scaffold builds a deep network, radiomics-ml covers the feature-based classical-ML path. It integrates scikit-learn / xgboost / pyradiomics (referenced in the emitted code); it does not reimplement them and never runs a model on real patient data.

When to use

  • You have a radiomics or clinical/tabular feature table and want to build a random-forest / XGBoost clinical prediction model that will pass statistical review.
  • You want to audit an existing radiomics/ML pipeline for the failure modes below.

When NOT to use

  • Deep-learning imaging models → /architecture-zoo → /model-scaffold → /model-validation.
  • Classical inferential statistics / a regression model as the estimand → /analyze-stats.
  • Interpretability of a trained network → /explainability.
  • Reimplementing scikit-learn / xgboost / pyradiomics → out of scope (this skill wires and audits them).

The failure modes (what the gate enforces)

  1. No nested CV. Tuning and reporting on the same folds inflates performance. Use nested CV or a held-out test set.
  2. High dimensionality, low events. Features ≥ events with no dimensionality reduction overfits — the classic radiomics trap. Apply LASSO / PCA / a stability + redundancy filter.
  3. Selection outside the fold. Feature selection fit on the whole dataset leaks the held-out folds. Nest selection inside each training fold.
  4. No feature stability. Radiomics features are unstable across acquisition/segmentation — filter to reproducible features (ICC / test-retest).
  5. No calibration. A clinical prediction model needs calibration (slope/intercept + a flexible curve), not discrimination alone.
  6. No external validation. A single-cohort model needs external / temporal validation for a clinical claim.

Workflow

Phase 1 — Extract features (integrate, don't reimplement)

For radiomics, extract with pyradiomics under reproducible, IBSI-aligned settings (fixed bin width, resampling, normalisation) — record them. For clinical/tabular data, assemble the feature table with a patient/subject ID and the outcome. See references/radiomics_ml_guide.md.

Phase 2 — Build the pipeline correctly

  • Feature stability — with test-retest / multi-rater data, keep features with ICC ≥ 0.75.
  • Nested cross-validation — outer folds estimate performance, inner folds tune; do feature selection and scaling inside each training fold (never on the whole dataset).
  • Dimensionality — with features ≥ events, use LASSO / a stability+redundancy filter / PCA.
  • Model — pick from the full classical family for the task; a simple baseline (penalised logistic) is mandatory alongside any complex learner:
    • penalised regression — LASSO / ridge / elastic-net logistic (also the baseline)
    • margin / kernel — linear or RBF SVM
    • instance-based — k-NN
    • probabilistic / discriminant — naive Bayes, LDA / QDA
    • trees & bagging — decision tree, random forest, extra-trees
    • boosting — XGBoost, LightGBM, CatBoost, HistGBM, AdaBoost
    • shallow neural — MLP
    • meta — stacking / voting ensembles
    • unsupervised (upstream) — PCA / UMAP for reduction, k-means / hierarchical / GMM for phenotyping The gate below is learner-agnostic — it audits the pipeline (nested CV, leakage, dimensionality, calibration), so it applies identically to any of these. See the full method map in docs/method_coverage_map.md.
  • Report — discrimination and calibration (slope/intercept + flexible curve, via the /analyze-stats calibration guide) and clinical utility (decision curve). SHAP for interpretation.

Phase 3 — Emit the pipeline manifest

{
  "task": "classification",
  "n_features": 1200, "n_samples": 300, "n_events": 110,
  "cv_scheme": "nested",
  "feature_selection_stage": "inside_cv",
  "dimensionality_reduction": true,
  "feature_stability": "icc",
  "calibration_reported": true,
  "external_validation": "temporal",
  "model": "xgboost"
}

Phase 4 — Gate the pipeline (deterministic)

python3 scripts/check_radiomics_ml.py --manifest pipeline_manifest.json --strict

Verdicts: NO_NESTED_CV, HIGH_DIM_LOW_EVENTS, SELECTION_OUTSIDE_CV (Major); NO_FEATURE_STABILITY, NO_CALIBRATION, NO_EXTERNAL_VALIDATION (Minor). Complements self-review's check_cv_leakage (which audits a finished manuscript's prose) at the pipeline-spec level.

Integration

  • /analyze-stats — calibration + clinical-utility (decision curve, NNT) guides for the reporting.
  • /check-reporting — CLEAR (radiomics), TRIPOD+AI, PROBAST-AI item coverage.
  • /self-review clinical_prediction_model probe audits the finished manuscript; this skill produces the rigorous pipeline it looks for.

Anti-Hallucination

  • Never fabricate features, performance metrics, or sample/event counts. Every value in the manifest and every reported metric comes from the researcher's executed code — never invented. This skill designs and audits the pipeline; it does not run a model on real patient data.
  • Never report flat-CV performance as if it were nested or held-out. Tuning on the reported folds is the optimism this skill exists to prevent (NO_NESTED_CV).
  • Never report a radiomics/ML audit "pass" without running check_radiomics_ml.py. The rigor verdict is reproduced deterministically, never asserted from prose.
  • Integrate, don't reimplement. Reference scikit-learn / xgboost / pyradiomics; do not write a new feature extractor or learner or claim results for one.

Reproducible challenge

scripts/check_radiomics_ml_challenge/ ships a synthetic weak/strong pipeline pair with a network-free verify.sh wired into the skill's validation commands.

Files (medsci-skills)
  • references
    • radiomics_ml_guide.md 6.5 KB
      # Radiomics / classical-ML — how to build a pipeline that passes review
      
      Companion to `radiomics-ml`. *Produce* knowledge for a clinician building a radiomics or tabular
      clinical-ML model without an engineer. It wires pyradiomics / scikit-learn / xgboost by name; it does
      not reimplement them.
      
      ## 1. Feature extraction (radiomics) — reproducibly
      
      Use **pyradiomics** with settings recorded for reproducibility (CLEAR / IBSI):
      
      - **Resample** to a fixed voxel spacing; state interpolator.
      - **Intensity discretisation** — a fixed **bin width** (preferred for CT/PET) or fixed bin count;
        state which and the value.
      - **Normalisation** for MR (z-score) — fit per image or on train only (never on test).
      - **Segmentation source** — who/what drew the ROI; single vs multi-rater (feeds stability, §2).
      - Extract the standard classes (first-order, shape, GLCM/GLRLM/GLSZM/GLDM/NGTDM) ± wavelet/LoG; record
        the pyradiomics version and parameter file.
      
      For **tabular clinical** data, the same pipeline applies from §2 onward — the "features" are labs,
      demographics, and measurements instead of radiomic descriptors.
      
      ## 2. Feature stability (radiomics-specific)
      
      Radiomic features drift with acquisition and segmentation. With test-retest or multi-rater ROIs,
      compute **ICC** per feature and keep the stable ones (commonly ICC ≥ 0.75) *before* modelling. Report
      how many features survived. No stability step → `NO_FEATURE_STABILITY`.
      
      ## 3. The events-per-feature problem (the classic trap)
      
      Radiomics yields hundreds-to-thousands of features on tens of patients. Fitting a flexible model in
      that regime overfits. Control it:
      
      - **Dimensionality reduction / regularisation** — LASSO (embedded selection), a stability + redundancy
        filter (drop |r| > 0.9 pairs), or PCA.
      - Keep an eye on **events per candidate feature** (the limiting count is the minority class). Report
        the ratio honestly. Features ≥ events with no reduction → `HIGH_DIM_LOW_EVENTS`.
      
      ## 4. Nested cross-validation (the non-negotiable)
      
      If you tune hyperparameters and report performance from the **same** CV, the performance is
      optimistic. Two acceptable designs:
      
      - **Nested CV** — outer folds estimate performance; an inner CV inside each outer training fold tunes
        hyperparameters **and** does feature selection + scaling. Nothing from the outer test fold touches
        fitting.
      - **Held-out test set** — tune with CV on the training split, evaluate once on an untouched test split.
      
      **Everything data-driven goes inside the fold**: imputation, scaling, feature selection, class-imbalance
      resampling. Selection on the whole dataset → `SELECTION_OUTSIDE_CV` (and, in prose,
      `self-review/check_cv_leakage`). Flat CV or no validation → `NO_NESTED_CV`.
      
      ```python
      # sklearn nested-CV skeleton (integrate, don't reimplement)
      from sklearn.pipeline import Pipeline
      from sklearn.feature_selection import SelectKBest
      from sklearn.model_selection import GridSearchCV, StratifiedKFold, cross_val_score
      pipe = Pipeline([("scale", StandardScaler()),
                       ("select", SelectKBest(k=20)),        # selection INSIDE the fold
                       ("clf", XGBClassifier(eval_metric="logloss"))])
      inner = StratifiedKFold(5, shuffle=True, random_state=42)
      outer = StratifiedKFold(5, shuffle=True, random_state=42)
      grid  = GridSearchCV(pipe, param_grid, cv=inner, scoring="roc_auc")
      auc   = cross_val_score(grid, X, y, cv=outer, scoring="roc_auc")  # outer = unbiased estimate
      ```
      
      ## 5. Models — the full classical family (not just RF / XGBoost)
      
      Always report a **simple baseline** (penalised logistic) alongside any complex learner — a model that
      does not beat penalised logistic on your data is a finding, not a failure. Fix the seed. Pick by task
      and sample size; the pipeline rigor in §2–§4 is identical across all of them (the gate is
      learner-agnostic).
      
      | Family | Learner (scikit-learn / library) | When |
      |---|---|---|
      | Penalised regression | `LogisticRegression(penalty=l1/l2/elasticnet)` — LASSO / ridge / elastic-net | baseline; small n; interpretable coefficients |
      | Margin / kernel | `SVC` (linear / RBF) | moderate n, clear margin; scale features first |
      | Instance-based | `KNeighborsClassifier` | small feature set, local structure |
      | Probabilistic / discriminant | `GaussianNB`, `LinearDiscriminantAnalysis`, `QuadraticDiscriminantAnalysis` | fast baselines; LDA when classes ~Gaussian |
      | Single tree | `DecisionTreeClassifier` | interpretability demo (rarely final) |
      | Bagging | `RandomForestClassifier`, `ExtraTreesClassifier` | robust default, low tuning |
      | Boosting | `XGBClassifier`, `LGBMClassifier`, `CatBoostClassifier`, `HistGradientBoostingClassifier`, `AdaBoost` | usually top tabular performance; tune with the inner CV |
      | Shallow neural | `MLPClassifier` | non-linear, enough samples; scale + early-stop |
      | Meta / ensemble | `StackingClassifier`, `VotingClassifier` | squeeze marginal gain; guard overfitting |
      | Survival ML | random survival forest, Cox-net, DeepSurv | time-to-event outcome (+ `/analyze-stats` survival) |
      
      **Unsupervised (upstream, not the endpoint):** PCA / UMAP for dimensionality reduction (fit inside the
      fold, §4), and k-means / hierarchical / Gaussian-mixture for phenotype discovery — report cluster
      stability, never as a supervised performance claim.
      
      For imbalanced outcomes prefer probability-calibrated models + threshold analysis over resampling that
      distorts prevalence; calibrate with Platt / isotonic (`/analyze-stats` calibration).
      
      ## 6. Report discrimination AND calibration AND utility
      
      - **Discrimination** — AUROC (+ AUPRC at low prevalence) with bootstrap CIs.
      - **Calibration** — slope + intercept and a flexible calibration curve (not decile bins); use the
        `/analyze-stats` calibration guide. Discrimination-only → `NO_CALIBRATION`.
      - **Clinical utility** — decision-curve net benefit / NNT at a stated threshold (`/analyze-stats`).
      - **Interpretation** — SHAP for feature contributions (global + a few local), framed as association.
      
      ## 7. Validation and reporting
      
      - **External / temporal validation** for a clinical claim; single-cohort development → report as
        development-only (`NO_EXTERNAL_VALIDATION`).
      - **Reporting** — CLEAR (radiomics), TRIPOD+AI (prediction model), PROBAST-AI (risk of bias) via
        `/check-reporting`.
      
      ## 8. Common reviewer objections this pre-empts
      
      1. "Was performance from nested CV or the same folds you tuned on?" → §4.
      2. "Features ≥ patients — how did you avoid overfitting?" → §3.
      3. "Were features selected inside the CV?" → §4.
      4. "Are the features reproducible (ICC)?" → §2.
      5. "Calibration, not just AUC?" → §6.
      6. "External validation?" → §7.
      
  • scripts
    • check_radiomics_ml_challenge
      • expected
        • strong.txt 369 B
          =========================================
           Radiomics / Classical-ML Gate (radiomics-ml)
          =========================================
            model=random_forest  n_features=1200  n_samples=300  n_events=110  cv_scheme=nested
          | Check | Severity | Detail |
          |---|---|---|
          | (none) | — | radiomics/ML pipeline meets the rigor bar |
          
          OK: radiomics/ML pipeline meets the rigor bar.
          
        • weak.txt 1.4 KB
          =========================================
           Radiomics / Classical-ML Gate (radiomics-ml)
          =========================================
            model=xgboost  n_features=1200  n_samples=140  n_events=40  cv_scheme=flat
          | Check | Severity | Detail |
          |---|---|---|
          | NO_NESTED_CV | Major | flat CV ('flat') tunes and reports on the same folds; use nested cross-validation or a held-out test set so tuning does not inflate the reported performance |
          | HIGH_DIM_LOW_EVENTS | Major | 1200 features vs 40 events (p >= events) with no dimensionality reduction / regularisation; radiomics overfits badly in this regime — apply LASSO / PCA / a stability+redundancy filter |
          | SELECTION_OUTSIDE_CV | Major | feature selection is fit outside the CV fold (on the whole dataset), so the held-out folds leak into selection; nest selection inside each training fold |
          | NO_FEATURE_STABILITY | Minor | no test-retest / ICC feature-stability filtering; radiomics features are unstable across acquisition and segmentation — filter to reproducible features |
          | NO_CALIBRATION | Minor | a clinical prediction model is reported without calibration (slope/intercept or a flexible calibration curve), only discrimination |
          | NO_EXTERNAL_VALIDATION | Minor | single-cohort development with no external / temporal validation; a clinical claim needs validation beyond the development sample |
          
          MAJOR candidate: 3 radiomics/ML rigor issue(s).
          
      • fixture
        • pipeline_strong.json 322 B
          {
            "task": "classification",
            "n_features": 1200,
            "n_samples": 300,
            "n_events": 110,
            "cv_scheme": "nested",
            "feature_selection_stage": "inside_cv",
            "dimensionality_reduction": true,
            "feature_stability": "icc",
            "calibration_reported": true,
            "external_validation": "temporal",
            "model": "random_forest"
          }
          
        • pipeline_weak.json 313 B
          {
            "task": "classification",
            "n_features": 1200,
            "n_samples": 140,
            "n_events": 40,
            "cv_scheme": "flat",
            "feature_selection_stage": "outside_cv",
            "dimensionality_reduction": false,
            "feature_stability": "none",
            "calibration_reported": false,
            "external_validation": "none",
            "model": "xgboost"
          }
          
      • problem.md 894 B
        # Challenge — radiomics / classical-ML pipeline-rigor gate
        
        A radiomics + tree-ensemble study (features → random forest / XGBoost → clinical outcome)
        is trustworthy only with nested CV, dimensionality control, in-fold feature selection,
        stability filtering, calibration, and external validation. Given a declarative pipeline
        manifest (JSON), `check_radiomics_ml.py` must decide — by rule, not from prose — whether
        the pipeline meets that bar.
        
        ## Task
        Run the gate on the two synthetic manifests in `fixture/` and reproduce `expected/`:
        
        - `pipeline_weak.json` → three Major verdicts (`NO_NESTED_CV`, `HIGH_DIM_LOW_EVENTS`,
          `SELECTION_OUTSIDE_CV`) plus three Minor (`NO_FEATURE_STABILITY`, `NO_CALIBRATION`,
          `NO_EXTERNAL_VALIDATION`); exit 1 under `--strict`.
        - `pipeline_strong.json` → no claims; exit 0.
        
        ## Verify
        ```bash
        bash verify.sh   # deterministic, network-free
        ```
        
      • verify.sh 1.9 KB
        #!/usr/bin/env bash
        # Deterministic verifier for the radiomics/classical-ML challenge card.
        # Runs check_radiomics_ml.py on two synthetic pipeline manifests and diffs stdout
        # against expected/. No network, no sklearn — every verdict is decided by rule on the
        # manifest. Exit 0 = both match and exit codes correct.
        #
        # Fixtures (synthetic only — no real patients, no PII):
        #   pipeline_weak.json   — 1200 features / 40 events, flat CV, selection on the whole
        #                          dataset, no dim-reduction / stability / calibration / external
        #                          validation (all six verdicts fire).
        #   pipeline_strong.json — nested CV, selection inside the fold, dim-reduction on, ICC
        #                          stability filter, calibration + temporal external validation.
        set -euo pipefail
        HERE="$(cd "$(dirname "$0")" && pwd)"
        DET="$HERE/../check_radiomics_ml.py"
        
        weak="$(python3 "$DET" --manifest "$HERE/fixture/pipeline_weak.json")"
        strong="$(python3 "$DET" --manifest "$HERE/fixture/pipeline_strong.json")"
        
        ok=1
        if ! diff -u "$HERE/expected/weak.txt" <(printf '%s\n' "$weak"); then
          echo "FAIL: weak-fixture output drifted from expected/weak.txt" >&2; ok=0
        fi
        if ! diff -u "$HERE/expected/strong.txt" <(printf '%s\n' "$strong"); then
          echo "FAIL: strong-fixture output drifted from expected/strong.txt" >&2; ok=0
        fi
        
        python3 "$DET" --manifest "$HERE/fixture/pipeline_weak.json" --strict --quiet >/dev/null 2>&1 && rc_weak=0 || rc_weak=$?
        python3 "$DET" --manifest "$HERE/fixture/pipeline_strong.json" --strict --quiet >/dev/null 2>&1 && rc_strong=0 || rc_strong=$?
        [ "${rc_weak:-0}" -eq 1 ] || { echo "FAIL: weak fixture should exit 1 under --strict (got ${rc_weak:-0})" >&2; ok=0; }
        [ "$rc_strong" -eq 0 ]    || { echo "FAIL: strong fixture should exit 0 under --strict (got $rc_strong)" >&2; ok=0; }
        
        if [ "$ok" -eq 1 ]; then
          echo "PASS: radiomics/ML gate flags the overfit, leaky, uncalibrated pipeline and clears the rigorous one."
        else
          exit 1
        fi
        
    • check_radiomics_ml.py 10 KB
      #!/usr/bin/env python3
      """Radiomics / classical-ML pipeline-rigor gate (radiomics-ml).
      
      Radiomics + tree-ensemble studies (features -> random forest / XGBoost -> clinical
      outcome) are the most common solo-doable clinical-ML workflow, and the most commonly
      over-optimistic: hundreds-to-thousands of features on tens of patients, hyperparameters
      tuned on the same folds performance is reported from, features selected on the whole
      dataset, unstable features never filtered, and discrimination reported without
      calibration (Lambin 2017; Park & Kim radiomics-quality; CLEAR; TRIPOD+AI; PROBAST-AI).
      
      This gate reads a declarative **pipeline manifest** (JSON — the artifact this skill
      emits, or one the researcher writes) and decides each rigor requirement by rule. It
      complements `self-review/check_cv_leakage` (which greps a finished manuscript's prose):
      this one audits the pipeline spec at build time.
      
      CHECKS (verdicts):
        1. NO_NESTED_CV          (Major)  hyperparameters are tuned and performance reported on
                                          the same CV (flat k-fold) or with no validation at all;
                                          nested CV or a held-out test set is required.
        2. HIGH_DIM_LOW_EVENTS   (Major)  at least as many features as events (p >= events) with no
                                          dimensionality reduction / regularisation — the classic
                                          radiomics overfitting trap.
        3. SELECTION_OUTSIDE_CV  (Major)  feature selection is fit outside the CV fold (on the whole
                                          dataset), leaking the held-out folds into selection.
        4. NO_FEATURE_STABILITY  (Minor)  no test-retest / ICC feature-stability filtering; radiomics
                                          features are notoriously unstable across acquisition.
        5. NO_CALIBRATION        (Minor)  a clinical prediction model reported by discrimination only
                                          (no calibration slope/intercept or flexible curve).
        6. NO_EXTERNAL_VALIDATION(Minor)  single cohort, no external / temporal validation for a
                                          clinical claim.
      
      MANIFEST (JSON)
        {
          "task": "classification",
          "n_features": 1200,
          "n_samples": 140,
          "n_events": 40,                       // minority-class count (for events-per-feature)
          "cv_scheme": "nested",                // nested / single_split / held_out_test / flat / loocv / none
          "feature_selection_stage": "inside_cv", // inside_cv / outside_cv / none
          "dimensionality_reduction": true,     // LASSO / PCA / regularisation applied
          "feature_stability": "icc",           // icc / test_retest / none
          "calibration_reported": true,
          "external_validation": "temporal",    // external / temporal / none
          "model": "xgboost"
        }
      
      INPUTS
        --manifest  radiomics/classical-ML pipeline manifest JSON (required).
      
      OUTPUT
        A reconciliation table (stdout) and, with --out, a JSON artifact:
          {manifest, model, n_features, n_samples, n_events, cv_scheme, claims[...], summary}
        NO_NESTED_CV / HIGH_DIM_LOW_EVENTS / SELECTION_OUTSIDE_CV 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
      
      VALID_CV = {"nested", "nested_cv", "single_split", "held_out_test", "holdout_test",
                  "held-out", "train_test_holdout"}
      NONE_VALUES = {"", "none", "no", "na", "n/a", "false", "0"}
      
      
      def _norm(s) -> str:
          return str(s).strip().lower() if s is not None else ""
      
      
      def check(m: dict) -> list[dict]:
          claims: list[dict] = []
          n_features = m.get("n_features")
          n_events = m.get("n_events")
          n_samples = m.get("n_samples")
          cv = _norm(m.get("cv_scheme"))
          sel = _norm(m.get("feature_selection_stage"))
          dimred = m.get("dimensionality_reduction")
          stability = _norm(m.get("feature_stability"))
          calib = m.get("calibration_reported")
          extval = _norm(m.get("external_validation"))
      
          # 1. No nested CV / no held-out validation.
          if cv not in VALID_CV:
              detail = ("no validation scheme (`none`)" if cv in NONE_VALUES
                        else f"flat CV ('{cv or 'missing'}') tunes and reports on the same folds")
              claims.append({
                  "verdict": "NO_NESTED_CV", "severity": "Major",
                  "detail": (f"{detail}; use nested cross-validation or a held-out test set so tuning "
                             f"does not inflate the reported performance"),
                  "where": "cv_scheme",
              })
      
          # 2. High dimensionality vs events, no reduction.
          denom = n_events if isinstance(n_events, (int, float)) else n_samples
          if isinstance(n_features, (int, float)) and isinstance(denom, (int, float)) and denom > 0:
              if n_features >= denom and dimred is not True:
                  unit = "events" if isinstance(n_events, (int, float)) else "samples"
                  claims.append({
                      "verdict": "HIGH_DIM_LOW_EVENTS", "severity": "Major",
                      "detail": (f"{n_features} features vs {int(denom)} {unit} (p >= {unit}) with no "
                                 f"dimensionality reduction / regularisation; radiomics overfits badly "
                                 f"in this regime — apply LASSO / PCA / a stability+redundancy filter"),
                      "where": "n_features",
                  })
      
          # 3. Feature selection outside the CV fold.
          if sel in {"outside_cv", "outside", "whole_dataset", "before_cv", "pre_cv", "global"}:
              claims.append({
                  "verdict": "SELECTION_OUTSIDE_CV", "severity": "Major",
                  "detail": ("feature selection is fit outside the CV fold (on the whole dataset), so the "
                             "held-out folds leak into selection; nest selection inside each training fold"),
                  "where": "feature_selection_stage",
              })
      
          # 4. No feature-stability filtering.
          if stability in NONE_VALUES:
              claims.append({
                  "verdict": "NO_FEATURE_STABILITY", "severity": "Minor",
                  "detail": ("no test-retest / ICC feature-stability filtering; radiomics features are "
                             "unstable across acquisition and segmentation — filter to reproducible features"),
                  "where": "feature_stability",
              })
      
          # 5. No calibration.
          if calib is not True:
              claims.append({
                  "verdict": "NO_CALIBRATION", "severity": "Minor",
                  "detail": ("a clinical prediction model is reported without calibration (slope/intercept "
                             "or a flexible calibration curve), only discrimination"),
                  "where": "calibration_reported",
              })
      
          # 6. No external / temporal validation.
          if extval in NONE_VALUES:
              claims.append({
                  "verdict": "NO_EXTERNAL_VALIDATION", "severity": "Minor",
                  "detail": ("single-cohort development with no external / temporal validation; a clinical "
                             "claim needs validation beyond the development sample"),
                  "where": "external_validation",
              })
      
          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:
              m = 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(m, dict):
              sys.stderr.write("ERROR: manifest JSON must be an object\n")
              sys.exit(2)
      
          claims = check(m)
          n_major = sum(1 for c in claims if c["severity"] == "Major")
          return {
              "manifest": str(p),
              "model": m.get("model"),
              "n_features": m.get("n_features"),
              "n_samples": m.get("n_samples"),
              "n_events": m.get("n_events"),
              "cv_scheme": m.get("cv_scheme"),
              "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) | — | radiomics/ML pipeline meets the rigor bar |")
          return "\n".join(lines)
      
      
      def main() -> int:
          ap = argparse.ArgumentParser(description="Radiomics / classical-ML pipeline-rigor gate.")
          ap.add_argument("--manifest", required=True, help="radiomics/classical-ML pipeline 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(" Radiomics / Classical-ML Gate (radiomics-ml)")
              print("=" * 41)
              print(f"  model={result['model']}  n_features={result['n_features']}  "
                    f"n_samples={result['n_samples']}  n_events={result['n_events']}  "
                    f"cv_scheme={result['cv_scheme']}")
              print(render(result))
              print()
              s = result["summary"]
              if s["n_major"]:
                  print(f"MAJOR candidate: {s['n_major']} radiomics/ML rigor issue(s).")
              elif s["n_flag"]:
                  print(f"MINOR flag: {s['n_flag']} radiomics/ML rigor issue(s) (see table).")
              else:
                  print("OK: radiomics/ML pipeline meets the rigor bar.")
      
          if args.out:
              Path(args.out).parent.mkdir(parents=True, exist_ok=True)
              Path(args.out).write_text(json.dumps({"detector": "check_radiomics_ml", **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_radiomics_ml.sh 4.2 KB
      #!/usr/bin/env bash
      # Regression test for the radiomics/classical-ML pipeline-rigor gate (radiomics-ml).
      # Synthetic, PII-free JSON manifests reproduce each verdict class + the suppressions.
      # Stdlib-only (python3).
      set -u
      
      HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
      SCRIPT="$HERE/../scripts/check_radiomics_ml.py"
      CH="$HERE/../scripts/check_radiomics_ml_challenge"
      TMP="$(mktemp -d -t radml_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) weak fixture -> 3 Major + exit 1
      python3 "$SCRIPT" --manifest "$CH/fixture/pipeline_weak.json" --out "$OUT" --strict --quiet >/dev/null 2>&1
      check "exit 1 (weak pipeline)" test "$?" -eq 1
      check "NO_NESTED_CV detected" has_verdict NO_NESTED_CV
      check "HIGH_DIM_LOW_EVENTS detected" has_verdict HIGH_DIM_LOW_EVENTS
      check "SELECTION_OUTSIDE_CV detected" has_verdict SELECTION_OUTSIDE_CV
      check "NO_FEATURE_STABILITY detected" has_verdict NO_FEATURE_STABILITY
      check "NO_CALIBRATION detected" has_verdict NO_CALIBRATION
      check "NO_EXTERNAL_VALIDATION detected" has_verdict NO_EXTERNAL_VALIDATION
      
      # (2) strong fixture -> exit 0, no claims
      python3 "$SCRIPT" --manifest "$CH/fixture/pipeline_strong.json" --strict --quiet >/dev/null 2>&1
      check "exit 0 (strong pipeline)" test "$?" -eq 0
      
      # (3) dimensionality_reduction=true suppresses HIGH_DIM_LOW_EVENTS even when p >= events
      cat > "$TMP/dimred.json" <<'EOF'
      {"n_features": 1000, "n_events": 30, "cv_scheme": "nested", "feature_selection_stage": "inside_cv",
       "dimensionality_reduction": true, "feature_stability": "icc", "calibration_reported": true,
       "external_validation": "external", "model": "lasso_logistic"}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/dimred.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "dim-reduction suppresses HIGH_DIM_LOW_EVENTS" no_verdict HIGH_DIM_LOW_EVENTS
      
      # (4) single_split is an acceptable validation scheme (no NO_NESTED_CV)
      cat > "$TMP/single.json" <<'EOF'
      {"n_features": 50, "n_events": 120, "cv_scheme": "single_split", "feature_selection_stage": "inside_cv",
       "dimensionality_reduction": true, "feature_stability": "icc", "calibration_reported": true,
       "external_validation": "temporal", "model": "random_forest"}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/single.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "single_split does NOT fire NO_NESTED_CV" no_verdict NO_NESTED_CV
      python3 "$SCRIPT" --manifest "$TMP/single.json" --strict --quiet >/dev/null 2>&1
      check "exit 0 on rigorous single-split pipeline" test "$?" -eq 0
      
      # (5) cv_scheme none -> NO_NESTED_CV (no validation at all)
      cat > "$TMP/nocv.json" <<'EOF'
      {"n_features": 20, "n_events": 200, "cv_scheme": "none", "feature_selection_stage": "inside_cv",
       "dimensionality_reduction": true, "feature_stability": "icc", "calibration_reported": true,
       "external_validation": "external", "model": "xgboost"}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/nocv.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "cv_scheme=none fires NO_NESTED_CV" has_verdict NO_NESTED_CV
      
      # (6) n_events absent -> falls back to n_samples for the dimensionality check
      cat > "$TMP/samplesonly.json" <<'EOF'
      {"n_features": 500, "n_samples": 60, "cv_scheme": "nested", "feature_selection_stage": "inside_cv",
       "dimensionality_reduction": false, "feature_stability": "icc", "calibration_reported": true,
       "external_validation": "external", "model": "random_forest"}
      EOF
      python3 "$SCRIPT" --manifest "$TMP/samplesonly.json" --out "$OUT" --quiet >/dev/null 2>&1
      check "HIGH_DIM_LOW_EVENTS uses n_samples fallback when n_events missing" has_verdict HIGH_DIM_LOW_EVENTS
      
      # (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: radiomics-ml
    description: >
      Produce or audit a radiomics / tabular clinical-ML study — imaging or clinical features → any
      classical learner (penalised logistic [LASSO / ridge / elastic-net], SVM, k-NN, naive Bayes,
      LDA/QDA, decision tree, random forest, gradient boosting [XGBoost / LightGBM / CatBoost], shallow
      MLP, stacked ensembles) → a clinical outcome — so it clears the rigor bar reviewers expect: nested
      cross-validation (tuning never on the reported folds), dimensionality control for the
      features-far-exceed-events regime, feature selection inside the fold, feature-stability (ICC /
      test-retest) filtering, calibration, and external/temporal validation. The deterministic gate is
      learner-agnostic (it audits the pipeline, not the algorithm). Emits a pipeline manifest and the gate.
      The most common solo-doable clinical-ML workflow — no GPU, no engineer. Integrates scikit-learn /
      xgboost / lightgbm / catboost / pyradiomics; it does not reimplement them.
    triggers: radiomics, radiomic features, pyradiomics, tabular ML, clinical prediction model, random forest, XGBoost, LightGBM, CatBoost, gradient boosting, tree ensemble, SVM, support vector machine, k-NN, KNN, naive Bayes, LDA, QDA, elastic net, ridge, LASSO, logistic regression, MLP, stacking, ensemble, clustering, k-means, PCA, UMAP, dimensionality reduction, feature selection, nested cross-validation, nested CV, ICC feature stability, SHAP, machine learning model, classical ML, clinical machine learning, feature stability, decision curve, calibration, TRIPOD, CLEAR, PROBAST
    tools: Read, Write, Edit, Bash, Grep, Glob
    model: inherit
    ---
    
    # Radiomics / Classical-ML Skill
    
    ## Purpose
    
    Radiomics + tree-ensemble studies (features → random forest / XGBoost → a clinical outcome) are the
    **most common solo-doable clinical-ML workflow** — no GPU, no engineer — and the **most commonly
    over-optimistic**: hundreds-to-thousands of features on tens of patients, hyperparameters tuned on the
    same folds the performance is reported from, features selected on the whole dataset, unstable features
    never filtered, and discrimination (AUC) reported without calibration. This skill produces the pipeline
    correctly and audits an existing one, so the clinical result survives review (Lambin 2017; CLEAR;
    TRIPOD+AI; PROBAST-AI).
    
    It sits beside the imaging-DL lane: where `/model-scaffold` builds a deep network, **radiomics-ml**
    covers the feature-based classical-ML path. It **integrates** scikit-learn / xgboost / pyradiomics
    (referenced in the emitted code); it does not reimplement them and never runs a model on real patient
    data.
    
    ## When to use
    - You have a radiomics or clinical/tabular feature table and want to build a random-forest / XGBoost
      clinical prediction model that will pass statistical review.
    - You want to audit an existing radiomics/ML pipeline for the failure modes below.
    
    ## When NOT to use
    - Deep-learning imaging models → `/architecture-zoo` → `/model-scaffold` → `/model-validation`.
    - Classical inferential statistics / a regression model as the estimand → `/analyze-stats`.
    - Interpretability of a trained network → `/explainability`.
    - Reimplementing scikit-learn / xgboost / pyradiomics → out of scope (this skill wires and audits them).
    
    ## The failure modes (what the gate enforces)
    1. **No nested CV.** Tuning and reporting on the same folds inflates performance. Use nested CV or a
       held-out test set.
    2. **High dimensionality, low events.** Features ≥ events with no dimensionality reduction overfits —
       the classic radiomics trap. Apply LASSO / PCA / a stability + redundancy filter.
    3. **Selection outside the fold.** Feature selection fit on the whole dataset leaks the held-out folds.
       Nest selection inside each training fold.
    4. **No feature stability.** Radiomics features are unstable across acquisition/segmentation — filter
       to reproducible features (ICC / test-retest).
    5. **No calibration.** A clinical prediction model needs calibration (slope/intercept + a flexible
       curve), not discrimination alone.
    6. **No external validation.** A single-cohort model needs external / temporal validation for a
       clinical claim.
    
    ## Workflow
    
    ### Phase 1 — Extract features (integrate, don't reimplement)
    For radiomics, extract with **pyradiomics** under reproducible, IBSI-aligned settings (fixed bin width,
    resampling, normalisation) — record them. For clinical/tabular data, assemble the feature table with a
    patient/subject ID and the outcome. See `references/radiomics_ml_guide.md`.
    
    ### Phase 2 — Build the pipeline correctly
    - **Feature stability** — with test-retest / multi-rater data, keep features with ICC ≥ 0.75.
    - **Nested cross-validation** — outer folds estimate performance, inner folds tune; do **feature
      selection and scaling inside each training fold** (never on the whole dataset).
    - **Dimensionality** — with features ≥ events, use LASSO / a stability+redundancy filter / PCA.
    - **Model** — pick from the full classical family for the task; a simple baseline (penalised logistic)
      is mandatory alongside any complex learner:
      - *penalised regression* — LASSO / ridge / elastic-net logistic (also the baseline)
      - *margin / kernel* — linear or RBF SVM
      - *instance-based* — k-NN
      - *probabilistic / discriminant* — naive Bayes, LDA / QDA
      - *trees & bagging* — decision tree, random forest, extra-trees
      - *boosting* — XGBoost, LightGBM, CatBoost, HistGBM, AdaBoost
      - *shallow neural* — MLP
      - *meta* — stacking / voting ensembles
      - *unsupervised (upstream)* — PCA / UMAP for reduction, k-means / hierarchical / GMM for phenotyping
      The gate below is **learner-agnostic** — it audits the pipeline (nested CV, leakage, dimensionality,
      calibration), so it applies identically to any of these. See the full method map in
      [`docs/method_coverage_map.md`](../../docs/method_coverage_map.md).
    - **Report** — discrimination **and** calibration (slope/intercept + flexible curve, via the
      `/analyze-stats` calibration guide) and clinical utility (decision curve). SHAP for interpretation.
    
    ### Phase 3 — Emit the pipeline manifest
    ```json
    {
      "task": "classification",
      "n_features": 1200, "n_samples": 300, "n_events": 110,
      "cv_scheme": "nested",
      "feature_selection_stage": "inside_cv",
      "dimensionality_reduction": true,
      "feature_stability": "icc",
      "calibration_reported": true,
      "external_validation": "temporal",
      "model": "xgboost"
    }
    ```
    
    ### Phase 4 — Gate the pipeline (deterministic)
    ```bash
    python3 scripts/check_radiomics_ml.py --manifest pipeline_manifest.json --strict
    ```
    Verdicts: `NO_NESTED_CV`, `HIGH_DIM_LOW_EVENTS`, `SELECTION_OUTSIDE_CV` (Major);
    `NO_FEATURE_STABILITY`, `NO_CALIBRATION`, `NO_EXTERNAL_VALIDATION` (Minor). Complements
    `self-review`'s `check_cv_leakage` (which audits a finished manuscript's prose) at the pipeline-spec
    level.
    
    ## Integration
    - **`/analyze-stats`** — calibration + clinical-utility (decision curve, NNT) guides for the reporting.
    - **`/check-reporting`** — CLEAR (radiomics), TRIPOD+AI, PROBAST-AI item coverage.
    - **`/self-review`** `clinical_prediction_model` probe audits the finished manuscript; this skill
      *produces* the rigorous pipeline it looks for.
    
    ## Anti-Hallucination
    
    - **Never fabricate features, performance metrics, or sample/event counts.** Every value in the
      manifest and every reported metric comes from the researcher's executed code — never invented. This
      skill designs and audits the pipeline; it does not run a model on real patient data.
    - **Never report flat-CV performance as if it were nested or held-out.** Tuning on the reported folds
      is the optimism this skill exists to prevent (`NO_NESTED_CV`).
    - **Never report a radiomics/ML audit "pass" without running `check_radiomics_ml.py`.** The rigor
      verdict is reproduced deterministically, never asserted from prose.
    - **Integrate, don't reimplement.** Reference scikit-learn / xgboost / pyradiomics; do not write a new
      feature extractor or learner or claim results for one.
    
    ## Reproducible challenge
    `scripts/check_radiomics_ml_challenge/` ships a synthetic weak/strong pipeline pair with a network-free
    `verify.sh` wired into the skill's validation commands.
    
  • skill.yml 3.7 KB
    schema_version: 2
    name: radiomics-ml
    layer: D
    owner_domain: model_validation
    maturity: official
    
    when_to_use: "Produce or audit a radiomics / tabular clinical-ML study — imaging or clinical features → random forest / XGBoost / regularised logistic → a clinical outcome — so it clears the rigor bar reviewers expect: nested cross-validation (tuning never on the reported folds), dimensionality control for the features-far-exceed-events regime, feature selection inside the fold, feature-stability (ICC / test-retest) filtering, calibration, and external/temporal validation. Emits a pipeline manifest and a deterministic rigor gate. The most common solo-doable clinical-ML workflow — no GPU, no engineer."
    when_NOT_to_use: "Deep-learning imaging models (use architecture-zoo / model-scaffold / model-validation); classical inferential statistics or a regression model as the primary estimand (use analyze-stats); LLM/MLLM evaluation (use mllm-eval); interpretability of a trained imaging network (use explainability); item-by-item reporting-guideline audit of a finished manuscript (use check-reporting — CLEAR / TRIPOD+AI / PROBAST-AI); reimplementing scikit-learn / xgboost / pyradiomics (out of scope — this skill wires and audits them)."
    
    inputs:
      - "a feature table (radiomics or clinical/tabular) with a patient/subject ID and the outcome"
      - "the intended model (random forest / XGBoost / regularised logistic) and validation plan"
      - "feature provenance (pyradiomics settings) and, if available, test-retest data for stability"
    outputs:
      - "a pipeline manifest (JSON: n_features, n_events, cv_scheme, feature_selection_stage, dimensionality_reduction, feature_stability, calibration, external_validation)"
      - "nested-CV training code (scikit-learn / xgboost) with in-fold selection, SHAP, calibration, and clinical-utility"
      - "radiomics/ML pipeline-rigor audit JSON (deterministic) + a CLEAR / TRIPOD+AI / PROBAST-AI reporting fit"
    deterministic_scripts:
      - scripts/check_radiomics_ml.py
    side_effects:
      - writes_decision_notes
    downstream_consumers:
      - analyze-stats
      - check-reporting
      - self-review
      - write-paper
    forbidden_actions:
      - fabricate_features_performance_metrics_or_sample_counts
      - report_flat_cv_performance_as_if_nested_or_held_out
      - approve_a_pipeline_with_feature_selection_fit_outside_the_cv_fold
      - report_a_radiomics_ml_audit_pass_without_running_the_detector
      - reimplement_scikit_learn_xgboost_or_pyradiomics
    
    # v2.1 quality card
    purpose: "Stop the over-optimistic radiomics / tree-ensemble study — hundreds of features on tens of events, hyperparameters tuned on the reported folds, features selected on the whole dataset, unstable features unfiltered, discrimination without calibration — from reaching a clinical manuscript."
    safety_boundaries:
      - "Advisory plus deterministic-audit only: never alters features, metrics, or sample counts."
      - "The rigor verdict is reproduced by a stdlib script (rule on the pipeline manifest), never asserted from prose."
      - "Integrates scikit-learn / xgboost / pyradiomics by reference; it does not reimplement them and never runs a model on real patient data."
    known_limitations:
      - "Audits the declared pipeline manifest, not the executed code; a mislabelled field (e.g. flat CV recorded as nested) can hide a real problem — complements, does not replace, self-review/check_cv_leakage (prose audit)."
      - "A clean pipeline audit is necessary, not sufficient — external validation and clinical-utility evidence still govern the clinical claim."
    validation_commands:
      - "python3 scripts/check_radiomics_ml.py --manifest <pipeline_manifest.json> --strict"
      - "bash scripts/check_radiomics_ml_challenge/verify.sh  # deterministic, network-free"
    evidence_surface: ci_validator
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related