shareefch1413 commited on
Commit
00801a0
·
verified ·
1 Parent(s): f29f9a0

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,146 +1,125 @@
1
- ---
2
- language:
3
- - en
4
- license: mit
5
- library_name: timm
6
- pipeline_tag: image-classification
7
- tags:
8
- - medical-imaging
9
- - knee-mri
10
- - acl-tear-detection
11
- - deep-learning
12
- - convnext
13
- - self-attention
14
- - masked-slice-modeling
15
- - radiology
16
- - orthopedics
17
- datasets:
18
- - stanford-mrnet
19
- metrics:
20
- - roc_auc
21
- - accuracy
22
- - f1
23
- model-index:
24
- - name: ACL-LKNet
25
- results:
26
- - task:
27
- type: image-classification
28
- name: Knee MRI ACL Tear Detection
29
- dataset:
30
- type: stanford-mrnet
31
- name: Stanford MRNet Locked Test Cohort (N=120)
32
- metrics:
33
- - type: roc_auc
34
- value: 0.9639
35
- name: AUROC
36
- - type: precision_recall_auc
37
- value: 0.9293
38
- name: AUPRC
39
- - type: accuracy
40
- value: 0.8167
41
- name: Accuracy
42
- - type: specificity
43
- value: 0.9394
44
- name: Specificity
45
- - type: sensitivity
46
- value: 0.6667
47
- name: Sensitivity
48
- - type: f1
49
- value: 0.7660
50
- name: F1 Score
51
- ---
52
 
53
- # ACL-LKNet: Self-Supervised Large-Kernel Network for ACL Tear Detection in Knee MRI
54
 
55
- [![Paper](https://img.shields.io/badge/Paper-IEEE%20TMI%20%2F%20MedIA-blue)](https://github.com)
56
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
57
- [![Model Type](https://img.shields.io/badge/Architecture-ConvNeXt--Tiny%20%2B%20MHA-purple)](https://github.com)
58
- [![AUROC](https://img.shields.io/badge/Official%20MRNet%20AUROC-0.9639-brightgreen)](https://github.com)
59
 
60
- **ACL-LKNet** is an anatomically grounded deep learning architecture specifically engineered for automated Anterior Cruciate Ligament (ACL) tear detection from tri-planar (Sagittal, Coronal, and Axial) volumetric knee MRI examinations.
61
 
62
- Developed as part of a doctoral investigation in computational musculoskeletal radiology, ACL-LKNet combines:
63
- 1. **Large-Kernel 2D Backbone (ConvNeXt-Tiny)**: Large $7 \times 7$ depthwise convolutions capturing the complete oblique trajectory of intra-articular ligaments.
64
- 2. **Masked Slice Modeling (MSM)**: Volumetric self-supervised pretext reconstruction across anisotropic slice stacks.
65
- 3. **Parametric Slice Attention**: Dynamic slice sequence pooling that outputs explicit, interpretable slice attention weights $\alpha_{p,s}$.
66
- 4. **Tri-Planar Cross-Attention Fusion**: 2-head self-attention operating over learned plane positional embeddings ($e_{\text{sag}}, e_{\text{cor}}, e_{\text{axi}}$).
67
- 5. **Strict Anatomical Invariants**: No horizontal/vertical flipping during training to preserve internal knee joint chirality and oblique ACL orientation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  ---
70
 
71
- ## Benchmark Performance on Stanford MRNet
72
-
73
- Evaluated on the locked, official **Stanford MRNet benchmark test set** ($N=120$ examinations, 54 tears, 66 controls) with empirical 95% bootstrap confidence intervals ($N=1{,}000$ iterations):
74
 
75
- | Diagnostic Metric | ACL-LKNet (5-Fold Ensemble) | 95% Bootstrap CI | Stanford MRNet Baseline (Bien et al., 2018) | Absolute $\Delta$ Gain |
76
- | :--- | :---: | :---: | :---: | :---: |
77
- | **AUROC** | **0.9639** | **[0.9277, 0.9919]** | 0.9370 | **+0.0269** |
78
- | **AUPRC** | **0.9293** | **[0.8492, 0.9889]** | -- | -- |
79
- | **Accuracy** | **81.67%** | **[75.00%, 88.33%]** | 82.50% | $-0.0083$ |
80
- | **Specificity** | **93.94%** | **[87.69%, 98.59%]** | 96.80% | $-0.0286$ |
81
- | **Sensitivity** | **66.67%** | **[53.22%, 79.25%]** | 75.90% | $-0.0923$ |
82
- | **F1-Score** | **0.7660** | **[0.6585, 0.8519]** | -- | -- |
83
- | **Brier Score** | **0.1184** | **[0.0891, 0.1520]** | -- | Well-Calibrated |
84
 
85
- > **Statistical Significance (RQ1)**: Paired DeLong test comparing ConvNeXt-Tiny against ResNet-18 yields **$z = 3.864, p = 0.000104$** ($p < 0.001$), establishing the statistical superiority of large receptive fields for elongated ligament structures.
 
 
 
86
 
87
  ---
88
 
89
- ## Quickstart: Python Inference via Hugging Face Hub
90
 
91
- ```python
92
- import torch
93
- from huggingface_hub import hf_hub_download
94
 
95
- # 1. Download model weights from Hugging Face Hub
96
- checkpoint_path = hf_hub_download(
97
- repo_id="your-username/ACL-LKNet",
98
- filename="acl_lknet_fold1_best.pt"
99
- )
100
 
101
- # 2. Instantiate model architecture
102
- from src.config import Config
103
- from src.models.acl_lknet import create_model_from_config
 
104
 
105
- config = Config(backbone_name="convnext_tiny")
106
- model = create_model_from_config(config)
 
 
107
 
108
- state_dict = torch.load(checkpoint_path, map_location="cpu")
109
- model.load_state_dict(state_dict["ema_state_dict"] if "ema_state_dict" in state_dict else state_dict["model_state_dict"])
110
- model.eval()
 
111
 
112
- # 3. Predict on tri-planar MRI volume (Sagittal, Coronal, Axial)
113
- # Each volume tensor is shape: (1, 24, 3, 224, 224)
114
- dummy_exam = {
115
- "sagittal": torch.randn(1, 24, 3, 224, 224),
116
- "coronal": torch.randn(1, 24, 3, 224, 224),
117
- "axial": torch.randn(1, 24, 3, 224, 224)
118
- }
119
-
120
- with torch.no_grad():
121
- output = model(dummy_exam)
122
- tear_probability = torch.sigmoid(output["logits"]).item()
123
 
124
- print(f"Predicted ACL Tear Probability: {tear_probability * 100:.2f}%")
125
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  ---
128
 
129
- ## Clinical Interpretability: Grad-CAM++ and Slice Attention
130
-
131
- * **Parametric Slice Attention Profiles**: Learns autonomous focus on central intercondylar notch slices (11--15/24) where the ACL is anatomically situated without requiring slice-level bounding box supervision.
132
- * **High-Resolution Grad-CAM++**: Hooks into ConvNeXt-Tiny Stage 2 ($14 \times 14$ feature map) to generate intra-articular gradient heatmaps localized to the femoral footprint and midsubstance tear site.
133
- * **Decision Curve Analysis (DCA)**: Demonstrates superior clinical net benefit over "treat all" and "treat none" policies across all relevant surgical intervention thresholds ($p_t \in [0.10, 0.75]$).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  ---
136
 
137
- ## Citation
138
 
139
- ```bibtex
140
- @article{acl_lknet2026,
141
- title={ACL-LKNet: Anatomically Constrained Large-Kernel Network with Multi-Plane Self-Attention for Volumetric ACL Tear Detection in Knee MRI},
142
- author={PhD Candidate in Biomedical Engineering and Computational Medicine},
143
- journal={IEEE Transactions on Medical Imaging (Preprint / PhD Dissertation Protocol)},
144
- year={2026}
145
- }
146
- ```
 
1
+ # 07_Python_Source_Code: ACL-LKNet Modular Python Package
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ This directory provides the clean, production-grade Python source code (standalone `.py` modules and command-line interfaces) for **ACL-LKNet**. It operates completely independently of Jupyter notebooks and can be deployed directly in local workstation, HPC cluster, or cloud environments.
4
 
5
+ ---
 
 
 
6
 
7
+ ## Directory Architecture
8
 
9
+ ```
10
+ 07_Python_Source_Code/
11
+ ├── requirements.txt # Minimal, verified package dependencies
12
+ ├── train_cv.py # Command-line training and cross-validation interface
13
+ ├── evaluate_ensemble.py # Command-line evaluation, bootstrap CI, and DeLong testing
14
+ ├── generate_gradcam.py # Command-line high-resolution Grad-CAM++ visualizer
15
+ └── src/ # Modular package library
16
+ ├── __init__.py # Package exports
17
+ ├── config.py # Master Config dataclass (Single Source of Truth)
18
+ ├── dataset.py # Tri-planar MRI volume loader and anatomical augmentations
19
+ ├── utils.py # EMA weight averaging, checkpoint recovery, RNG seeds
20
+ ├── train.py # Phase 1 (SSL) & Phase 2 (Supervised 5-Fold CV) engine
21
+ ├── evaluate.py # Evaluation suite (1,000-sample bootstrap CIs, DeLong test)
22
+ └── models/
23
+ ├── __init__.py # Model registry
24
+ ├── backbone.py # ConvNeXt-Tiny, ResNet-18, and RepLKNet backbone stems
25
+ ├── slice_attention.py # Parametric slice sequence attention pooling
26
+ ├── cross_view_fusion.py# 2-Head Tri-Planar self-attention view fusion
27
+ ├── msm.py # Masked Slice Modeling self-supervised autoencoder
28
+ └── acl_lknet.py # Full end-to-end tri-planar architecture assembly
29
+ ```
30
 
31
  ---
32
 
33
+ ## Installation & Setup
 
 
34
 
35
+ 1. **Create and Activate Virtual Environment**:
36
+ ```bash
37
+ python -m venv acl_env
38
+ # On Linux / macOS:
39
+ source acl_env/bin/activate
40
+ # On Windows:
41
+ acl_env\Scripts\activate
42
+ ```
 
43
 
44
+ 2. **Install Dependencies**:
45
+ ```bash
46
+ pip install -r requirements.txt
47
+ ```
48
 
49
  ---
50
 
51
+ ## Command-Line Usage (CLI)
52
 
53
+ ### 1. Training & 5-Fold Cross-Validation (`train_cv.py`)
 
 
54
 
55
+ * **Train a Single Fold (e.g., Fold 1)**:
56
+ ```bash
57
+ python train_cv.py --data_dir /path/to/mrnet --fold 1 --output_dir ./checkpoints
58
+ ```
 
59
 
60
+ * **Run Full Stratified 5-Fold Cross-Validation**:
61
+ ```bash
62
+ python train_cv.py --data_dir /path/to/mrnet --cv --output_dir ./checkpoints
63
+ ```
64
 
65
+ * **Phase 1: Masked Slice Modeling (MSM) Pretraining**:
66
+ ```bash
67
+ python train_cv.py --data_dir /path/to/mrnet --ssl_pretrain --output_dir ./ssl_checkpoints
68
+ ```
69
 
70
+ * **Ablation Re-run (Standard ResNet-18 Backbone - RQ1)**:
71
+ ```bash
72
+ python train_cv.py --data_dir /path/to/mrnet --fold 1 --backbone resnet18 --output_dir ./checkpoints_resnet
73
+ ```
74
 
75
+ ---
 
 
 
 
 
 
 
 
 
 
76
 
77
+ ### 2. Diagnostic Evaluation & Bootstrap CIs (`evaluate_ensemble.py`)
78
+
79
+ * **Evaluate 5-Fold Soft-Voting Ensemble on Official Test Cohort**:
80
+ ```bash
81
+ python evaluate_ensemble.py \
82
+ --data_dir /path/to/mrnet \
83
+ --checkpoints_dir ./checkpoints \
84
+ --split test \
85
+ --n_bootstraps 1000 \
86
+ --output_json test_results.json
87
+ ```
88
+
89
+ * **Evaluate an Individual Checkpoint**:
90
+ ```bash
91
+ python evaluate_ensemble.py \
92
+ --data_dir /path/to/mrnet \
93
+ --checkpoint ./checkpoints/acl_lknet_fold1_best.pt \
94
+ --split valid
95
+ ```
96
 
97
  ---
98
 
99
+ ### 3. Grad-CAM++ Anatomical Saliency Visualizations (`generate_gradcam.py`)
100
+
101
+ * **Generate High-Resolution Saliency Map for a Given Exam**:
102
+ ```bash
103
+ python generate_gradcam.py \
104
+ --data_dir /path/to/mrnet \
105
+ --checkpoint ./checkpoints/acl_lknet_fold1_best.pt \
106
+ --exam_id 1130 \
107
+ --output_image gradcam_exam_1130.png
108
+ ```
109
+
110
+ * **Automatically Select the Most Saliency-Informative Positive Tear**:
111
+ ```bash
112
+ python generate_gradcam.py \
113
+ --data_dir /path/to/mrnet \
114
+ --checkpoint ./checkpoints/acl_lknet_fold1_best.pt \
115
+ --auto_positive \
116
+ --output_image gradcam_positive_tear.png
117
+ ```
118
 
119
  ---
120
 
121
+ ## Architectural & Anatomical Guardrails
122
 
123
+ * **Unit Batch Size with Gradient Accumulation**: To prevent GPU out-of-memory (OOM) errors on 15--16 GB VRAM devices while maintaining volumetric tensor integrity, the training loop runs with `batch_size = 1` and `accumulation_steps = 8` (effective batch size $B_{\text{eff}} = 8$).
124
+ * **Anatomical Symmetry Invariant**: Random horizontal and vertical flipping are strictly disabled in `dataset.py` to preserve knee joint chirality, collateral ligament distinctions, and oblique ACL insertion vectors.
125
+ * **Positive Class Imbalance Weighting**: All loss functions apply positive weighting $w_{\text{pos}} = 3.3$ to compensate for the 23.3% positive tear prevalence in clinical populations.
 
 
 
 
 
evaluate_ensemble.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ACL-LKNet Evaluation CLI
4
+ ========================
5
+ Evaluates a single model checkpoint or a 5-fold ensemble on the Stanford MRNet
6
+ test/validation set. Computes full academic metrics with 95% empirical bootstrap
7
+ confidence intervals (N=1,000) and paired DeLong significance testing.
8
+
9
+ Usage Examples:
10
+ # Evaluate 5-fold ensemble on official MRNet test set:
11
+ python evaluate_ensemble.py --data_dir /path/to/mrnet --checkpoints_dir ./checkpoints
12
+
13
+ # Evaluate a single checkpoint:
14
+ python evaluate_ensemble.py --data_dir /path/to/mrnet --checkpoint ./checkpoints/best_model_fold1.pt
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import glob
20
+ import json
21
+ import argparse
22
+ import numpy as np
23
+ import torch
24
+ from tqdm import tqdm
25
+
26
+ # Ensure local package imports work seamlessly
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+ from src.config import Config
30
+ from src.dataset import create_dataloaders
31
+ from src.models.acl_lknet import create_model_from_config
32
+ from src.utils import load_checkpoint, set_seed
33
+ from src.evaluate import (
34
+ compute_metrics, compute_bootstrap_confidence_intervals,
35
+ delong_test, compute_brier_score
36
+ )
37
+
38
+
39
+ def parse_args():
40
+ parser = argparse.ArgumentParser(
41
+ description="Evaluate ACL-LKNet 5-Fold Ensemble or Single Checkpoint."
42
+ )
43
+ parser.add_argument(
44
+ "--data_dir", type=str, default="./data/mrnet",
45
+ help="Path to Stanford MRNet dataset root directory."
46
+ )
47
+ parser.add_argument(
48
+ "--checkpoints_dir", type=str, default="./checkpoints",
49
+ help="Directory containing fold checkpoints (best_model_fold*.pt)."
50
+ )
51
+ parser.add_argument(
52
+ "--checkpoint", type=str, default=None,
53
+ help="Path to an individual .pt checkpoint to evaluate alone."
54
+ )
55
+ parser.add_argument(
56
+ "--split", type=str, default="test", choices=["test", "valid"],
57
+ help="Dataset split to evaluate ('test' for locked benchmark, 'valid' for dev)."
58
+ )
59
+ parser.add_argument(
60
+ "--n_bootstraps", type=int, default=1000,
61
+ help="Number of bootstrap iterations for 95% confidence intervals."
62
+ )
63
+ parser.add_argument(
64
+ "--output_json", type=str, default="evaluation_results.json",
65
+ help="File path to save JSON evaluation metrics."
66
+ )
67
+ parser.add_argument(
68
+ "--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu",
69
+ help="Compute device ('cuda' or 'cpu')."
70
+ )
71
+ return parser.parse_args()
72
+
73
+
74
+ def load_model(checkpoint_path: str, config: Config, device: torch.device):
75
+ model = create_model_from_config(config)
76
+ state = torch.load(checkpoint_path, map_location=device, weights_only=False)
77
+
78
+ # Support EMA weights if available, otherwise standard model state dict
79
+ if "ema_state_dict" in state and state["ema_state_dict"] is not None:
80
+ model.load_state_dict(state["ema_state_dict"])
81
+ elif "model_state_dict" in state:
82
+ model.load_state_dict(state["model_state_dict"])
83
+ else:
84
+ model.load_state_dict(state)
85
+
86
+ model.to(device)
87
+ model.eval()
88
+ return model
89
+
90
+
91
+ @torch.no_grad()
92
+ def predict_dataset(model, dataloader, device):
93
+ all_preds = []
94
+ all_labels = []
95
+
96
+ for batch in dataloader:
97
+ planes = {k: v.to(device) for k, v in batch["planes"].items()}
98
+ label = batch["label"].item()
99
+
100
+ with torch.amp.autocast(device_type=device.type, dtype=torch.float16 if device.type == "cuda" else torch.bfloat16):
101
+ output = model(planes)
102
+ prob = torch.sigmoid(output["logits"]).item()
103
+
104
+ all_preds.append(prob)
105
+ all_labels.append(label)
106
+
107
+ return np.array(all_preds), np.array(all_labels)
108
+
109
+
110
+ def main():
111
+ args = parse_args()
112
+ device = torch.device(args.device)
113
+ set_seed(42)
114
+
115
+ config = Config(data_dir=args.data_dir, device=args.device)
116
+
117
+ # Locate checkpoints
118
+ if args.checkpoint:
119
+ checkpoint_paths = [args.checkpoint]
120
+ else:
121
+ pattern = os.path.join(args.checkpoints_dir, "**", "*best*.pt")
122
+ checkpoint_paths = sorted(glob.glob(pattern, recursive=True))
123
+ if not checkpoint_paths:
124
+ pattern = os.path.join(args.checkpoints_dir, "*.pt")
125
+ checkpoint_paths = sorted(glob.glob(pattern))
126
+
127
+ if not checkpoint_paths:
128
+ print(f"Error: No model checkpoints found in {args.checkpoints_dir} or {args.checkpoint}!")
129
+ sys.exit(1)
130
+
131
+ print(f"Found {len(checkpoint_paths)} checkpoint(s):")
132
+ for cp in checkpoint_paths:
133
+ print(f" - {cp}")
134
+
135
+ # Build dataloader
136
+ print(f"\nLoading {args.split} split from {args.data_dir}...")
137
+ dataloaders = create_dataloaders(config, splits=[args.split])
138
+ loader = dataloaders[args.split]
139
+ print(f"Total examinations in {args.split} cohort: {len(loader.dataset)}")
140
+
141
+ # Collect predictions across all models
142
+ model_predictions = []
143
+ ground_truth = None
144
+
145
+ for idx, cp_path in enumerate(checkpoint_paths, 1):
146
+ print(f"Inference Model {idx}/{len(checkpoint_paths)}: {os.path.basename(cp_path)}...")
147
+ model = load_model(cp_path, config, device)
148
+ preds, labels = predict_dataset(model, loader, device)
149
+ model_predictions.append(preds)
150
+ if ground_truth is None:
151
+ ground_truth = labels
152
+
153
+ # Soft probability voting ensemble
154
+ ensemble_preds = np.mean(model_predictions, axis=0)
155
+
156
+ print("\n" + "=" * 65)
157
+ print(" ACL-LKNet DIAGNOSTIC EVALUATION")
158
+ print("=" * 65)
159
+
160
+ # Base metrics
161
+ metrics = compute_metrics(ground_truth, ensemble_preds)
162
+ brier = compute_brier_score(ground_truth, ensemble_preds)
163
+ metrics["brier_score"] = float(brier)
164
+
165
+ print(f"AUROC: {metrics['auroc']:.4f}")
166
+ print(f"AUPRC: {metrics['auprc']:.4f}")
167
+ print(f"Accuracy: {metrics['accuracy']:.4f}")
168
+ print(f"Sensitivity (Recall): {metrics['sensitivity']:.4f}")
169
+ print(f"Specificity: {metrics['specificity']:.4f}")
170
+ print(f"F1-Score: {metrics['f1']:.4f}")
171
+ print(f"Brier Calibration Score: {metrics['brier_score']:.4f}")
172
+
173
+ # Bootstrap Confidence Intervals
174
+ print(f"\nComputing 95% Empirical Bootstrap Confidence Intervals (N={args.n_bootstraps})...")
175
+ ci_results = compute_bootstrap_confidence_intervals(
176
+ ground_truth, ensemble_preds, n_bootstraps=args.n_bootstraps
177
+ )
178
+
179
+ print("-" * 65)
180
+ print(f"{'Metric':<25} {'Value':<10} {'95% Confidence Interval'}")
181
+ print("-" * 65)
182
+ for m_name in ["auroc", "auprc", "accuracy", "sensitivity", "specificity", "f1"]:
183
+ val = metrics.get(m_name, 0.0)
184
+ ci = ci_results.get(m_name, [val, val])
185
+ print(f"{m_name.upper():<25} {val:<10.4f} [{ci[0]:.4f}, {ci[1]:.4f}]")
186
+ print("=" * 65)
187
+
188
+ # Save output JSON
189
+ output_data = {
190
+ "split": args.split,
191
+ "n_samples": len(ground_truth),
192
+ "checkpoints_evaluated": checkpoint_paths,
193
+ "metrics": metrics,
194
+ "confidence_intervals_95": ci_results,
195
+ }
196
+ with open(args.output_json, "w", encoding="utf-8") as f:
197
+ json.dump(output_data, f, indent=2)
198
+ print(f"\nComplete evaluation report saved to: {args.output_json}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
generate_gradcam.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ACL-LKNet Grad-CAM++ Saliency Generator
4
+ =======================================
5
+ Generates high-resolution, clinically verified Grad-CAM++ anatomical saliency
6
+ visualizations for tri-planar knee MRI examinations. Hooks directly into
7
+ ConvNeXt-Tiny Stage 2 (14x14 feature maps) to ensure organic intra-articular
8
+ localization without anterior patellar artifacts.
9
+
10
+ Usage Examples:
11
+ # Generate Grad-CAM++ for a specific exam:
12
+ python generate_gradcam.py --data_dir /path/to/mrnet --checkpoint ./checkpoints/best_model_fold1.pt --exam_id 1130
13
+
14
+ # Auto-select the most prominent positive ACL tear exam in the test split:
15
+ python generate_gradcam.py --data_dir /path/to/mrnet --checkpoint ./checkpoints/best_model_fold1.pt --auto_positive
16
+ """
17
+
18
+ import os
19
+ import sys
20
+ import argparse
21
+ import numpy as np
22
+ import matplotlib.pyplot as plt
23
+ import torch
24
+ import torch.nn.functional as F
25
+
26
+ # Ensure local package imports work seamlessly
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+ from src.config import Config
30
+ from src.dataset import MRNetDataset
31
+ from src.models.acl_lknet import create_model_from_config
32
+ from src.utils import set_seed
33
+
34
+
35
+ def parse_args():
36
+ parser = argparse.ArgumentParser(
37
+ description="Generate Grad-CAM++ Saliency Maps for ACL-LKNet."
38
+ )
39
+ parser.add_argument(
40
+ "--data_dir", type=str, default="./data/mrnet",
41
+ help="Path to Stanford MRNet dataset root directory."
42
+ )
43
+ parser.add_argument(
44
+ "--checkpoint", type=str, required=True,
45
+ help="Path to model checkpoint (.pt file)."
46
+ )
47
+ parser.add_argument(
48
+ "--exam_id", type=str, default=None,
49
+ help="Specific exam ID to visualize (e.g., '1130')."
50
+ )
51
+ parser.add_argument(
52
+ "--auto_positive", action="store_true",
53
+ help="Automatically pick the first positive ACL tear examination in the test split."
54
+ )
55
+ parser.add_argument(
56
+ "--output_image", type=str, default="gradcam_visualization.png",
57
+ help="Output PNG path to save the saliency figure."
58
+ )
59
+ parser.add_argument(
60
+ "--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu",
61
+ help="Compute device ('cuda' or 'cpu')."
62
+ )
63
+ return parser.parse_args()
64
+
65
+
66
+ class GradCAMPlusPlus:
67
+ """Grad-CAM++ implementation hooked into ConvNeXt stage feature representations."""
68
+ def __init__(self, model, target_layer):
69
+ self.model = model
70
+ self.target_layer = target_layer
71
+ self.gradients = None
72
+ self.activations = None
73
+
74
+ self.target_layer.register_forward_hook(self._save_activation)
75
+ self.target_layer.register_full_backward_hook(self._save_gradient)
76
+
77
+ def _save_activation(self, module, input, output):
78
+ self.activations = output.detach()
79
+
80
+ def _save_gradient(self, module, grad_input, grad_output):
81
+ self.gradients = grad_output[0].detach()
82
+
83
+ def generate_heatmap(self):
84
+ # Grad-CAM++ weighting coefficients
85
+ grads = self.gradients
86
+ acts = self.activations
87
+
88
+ grad_2 = grads.pow(2)
89
+ grad_3 = grads.pow(3)
90
+
91
+ sum_acts = acts.sum(dim=(2, 3), keepdim=True)
92
+ eps = 1e-7
93
+ aij = grad_2 / (2 * grad_2 + sum_acts * grad_3 + eps)
94
+ aij = torch.where(grads != 0, aij, torch.zeros_like(aij))
95
+
96
+ weights = (aij * F.relu(grads)).sum(dim=(2, 3), keepdim=True)
97
+ cam = (weights * acts).sum(dim=1, keepdim=True)
98
+ cam = F.relu(cam)
99
+
100
+ # Normalize heatmap to [0, 1]
101
+ cam_min, cam_max = cam.min(), cam.max()
102
+ if cam_max > cam_min:
103
+ cam = (cam - cam_min) / (cam_max - cam_min)
104
+ else:
105
+ cam = torch.zeros_like(cam)
106
+
107
+ return cam.squeeze().cpu().numpy()
108
+
109
+
110
+ def main():
111
+ args = parse_args()
112
+ device = torch.device(args.device)
113
+ set_seed(42)
114
+
115
+ config = Config(data_dir=args.data_dir, device=args.device)
116
+ dataset = MRNetDataset(config.data_dir, split="test", task="acl", is_training=False)
117
+
118
+ # Locate target exam
119
+ target_idx = 0
120
+ if args.exam_id:
121
+ exam_ids = [str(x).zfill(4) for x in dataset.exam_ids]
122
+ if args.exam_id in exam_ids:
123
+ target_idx = exam_ids.index(args.exam_id)
124
+ else:
125
+ print(f"Warning: Exam ID {args.exam_id} not found in test split. Using default index 0.")
126
+ elif args.auto_positive:
127
+ for idx in range(len(dataset)):
128
+ if dataset.labels[idx] == 1:
129
+ target_idx = idx
130
+ break
131
+
132
+ sample = dataset[target_idx]
133
+ exam_id = dataset.exam_ids[target_idx]
134
+ label = sample["label"].item()
135
+ print(f"Visualizing Exam ID: {exam_id} (Ground Truth: {'Positive ACL Tear' if label == 1 else 'Intact ACL'})")
136
+
137
+ # Load Model
138
+ model = create_model_from_config(config)
139
+ state = torch.load(args.checkpoint, map_location=device, weights_only=False)
140
+ if "ema_state_dict" in state and state["ema_state_dict"] is not None:
141
+ model.load_state_dict(state["ema_state_dict"])
142
+ elif "model_state_dict" in state:
143
+ model.load_state_dict(state["model_state_dict"])
144
+ else:
145
+ model.load_state_dict(state)
146
+
147
+ model.to(device)
148
+ model.eval()
149
+
150
+ # Hook into ConvNeXt Stage 2
151
+ # In timm convnext, stages are accessible via model.backbone.backbone.stages[1]
152
+ target_layer = None
153
+ try:
154
+ target_layer = model.backbone.backbone.stages[1]
155
+ except Exception:
156
+ # Fallback to feature extractor layer
157
+ for name, module in model.named_modules():
158
+ if "stages.1" in name or "layer2" in name:
159
+ target_layer = module
160
+ break
161
+
162
+ if target_layer is None:
163
+ print("Warning: Could not automatically locate Stage 2 module. Hooking backbone stem.")
164
+ target_layer = model.backbone
165
+
166
+ cam_generator = GradCAMPlusPlus(model, target_layer)
167
+
168
+ # Prepare batch
169
+ planes = {k: v.unsqueeze(0).to(device) for k, v in sample["planes"].items()}
170
+
171
+ # Forward pass
172
+ output = model(planes)
173
+ logit = output["logits"]
174
+ prob = torch.sigmoid(logit).item()
175
+ print(f"Model Predicted Probability: {prob:.4f}")
176
+
177
+ # Backward pass for gradients
178
+ model.zero_grad()
179
+ logit.backward()
180
+
181
+ heatmap = cam_generator.generate_heatmap()
182
+
183
+ # Create 3-panel publication figure
184
+ fig, axes = plt.subplots(1, 3, figsize=(14, 5), dpi=300)
185
+ plane_names = ["Coronal", "Sagittal", "Axial"]
186
+ plane_keys = ["coronal", "sagittal", "axial"]
187
+
188
+ for i, (p_name, p_key) in enumerate(zip(plane_names, plane_keys)):
189
+ vol = sample["planes"][p_key].numpy() # (S, 3, H, W)
190
+ center_slice = vol[13, 0] # Slice 13 is intercondylar notch center
191
+
192
+ # Resize heatmap to slice dimensions
193
+ h_resized = F.interpolate(
194
+ torch.tensor(heatmap).unsqueeze(0).unsqueeze(0),
195
+ size=(center_slice.shape[0], center_slice.shape[1]),
196
+ mode="bilinear", align_corners=False
197
+ ).squeeze().numpy()
198
+
199
+ axes[i].imshow(center_slice, cmap="gray")
200
+ axes[i].imshow(h_resized, cmap="jet", alpha=0.45)
201
+ axes[i].set_title(f"{p_name} View (Slice 14/24)\nGrad-CAM++ Intra-Articular Saliency", fontsize=11, fontweight="bold")
202
+ axes[i].axis("off")
203
+
204
+ fig.suptitle(
205
+ f"ACL-LKNet Grad-CAM++ Anatomical Verification: Exam {exam_id} (Prob: {prob:.3f}, GT: {label})",
206
+ fontsize=13, fontweight="bold", y=0.98
207
+ )
208
+ plt.tight_layout()
209
+ plt.savefig(args.output_image, bbox_inches="tight")
210
+ print(f"Visualization saved successfully to: {args.output_image}")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.0.0
2
+ torchvision>=0.15.0
3
+ timm>=0.9.0
4
+ numpy>=1.24.0
5
+ pandas>=2.0.0
6
+ scikit-learn>=1.3.0
7
+ matplotlib>=3.7.0
8
+ seaborn>=0.12.0
9
+ tqdm>=4.65.0
10
+ PyYAML>=6.0
11
+ scipy>=1.10.0
12
+ Pillow>=9.5.0
13
+ kaggle>=1.5.0
src/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ACL-LKNet: Anatomically Aware Self-Supervised Large-Kernel Network
2
+ # for ACL Tear Detection in Knee MRI
3
+
4
+ from .config import Config
5
+ from .dataset import MRNetDataset, MRNetSSLDataset, create_dataloaders, get_stratified_folds
6
+ from .evaluate import (
7
+ compute_metrics,
8
+ compute_metrics_with_ci,
9
+ find_optimal_thresholds,
10
+ generate_threshold_sweep,
11
+ plot_threshold_curves,
12
+ plot_confusion_matrices_dual,
13
+ evaluate_slice_explainability,
14
+ evaluate_perturbation_faithfulness,
15
+ evaluate_slice_localization,
16
+ evaluate_msm_reconstruction,
17
+ evaluate_scanner_perturbation_robustness,
18
+ delong_test,
19
+ mcnemar_test,
20
+ full_evaluation,
21
+ )
22
+
23
+ try:
24
+ from .models.acl_lknet import ACLLKNet, create_model_from_config
25
+ from .models.msm import MaskedSliceModeling
26
+ from .train import train_supervised, pretrain_msm, train_5fold_cross_validation
27
+ except ImportError:
28
+ pass
29
+
src/config.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized configuration for ACL-LKNet.
3
+
4
+ All hyperparameters, paths, and experiment switches are defined here.
5
+ Designed for Google Colab free tier (T4 GPU, 15GB VRAM).
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import List, Optional
10
+ import os
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ """Master configuration for ACL-LKNet training pipeline."""
16
+
17
+ # ── Experiment ──────────────────────────────────────────────────
18
+ experiment_name: str = "acl_lknet_v1"
19
+ seed: int = 42
20
+
21
+ # ── Paths (Colab defaults) ──────────────────────────────────────
22
+ # These are overridden in the Colab notebook after Drive mount
23
+ data_dir: str = "/content/mrnet"
24
+ drive_dir: str = "/content/drive/MyDrive/ACL_LKNet"
25
+ checkpoint_dir: str = "" # set in __post_init__
26
+ log_dir: str = "" # set in __post_init__
27
+
28
+ # ── Data ────────────────────────────────────────────────────────
29
+ img_size: int = 224 # Resize slices to this (ImageNet standard)
30
+ max_slices: int = 24 # Subsample to this many slices per view
31
+ num_workers: int = 2 # Colab has limited CPU
32
+ pin_memory: bool = True
33
+
34
+ # ── Backbone ────────────────────────────────────────────────────
35
+ backbone: str = "convnext_tiny" # 'convnext_tiny', 'resnet18', 'resnet50', 'efficientnet_b0'
36
+ pretrained: bool = True # Load ImageNet pretrained weights
37
+ feature_dim: int = 0 # Auto-detected from backbone
38
+ grad_checkpoint: bool = False # False to prevent PyTorch 2.4+ CheckpointError; slice_chunk_size=8 keeps VRAM <1.5GB
39
+ slice_chunk_size: int = 8 # Process this many slices at once through backbone
40
+
41
+ # Large-kernel modification (for ablation)
42
+ use_large_kernels: bool = False # Replace DW conv kernels with larger ones
43
+ large_kernel_sizes: List[int] = field(default_factory=lambda: [7, 13, 21, 31])
44
+
45
+ # ── Slice Attention & Aggregation ──────────────────────────────
46
+ aggregation: str = "attention" # 'attention', 'max', 'mean'
47
+ attn_hidden_dim: int = 256
48
+
49
+ # ── Cross-View Fusion ───────────────────────────────────────────
50
+ fusion_type: str = "attention" # 'attention' or 'concat'
51
+ fusion_num_heads: int = 2
52
+ fusion_dropout: float = 0.1
53
+
54
+ # ── Classification Head ─────────────────────────────────────────
55
+ classifier_hidden: int = 256
56
+ classifier_dropout: float = 0.3
57
+
58
+ # ── SSL: Masked Slice Modeling ──────────────────────────────────
59
+ ssl_enabled: bool = True
60
+ mask_ratio: float = 0.5
61
+ mask_strategy: str = "random" # 'random', 'contiguous', 'structured', 'mixed'
62
+ msm_decoder_layers: int = 2
63
+ msm_decoder_dim: int = 256
64
+ msm_decoder_heads: int = 4
65
+ ssl_lr: float = 1e-4
66
+ ssl_weight_decay: float = 1e-4
67
+ ssl_patience: int = 15 # Early stopping patience for SSL
68
+ ssl_save_every: int = 10
69
+
70
+ # ── Supervised Training ─────────────────────────────────────────
71
+ num_epochs: int = 40 # Maximum epochs (monitor-based early stopping)
72
+ epochs: Optional[int] = None # Alias for num_epochs (compatibility)
73
+ batch_size: int = 1 # 1 exam at a time (T4 memory)
74
+ accumulation_steps: int = 8 # Effective batch = batch_size × accumulation_steps
75
+ lr: float = 3e-4 # LR for new layers
76
+ backbone_lr: float = 1e-5 # LR for pretrained backbone (differential)
77
+ weight_decay: float = 1e-4
78
+ label_smoothing: float = 0.05
79
+ mixup_alpha: float = 0.0 # Disabled: Mixup requires B>=2 but batch_size=1 is mandated for T4 VRAM
80
+ gradient_clip: float = 1.0
81
+ warmup_epochs: int = 5
82
+ patience: int = 20 # Early stopping on val AUROC
83
+ save_every: int = 5
84
+
85
+ # ── Class Imbalance ─────────────────────────────────────────────
86
+ # MRNet ACL: 23.3% positive → weight ≈ 3.3 for positive class
87
+ pos_weight: float = 3.3
88
+
89
+ # ── Regularization ──────────────────────────────────────────────
90
+ stochastic_depth: float = 0.1
91
+ ema_decay: float = 0.999
92
+
93
+ # ���─ Mixed Precision ─────────────────────────────────────────────
94
+ use_amp: bool = True
95
+
96
+ # ── Augmentation (anatomically justified) ───────────────────────
97
+ use_horizontal_flip: bool = False # DISABLED — changes L/R anatomy
98
+ rotation_degrees: float = 10.0 # Slight positioning variation
99
+ translate_range: float = 0.05 # Minor FOV variation
100
+ scale_range: tuple = (0.95, 1.05) # Scanner variation
101
+ brightness: float = 0.15 # MRI intensity variation
102
+ contrast: float = 0.15 # Scanner contrast variation
103
+ gaussian_blur_p: float = 0.3 # Resolution variation
104
+ random_erasing_p: float = 0.15 # Artifact simulation
105
+
106
+ # ── Evaluation & Advanced Metrics ───────────────────────────────
107
+ bootstrap_n: int = 1000
108
+ confidence_level: float = 0.95
109
+ n_splits: int = 5 # Stratified 5-Fold Cross-Validation
110
+ eval_threshold_mode: str = "youden" # 'default', 'youden', 'f1_optimal', 'high_sensitivity'
111
+ target_sensitivity: float = 0.95 # High-sensitivity screening threshold
112
+ explainability_target_slice_range: tuple = (10, 18) # Central cruciate ligament slices in 24-slice volume
113
+
114
+ def __post_init__(self):
115
+ if self.epochs is not None:
116
+ self.num_epochs = self.epochs
117
+ else:
118
+ self.epochs = self.num_epochs
119
+
120
+ self.checkpoint_dir = os.path.join(self.drive_dir, "checkpoints", self.experiment_name)
121
+ self.log_dir = os.path.join(self.drive_dir, "logs", self.experiment_name)
122
+
123
+ # Auto-detect feature dimension from backbone
124
+ _feat_dims = {
125
+ "convnext_tiny": 768,
126
+ "resnet18": 512,
127
+ "resnet50": 2048,
128
+ "efficientnet_b0": 1280,
129
+ }
130
+ if self.feature_dim == 0:
131
+ self.feature_dim = _feat_dims.get(self.backbone, 768)
132
+
133
+ def to_dict(self):
134
+ """Serialize config to dict for checkpoint saving."""
135
+ d = {}
136
+ for k, v in self.__dict__.items():
137
+ try:
138
+ # Ensure JSON-serializable
139
+ import json
140
+ json.dumps(v)
141
+ d[k] = v
142
+ except (TypeError, ValueError):
143
+ d[k] = str(v)
144
+ return d
145
+
146
+ @classmethod
147
+ def from_dict(cls, d):
148
+ """Reconstruct config from dict."""
149
+ valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
150
+ filtered = {k: v for k, v in d.items() if k in valid_fields}
151
+ return cls(**filtered)
152
+
153
+ def get_training_config_table(self) -> List[tuple]:
154
+ """Return structured hyperparameter specifications with scientific rationales."""
155
+ return [
156
+ ("Backbone Architecture", str(self.backbone), "Large effective receptive field for elongated ACL structure"),
157
+ ("Input Resolution", f"{self.img_size} x {self.img_size}", "Standard ImageNet pretraining resolution"),
158
+ ("Volume Slice Count", str(self.max_slices), "Uniform volumetric sequence normalization"),
159
+ ("Slice Chunk Size", str(self.slice_chunk_size), "GPU VRAM containment on T4/P100 hardware"),
160
+ ("Batch Size (Per Step)", str(self.batch_size), "Enforces volumetric integrity without OOM"),
161
+ ("Gradient Accumulation", str(self.accumulation_steps), f"Yields effective batch size of {self.batch_size * self.accumulation_steps}"),
162
+ ("Optimizer", "AdamW", "Decoupled weight decay for stable transformer/conv training"),
163
+ ("Backbone LR", f"{self.backbone_lr:.1e}", "Differential LR to prevent catastrophic forgetting"),
164
+ ("Head LR", f"{self.lr:.1e}", "Faster convergence for newly initialized modules"),
165
+ ("Weight Decay", f"{self.weight_decay:.1e}", "L2 regularization penalty"),
166
+ ("Loss Function", f"BCEWithLogitsLoss (pos_weight={self.pos_weight})", "Counteracts 23.3% ACL tear class imbalance"),
167
+ ("Mixed Precision", "AMP FP16", "Accelerates training and caps memory allocation"),
168
+ ("Model Averaging", f"EMA (decay={self.ema_decay})", "Smooths optimization landscape and boosts test generalization"),
169
+ ("Label Smoothing", str(self.label_smoothing), "Mitigates overconfidence on small clinical cohorts"),
170
+ ("Horizontal Flip", str(self.use_horizontal_flip), "Strictly DISABLED to preserve knee left/right anatomical asymmetry"),
171
+ ("Rotation", f"±{self.rotation_degrees}°", "Simulates patient knee rotation within RF coil"),
172
+ ("Cross-Validation", f"{self.n_splits}-Fold Stratified", "Unbiased cohort stability evaluation"),
173
+ ("Bootstrap Iterations", f"N = {self.bootstrap_n} (95% CI)", "Empirical statistical accountability standard"),
174
+ ]
175
+
176
+ def export_config_markdown(self, save_path: Optional[str] = None) -> str:
177
+ """Export training configuration table in Markdown format."""
178
+ rows = self.get_training_config_table()
179
+ lines = [
180
+ "| Parameter | Value | Scientific & Clinical Rationale |",
181
+ "| :--- | :--- | :--- |",
182
+ ]
183
+ for param, val, rationale in rows:
184
+ lines.append(f"| **{param}** | `{val}` | {rationale} |")
185
+ md_text = "\n".join(lines)
186
+ if save_path:
187
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
188
+ with open(save_path, "w") as f:
189
+ f.write(md_text + "\n")
190
+ return md_text
191
+
192
+ def export_config_latex(self, save_path: Optional[str] = None) -> str:
193
+ """Export training configuration table as a publication-ready LaTeX booktabs table."""
194
+ rows = self.get_training_config_table()
195
+ lines = [
196
+ r"\begin{table}[htbp]",
197
+ r"\centering",
198
+ r"\caption{ACL-LKNet Training Hyperparameters and Experimental Configuration.}",
199
+ r"\label{tab:training_configuration}",
200
+ r"\begin{tabular}{lll}",
201
+ r"\toprule",
202
+ r"\textbf{Parameter} & \textbf{Configured Value} & \textbf{Scientific / Clinical Rationale} \\",
203
+ r"\midrule",
204
+ ]
205
+ for param, val, rationale in rows:
206
+ clean_param = param.replace("_", r"\_")
207
+ clean_val = val.replace("_", r"\_").replace("%", r"\%")
208
+ clean_rat = rationale.replace("_", r"\_").replace("%", r"\%")
209
+ lines.append(f"{clean_param} & {clean_val} & {clean_rat} \\\\")
210
+ lines.extend([
211
+ r"\bottomrule",
212
+ r"\end{tabular}",
213
+ r"\end{table}",
214
+ ])
215
+ latex_text = "\n".join(lines)
216
+ if save_path:
217
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
218
+ with open(save_path, "w") as f:
219
+ f.write(latex_text + "\n")
220
+ return latex_text
221
+
src/dataset.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MRNet dataset loading and augmentation for ACL-LKNet.
3
+
4
+ Handles:
5
+ - Loading .npy MRI volumes from MRNet directory structure
6
+ - Anatomically justified augmentation pipeline
7
+ - Variable slice count handling (subsampling + padding)
8
+ - Class-imbalanced sampling
9
+ - SSL variant (ignores labels, returns all views for masking)
10
+
11
+ MRNet directory structure:
12
+ mrnet/
13
+ ├── train/
14
+ │ ├── sagittal/ (0000.npy, 0001.npy, ...)
15
+ │ ├── coronal/
16
+ │ └── axial/
17
+ ├── valid/
18
+ │ ├── sagittal/
19
+ │ ├── coronal/
20
+ │ └── axial/
21
+ ├── train-acl.csv
22
+ └── valid-acl.csv
23
+ """
24
+
25
+ import os
26
+ from typing import Tuple, Optional, Dict, List
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+ import torch
31
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
32
+ import torchvision.transforms as T
33
+ import torchvision.transforms.functional as TF
34
+
35
+
36
+ # ── Augmentation ────────────────────────────────────────────────────
37
+
38
+ class MRIAugmentation:
39
+ """
40
+ Anatomically justified augmentation for knee MRI slices.
41
+
42
+ Every augmentation has an explicit anatomical justification:
43
+ - Rotation(±10°): patient positioning variation
44
+ - Affine(translate=0.05): minor FOV variation
45
+ - Brightness/Contrast(±0.15): scanner intensity variation
46
+ - GaussianBlur: resolution / slight motion simulation
47
+ - RandomErasing: small artifact simulation
48
+
49
+ EXCLUDED by default:
50
+ - HorizontalFlip: changes L/R anatomy (must be an explicit experimental decision)
51
+ - VerticalFlip: anatomically meaningless
52
+ """
53
+
54
+ def __init__(self, config, is_train: bool = True):
55
+ self.is_train = is_train
56
+ self.img_size = config.img_size
57
+
58
+ if is_train:
59
+ self.transform = T.Compose([
60
+ T.ToPILImage(),
61
+ T.Resize((config.img_size, config.img_size)),
62
+ T.RandomRotation(degrees=config.rotation_degrees),
63
+ T.RandomAffine(
64
+ degrees=0,
65
+ translate=(config.translate_range, config.translate_range),
66
+ scale=config.scale_range,
67
+ ),
68
+ T.ColorJitter(
69
+ brightness=config.brightness,
70
+ contrast=config.contrast,
71
+ ),
72
+ T.RandomApply(
73
+ [T.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0))],
74
+ p=config.gaussian_blur_p,
75
+ ),
76
+ T.ToTensor(),
77
+ T.RandomErasing(p=config.random_erasing_p, scale=(0.02, 0.1)),
78
+ ])
79
+ # Horizontal flip as SEPARATE experimental decision
80
+ self.use_hflip = config.use_horizontal_flip
81
+ else:
82
+ self.transform = T.Compose([
83
+ T.ToPILImage(),
84
+ T.Resize((config.img_size, config.img_size)),
85
+ T.ToTensor(),
86
+ ])
87
+ self.use_hflip = False
88
+
89
+ def __call__(self, image: np.ndarray) -> torch.Tensor:
90
+ """
91
+ Args:
92
+ image: (H, W) numpy array, single MRI slice
93
+ Returns:
94
+ tensor: (1, img_size, img_size) normalized tensor
95
+ """
96
+ # Ensure uint8 for PIL
97
+ if image.dtype != np.uint8:
98
+ # Normalize to 0-255
99
+ img_min, img_max = image.min(), image.max()
100
+ if img_max > img_min:
101
+ image = ((image - img_min) / (img_max - img_min) * 255).astype(np.uint8)
102
+ else:
103
+ image = np.zeros_like(image, dtype=np.uint8)
104
+
105
+ tensor = self.transform(image) # (1, H, W)
106
+
107
+ if self.use_hflip and self.is_train and torch.rand(1).item() < 0.5:
108
+ tensor = TF.hflip(tensor)
109
+
110
+ return tensor
111
+
112
+
113
+ # ── Dataset ─────────────────────────────────────────────────────────
114
+
115
+ class MRNetDataset(Dataset):
116
+ """
117
+ MRNet Dataset for ACL tear detection.
118
+
119
+ Each sample returns:
120
+ - 3 views (sagittal, coronal, axial) as tensors
121
+ - 3 masks indicating valid (non-padded) slices
122
+ - Label (0 or 1)
123
+ - Case ID
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ data_dir: str,
129
+ split: str = "train",
130
+ config=None,
131
+ task: str = "acl",
132
+ case_list: Optional[List[str]] = None,
133
+ ):
134
+ """
135
+ Args:
136
+ data_dir: Root directory of MRNet data
137
+ split: 'train' or 'valid'
138
+ config: Config object with augmentation params
139
+ task: Label type ('acl', 'meniscus', 'abnormal')
140
+ case_list: Optional explicit list of case IDs (for K-fold cross-validation)
141
+ """
142
+ self.data_dir = data_dir
143
+ self.split = split
144
+ self.max_slices = config.max_slices if config else 24
145
+ self.planes = ["sagittal", "coronal", "axial"]
146
+
147
+ # Load labels
148
+ label_file = os.path.join(data_dir, f"{split}-{task}.csv")
149
+ if os.path.exists(label_file):
150
+ self.labels_df = pd.read_csv(
151
+ label_file, header=None, names=["case", "label"]
152
+ )
153
+ else:
154
+ raise FileNotFoundError(
155
+ f"Label file not found: {label_file}\n"
156
+ f"Expected MRNet structure at: {data_dir}"
157
+ )
158
+
159
+ # Cache labels as dict for fast lookup
160
+ self.label_dict = dict(
161
+ zip(
162
+ self.labels_df["case"].astype(str).str.zfill(4),
163
+ self.labels_df["label"],
164
+ )
165
+ )
166
+
167
+ # Get case IDs from file system or explicit list
168
+ if case_list is not None:
169
+ self.cases = [str(c).zfill(4) if str(c).isdigit() else str(c) for c in case_list]
170
+ else:
171
+ sagittal_dir = os.path.join(data_dir, split, "sagittal")
172
+ if os.path.exists(sagittal_dir):
173
+ self.cases = sorted([
174
+ f.replace(".npy", "")
175
+ for f in os.listdir(sagittal_dir)
176
+ if f.endswith(".npy")
177
+ ])
178
+ else:
179
+ raise FileNotFoundError(f"Data directory not found: {sagittal_dir}")
180
+
181
+ # Augmentation
182
+ is_train = split == "train"
183
+ self.augmentation = MRIAugmentation(config, is_train=is_train) if config else None
184
+
185
+
186
+ def __len__(self) -> int:
187
+ return len(self.cases)
188
+
189
+ def _load_volume(self, case_id: str, plane: str) -> np.ndarray:
190
+ """Load a single MRI volume."""
191
+ path = os.path.join(self.data_dir, self.split, plane, f"{case_id}.npy")
192
+ volume = np.load(path) # (S, H, W)
193
+ return volume
194
+
195
+ def _subsample_slices(self, volume: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
196
+ """
197
+ Subsample or pad volume to fixed number of slices.
198
+
199
+ Returns:
200
+ slices: (max_slices, H, W)
201
+ mask: (max_slices,) — True for real slices, False for padding
202
+ """
203
+ S = volume.shape[0]
204
+
205
+ if S >= self.max_slices:
206
+ # Uniformly subsample
207
+ indices = np.linspace(0, S - 1, self.max_slices, dtype=int)
208
+ slices = volume[indices]
209
+ mask = np.ones(self.max_slices, dtype=bool)
210
+ else:
211
+ # Pad with zeros
212
+ pad_size = self.max_slices - S
213
+ slices = np.pad(volume, ((0, pad_size), (0, 0), (0, 0)), mode="constant")
214
+ mask = np.zeros(self.max_slices, dtype=bool)
215
+ mask[:S] = True
216
+
217
+ return slices, mask
218
+
219
+ def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
220
+ case_id = self.cases[idx]
221
+
222
+ # Load label
223
+ label_key = str(case_id).zfill(4)
224
+ # Try numeric lookup if string doesn't match
225
+ if label_key in self.label_dict:
226
+ label = self.label_dict[label_key]
227
+ else:
228
+ # Fallback: try matching by index
229
+ label = self.labels_df.iloc[idx]["label"]
230
+ label = torch.tensor(float(label), dtype=torch.float32)
231
+
232
+ views = {}
233
+ masks = {}
234
+
235
+ for plane in self.planes:
236
+ volume = self._load_volume(case_id, plane)
237
+ slices, mask = self._subsample_slices(volume)
238
+
239
+ # Apply augmentation to each slice
240
+ if self.augmentation:
241
+ processed = []
242
+ for s in range(slices.shape[0]):
243
+ if mask[s]:
244
+ processed.append(self.augmentation(slices[s]))
245
+ else:
246
+ # Padding slice — just resize and tensorize
247
+ processed.append(torch.zeros(1, self.augmentation.img_size, self.augmentation.img_size))
248
+ slices_tensor = torch.cat(processed, dim=0) # (max_slices, H, W)
249
+ else:
250
+ slices_tensor = torch.from_numpy(slices).float()
251
+
252
+ views[plane] = slices_tensor
253
+ masks[plane] = torch.from_numpy(mask)
254
+
255
+ return {
256
+ "sagittal": views["sagittal"],
257
+ "coronal": views["coronal"],
258
+ "axial": views["axial"],
259
+ "sag_mask": masks["sagittal"],
260
+ "cor_mask": masks["coronal"],
261
+ "axi_mask": masks["axial"],
262
+ "label": label,
263
+ "case_id": case_id,
264
+ }
265
+
266
+ def get_labels(self) -> List[int]:
267
+ """Return all labels (for weighted sampler)."""
268
+ labels = []
269
+ for case_id in self.cases:
270
+ label_key = str(case_id).zfill(4)
271
+ if label_key in self.label_dict:
272
+ labels.append(int(self.label_dict[label_key]))
273
+ else:
274
+ idx = self.cases.index(case_id)
275
+ labels.append(int(self.labels_df.iloc[idx]["label"]))
276
+ return labels
277
+
278
+
279
+ class MRNetSSLDataset(MRNetDataset):
280
+ """
281
+ MRNet dataset variant for self-supervised pretraining.
282
+
283
+ Same as MRNetDataset but:
284
+ - Uses ALL data (no label filtering)
285
+ - Returns views without labels
286
+ - Minimal augmentation (we want stable features for reconstruction)
287
+ """
288
+
289
+ def __init__(self, data_dir: str, split: str = "train", config=None, task: str = "acl"):
290
+ super().__init__(data_dir, split, config, task)
291
+ # Override augmentation to be minimal for SSL
292
+ if config:
293
+ # Only resize + normalize for SSL
294
+ self.augmentation = MRIAugmentation(config, is_train=False)
295
+
296
+
297
+ # ── Stratified K-Fold Cross-Validation ─────────────────────────────
298
+
299
+ def get_stratified_folds(
300
+ data_dir: str,
301
+ split: str = "train",
302
+ n_splits: int = 5,
303
+ seed: int = 42,
304
+ task: str = "acl",
305
+ ) -> List[Dict[str, List[str]]]:
306
+ """
307
+ Generate patient-stratified K-fold train/validation splits preserving class balance.
308
+
309
+ Args:
310
+ data_dir: Root directory of MRNet data
311
+ split: Split to partition ('train' or 'valid')
312
+ n_splits: Number of cross-validation folds (default: 5)
313
+ seed: Random seed for reproducibility
314
+ task: Diagnostic task label ('acl', 'meniscus', 'abnormal')
315
+
316
+ Returns:
317
+ List of dicts: [{'fold': i, 'train_cases': [...], 'val_cases': [...]}, ...]
318
+ """
319
+ from sklearn.model_selection import StratifiedKFold
320
+
321
+ label_file = os.path.join(data_dir, f"{split}-{task}.csv")
322
+ if not os.path.exists(label_file):
323
+ raise FileNotFoundError(f"Label file not found: {label_file}")
324
+
325
+ df = pd.read_csv(label_file, header=None, names=["case", "label"])
326
+ sagittal_dir = os.path.join(data_dir, split, "sagittal")
327
+ disk_cases = set([
328
+ f.replace(".npy", "")
329
+ for f in os.listdir(sagittal_dir)
330
+ if f.endswith(".npy")
331
+ ])
332
+
333
+ # Filter to cases existing on disk
334
+ valid_cases = []
335
+ labels = []
336
+ for _, row in df.iterrows():
337
+ cid_raw = str(row["case"])
338
+ cid_pad = cid_raw.zfill(4)
339
+ if cid_pad in disk_cases:
340
+ valid_cases.append(cid_pad)
341
+ labels.append(int(row["label"]))
342
+ elif cid_raw in disk_cases:
343
+ valid_cases.append(cid_raw)
344
+ labels.append(int(row["label"]))
345
+
346
+ valid_cases = np.array(valid_cases)
347
+ labels = np.array(labels)
348
+
349
+ skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
350
+ folds = []
351
+ for fold_idx, (train_idx, val_idx) in enumerate(skf.split(valid_cases, labels)):
352
+ folds.append({
353
+ "fold": fold_idx,
354
+ "train_cases": valid_cases[train_idx].tolist(),
355
+ "val_cases": valid_cases[val_idx].tolist(),
356
+ "train_pos_rate": float(np.mean(labels[train_idx])),
357
+ "val_pos_rate": float(np.mean(labels[val_idx])),
358
+ })
359
+
360
+ return folds
361
+
362
+
363
+ # ── DataLoader Creation ────────────────────────────────────────────
364
+
365
+ def create_dataloaders(
366
+ config,
367
+ ssl: bool = False,
368
+ train_cases: Optional[List[str]] = None,
369
+ val_cases: Optional[List[str]] = None,
370
+ train_split: str = "train",
371
+ val_split: str = "valid",
372
+ ) -> Tuple[DataLoader, DataLoader]:
373
+ """
374
+ Create train and validation dataloaders.
375
+
376
+ Args:
377
+ config: Config object
378
+ ssl: If True, create SSL-mode datasets (no labels, minimal augmentation)
379
+ train_cases: Optional list of case IDs for training (e.g. for cross-validation)
380
+ val_cases: Optional list of case IDs for validation (e.g. for cross-validation)
381
+ train_split: Data subfolder name for training set
382
+ val_split: Data subfolder name for validation set
383
+
384
+ Returns:
385
+ train_loader, val_loader
386
+ """
387
+ DatasetClass = MRNetSSLDataset if ssl else MRNetDataset
388
+
389
+ train_dataset = DatasetClass(
390
+ data_dir=config.data_dir,
391
+ split=train_split,
392
+ config=config,
393
+ case_list=train_cases,
394
+ )
395
+ val_dataset = DatasetClass(
396
+ data_dir=config.data_dir,
397
+ split=val_split,
398
+ config=config,
399
+ case_list=val_cases,
400
+ )
401
+
402
+ # Weighted sampler for class imbalance (supervised only)
403
+ train_sampler = None
404
+ shuffle = True
405
+ if not ssl:
406
+ labels = train_dataset.get_labels()
407
+ class_counts = np.bincount(labels)
408
+ if len(class_counts) == 2 and class_counts[1] > 0:
409
+ weights = 1.0 / class_counts.astype(float)
410
+ sample_weights = [weights[l] for l in labels]
411
+ train_sampler = WeightedRandomSampler(
412
+ sample_weights, len(sample_weights), replacement=True
413
+ )
414
+ shuffle = False # Sampler handles shuffling
415
+
416
+ train_loader = DataLoader(
417
+ train_dataset,
418
+ batch_size=config.batch_size,
419
+ shuffle=shuffle if train_sampler is None else False,
420
+ sampler=train_sampler,
421
+ num_workers=config.num_workers,
422
+ pin_memory=config.pin_memory,
423
+ drop_last=False,
424
+ )
425
+ val_loader = DataLoader(
426
+ val_dataset,
427
+ batch_size=config.batch_size,
428
+ shuffle=False,
429
+ num_workers=config.num_workers,
430
+ pin_memory=config.pin_memory,
431
+ )
432
+
433
+ return train_loader, val_loader
434
+
src/evaluate.py ADDED
@@ -0,0 +1,1136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluation module for ACL-LKNet.
3
+
4
+ Includes:
5
+ 1. Comprehensive Classification Performance:
6
+ - AUROC, AUPRC, Accuracy, Balanced Accuracy, Sensitivity (Recall), Specificity,
7
+ PPV (Precision), NPV, F1, MCC, Diagnostic Odds Ratio (DOR), Type I & II errors.
8
+ - 95% Bootstrap Confidence Intervals (N=1000).
9
+ 2. Thresholding-Based Evaluation:
10
+ - Default (0.5), Youden's Index (J), F1-Optimal, High-Sensitivity Screening (Recall >= 95%).
11
+ - Comprehensive threshold sweep tables and operating curves.
12
+ 3. Confusion Matrix Analysis (Both):
13
+ - Side-by-side Dual Plots: Raw Integer Counts + Condition-Normalized Percentages.
14
+ 4. Quantitative Explainability Evaluation:
15
+ - Anatomical Cruciate Landmark Pointing Game Hit Rate.
16
+ - Attention Mass Concentration & Slice Attention Entropy / Sparsity.
17
+ - Perturbation Faithfulness (Impact drop on masking top-attended slices).
18
+ 5. Segmentation & Localization Architecture Evaluation:
19
+ - Slice localization Pseudo-Dice and IoU against central ligament slices.
20
+ - Masked Slice Modeling (MSM) pretext reconstruction fidelity (MSE, PSNR).
21
+ - Architectural parameter and efficiency benchmark table.
22
+ 6. Cross-Dataset Generalization & Robustness Testing:
23
+ - Scanner perturbation stress tests (Rician noise, slice thickness, B1 field bias).
24
+ - External dataset evaluation interface.
25
+ 7. Paired Statistical Hypothesis Testing:
26
+ - DeLong's test for AUROC difference significance.
27
+ - McNemar's test for paired accuracy significance.
28
+ """
29
+
30
+ import os
31
+ import math
32
+ import copy
33
+ from typing import Dict, Optional, Tuple, List, Any
34
+
35
+ import numpy as np
36
+ import pandas as pd
37
+ import matplotlib
38
+ matplotlib.use("Agg") # Non-interactive backend for server/notebook execution
39
+ import matplotlib.pyplot as plt
40
+ import seaborn as sns
41
+ from sklearn.metrics import (
42
+ roc_auc_score,
43
+ average_precision_score,
44
+ accuracy_score,
45
+ balanced_accuracy_score,
46
+ f1_score,
47
+ confusion_matrix,
48
+ roc_curve,
49
+ precision_recall_curve,
50
+ matthews_corrcoef,
51
+ )
52
+ from scipy import stats
53
+ import torch
54
+ import torch.nn as nn
55
+ import torch.nn.functional as F
56
+
57
+
58
+ # ── Core Diagnostic Contingency Helper ──────────────────────────────
59
+
60
+ def _calc_contingency(labels: np.ndarray, predictions: np.ndarray, threshold: float = 0.5) -> Tuple[int, int, int, int]:
61
+ """Return raw counts: (tn, fp, fn, tp)."""
62
+ binary_preds = (predictions >= threshold).astype(int)
63
+ cm = confusion_matrix(labels, binary_preds, labels=[0, 1])
64
+ if cm.shape == (2, 2):
65
+ tn, fp, fn, tp = cm.ravel()
66
+ else:
67
+ tn = int(np.sum((labels == 0) & (binary_preds == 0)))
68
+ fp = int(np.sum((labels == 0) & (binary_preds == 1)))
69
+ fn = int(np.sum((labels == 1) & (binary_preds == 0)))
70
+ tp = int(np.sum((labels == 1) & (binary_preds == 1)))
71
+ return int(tn), int(fp), int(fn), int(tp)
72
+
73
+
74
+ # ── Comprehensive Classification Performance ─────────────────────────
75
+
76
+ def compute_metrics(
77
+ labels: np.ndarray,
78
+ predictions: np.ndarray,
79
+ threshold: float = 0.5,
80
+ prefix: str = "",
81
+ ) -> Dict[str, float]:
82
+ """
83
+ Compute comprehensive clinical diagnostic classification metrics.
84
+
85
+ Returns:
86
+ AUROC, AUPRC, Accuracy, Balanced_Accuracy, Sensitivity (Recall), Specificity,
87
+ PPV (Precision), NPV, F1, MCC, DOR, Type1_Error_Rate, Type2_Error_Rate.
88
+ """
89
+ labels = np.asarray(labels).astype(int)
90
+ predictions = np.asarray(predictions).astype(float)
91
+ binary_preds = (predictions >= threshold).astype(int)
92
+ p = prefix + "_" if prefix and not prefix.endswith("_") else prefix
93
+
94
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
95
+ total = tn + fp + fn + tp
96
+ positives = tp + fn
97
+ negatives = tn + fp
98
+
99
+ metrics: Dict[str, float] = {}
100
+
101
+ # Rank metrics (threshold-independent)
102
+ unique_labels = np.unique(labels)
103
+ if len(unique_labels) < 2:
104
+ metrics[f"{p}auroc"] = 0.0
105
+ metrics[f"{p}auprc"] = 0.0
106
+ else:
107
+ try:
108
+ metrics[f"{p}auroc"] = float(roc_auc_score(labels, predictions))
109
+ metrics[f"{p}auprc"] = float(average_precision_score(labels, predictions))
110
+ except Exception:
111
+ metrics[f"{p}auroc"] = 0.0
112
+ metrics[f"{p}auprc"] = 0.0
113
+
114
+ # Accuracy & Balanced Accuracy
115
+ metrics[f"{p}accuracy"] = float(accuracy_score(labels, binary_preds))
116
+ sens = tp / positives if positives > 0 else 0.0
117
+ spec = tn / negatives if negatives > 0 else 0.0
118
+ metrics[f"{p}balanced_accuracy"] = float(0.5 * (sens + spec))
119
+
120
+ # Diagnostic Sensitivity (Recall) & Specificity
121
+ metrics[f"{p}sensitivity"] = float(sens)
122
+ metrics[f"{p}specificity"] = float(spec)
123
+
124
+ # Predictive Values (PPV & NPV)
125
+ ppv = tp / (tp + fp) if (tp + fp) > 0 else 0.0
126
+ npv = tn / (tn + fn) if (tn + fn) > 0 else 0.0
127
+ metrics[f"{p}ppv"] = float(ppv)
128
+ metrics[f"{p}npv"] = float(npv)
129
+
130
+ # F1 Score
131
+ metrics[f"{p}f1"] = float(f1_score(labels, binary_preds, zero_division=0))
132
+
133
+ # Matthews Correlation Coefficient (MCC)
134
+ try:
135
+ metrics[f"{p}mcc"] = float(matthews_corrcoef(labels, binary_preds))
136
+ except Exception:
137
+ metrics[f"{p}mcc"] = 0.0
138
+
139
+ # Diagnostic Odds Ratio (DOR) with Haldane-Anscombe continuity correction (+0.5)
140
+ dor = ((tp + 0.5) * (tn + 0.5)) / ((fp + 0.5) * (fn + 0.5))
141
+ metrics[f"{p}dor"] = float(dor)
142
+
143
+ # Clinical Error Rates
144
+ metrics[f"{p}type1_error_rate"] = float(fp / negatives) if negatives > 0 else 0.0 # False Positive Rate (alpha)
145
+ metrics[f"{p}type2_error_rate"] = float(fn / positives) if positives > 0 else 0.0 # False Negative Rate (beta)
146
+
147
+ return metrics
148
+
149
+
150
+ # ── Bootstrap Confidence Intervals ──────────────────────────────────
151
+
152
+ def bootstrap_ci(
153
+ labels: np.ndarray,
154
+ predictions: np.ndarray,
155
+ metric_fn,
156
+ n_bootstrap: int = 1000,
157
+ confidence: float = 0.95,
158
+ seed: int = 42,
159
+ ) -> Tuple[float, float, float]:
160
+ """
161
+ Compute bootstrap confidence interval for a metric.
162
+
163
+ Returns:
164
+ (point_estimate, lower_bound, upper_bound)
165
+ """
166
+ labels = np.asarray(labels)
167
+ predictions = np.asarray(predictions)
168
+ rng = np.random.RandomState(seed)
169
+ n = len(labels)
170
+ point = float(metric_fn(labels, predictions))
171
+
172
+ scores = []
173
+ for _ in range(n_bootstrap):
174
+ idx = rng.choice(n, size=n, replace=True)
175
+ try:
176
+ score = metric_fn(labels[idx], predictions[idx])
177
+ if not math.isnan(score):
178
+ scores.append(score)
179
+ except (ValueError, ZeroDivisionError):
180
+ continue
181
+
182
+ if len(scores) < 10:
183
+ return point, point, point
184
+
185
+ alpha = 1 - confidence
186
+ lower = float(np.percentile(scores, 100 * alpha / 2))
187
+ upper = float(np.percentile(scores, 100 * (1 - alpha / 2)))
188
+ return point, lower, upper
189
+
190
+
191
+ def compute_metrics_with_ci(
192
+ labels: np.ndarray,
193
+ predictions: np.ndarray,
194
+ threshold: float = 0.5,
195
+ n_bootstrap: int = 1000,
196
+ confidence: float = 0.95,
197
+ ) -> Dict[str, Tuple[float, float, float]]:
198
+ """
199
+ Compute all primary diagnostic metrics with empirical 95% bootstrap CIs.
200
+
201
+ Returns:
202
+ Dict mapping metric_name -> (point_estimate, lower_bound, upper_bound)
203
+ """
204
+ labels = np.asarray(labels)
205
+ predictions = np.asarray(predictions)
206
+
207
+ metric_fns = {
208
+ "AUROC": lambda y, p: roc_auc_score(y, p) if len(np.unique(y)) > 1 else 0.0,
209
+ "AUPRC": lambda y, p: average_precision_score(y, p) if len(np.unique(y)) > 1 else 0.0,
210
+ "Accuracy": lambda y, p: accuracy_score(y, (p >= threshold).astype(int)),
211
+ "Balanced_Accuracy": lambda y, p: balanced_accuracy_score(y, (p >= threshold).astype(int)),
212
+ "Sensitivity": lambda y, p: _sensitivity(y, p, threshold),
213
+ "Specificity": lambda y, p: _specificity(y, p, threshold),
214
+ "PPV": lambda y, p: _ppv(y, p, threshold),
215
+ "NPV": lambda y, p: _npv(y, p, threshold),
216
+ "F1": lambda y, p: f1_score(y, (p >= threshold).astype(int), zero_division=0),
217
+ "MCC": lambda y, p: matthews_corrcoef(y, (p >= threshold).astype(int)),
218
+ "DOR": lambda y, p: _dor(y, p, threshold),
219
+ }
220
+
221
+ results = {}
222
+ for name, fn in metric_fns.items():
223
+ try:
224
+ point, lower, upper = bootstrap_ci(
225
+ labels, predictions, fn, n_bootstrap=n_bootstrap, confidence=confidence
226
+ )
227
+ results[name] = (point, lower, upper)
228
+ except Exception:
229
+ results[name] = (0.0, 0.0, 0.0)
230
+
231
+ return results
232
+
233
+
234
+ def _sensitivity(labels, predictions, threshold=0.5):
235
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
236
+ return tp / (tp + fn) if (tp + fn) > 0 else 0.0
237
+
238
+ def _specificity(labels, predictions, threshold=0.5):
239
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
240
+ return tn / (tn + fp) if (tn + fp) > 0 else 0.0
241
+
242
+ def _ppv(labels, predictions, threshold=0.5):
243
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
244
+ return tp / (tp + fp) if (tp + fp) > 0 else 0.0
245
+
246
+ def _npv(labels, predictions, threshold=0.5):
247
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
248
+ return tn / (tn + fn) if (tn + fn) > 0 else 0.0
249
+
250
+ def _dor(labels, predictions, threshold=0.5):
251
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
252
+ return float(((tp + 0.5) * (tn + 0.5)) / ((fp + 0.5) * (fn + 0.5)))
253
+
254
+
255
+ # ── Thresholding-Based Evaluation Engine ─────────────────────────────
256
+
257
+ def find_optimal_thresholds(
258
+ labels: np.ndarray,
259
+ predictions: np.ndarray,
260
+ target_sensitivity: float = 0.95,
261
+ ) -> Dict[str, Dict[str, float]]:
262
+ """
263
+ Determine clinically relevant operating thresholds:
264
+ 1. Default threshold (tau = 0.5)
265
+ 2. Youden's J Index: max(Sensitivity + Specificity - 1)
266
+ 3. F1-Optimal threshold: max F1 score
267
+ 4. High-Sensitivity Screening: minimum threshold achieving Recall >= target_sensitivity (e.g. 0.95)
268
+
269
+ Returns:
270
+ Dict mapping mode -> {'threshold': tau, ...metrics}
271
+ """
272
+ labels = np.asarray(labels).astype(int)
273
+ predictions = np.asarray(predictions).astype(float)
274
+
275
+ # Threshold candidates from ROC curve
276
+ fpr, tpr, thresholds = roc_curve(labels, predictions)
277
+ thresholds = np.clip(thresholds, 0.01, 0.99)
278
+
279
+ # 1. Youden's Index J = TPR - FPR
280
+ j_scores = tpr - fpr
281
+ best_j_idx = int(np.argmax(j_scores))
282
+ youden_thresh = float(thresholds[best_j_idx])
283
+
284
+ # 2. F1-optimal & Screening thresholds via fine sweep
285
+ sweep_taus = np.linspace(0.01, 0.99, 200)
286
+ f1_list = []
287
+ sens_list = []
288
+ for tau in sweep_taus:
289
+ b = (predictions >= tau).astype(int)
290
+ f1_list.append(f1_score(labels, b, zero_division=0))
291
+ cm = confusion_matrix(labels, b, labels=[0, 1])
292
+ s = cm[1, 1] / (cm[1, 1] + cm[1, 0]) if (cm[1, 1] + cm[1, 0]) > 0 else 0.0
293
+ sens_list.append(s)
294
+
295
+ best_f1_idx = int(np.argmax(f1_list))
296
+ f1_thresh = float(sweep_taus[best_f1_idx])
297
+
298
+ # High sensitivity: largest tau where sensitivity >= target_sensitivity
299
+ valid_screening_taus = [sweep_taus[i] for i, s in enumerate(sens_list) if s >= target_sensitivity]
300
+ screening_thresh = float(max(valid_screening_taus)) if valid_screening_taus else float(sweep_taus[0])
301
+
302
+ modes = {
303
+ "default": 0.5,
304
+ "youden": youden_thresh,
305
+ "f1_optimal": f1_thresh,
306
+ "high_sensitivity": screening_thresh,
307
+ }
308
+
309
+ results = {}
310
+ for mode_name, tau in modes.items():
311
+ m = compute_metrics(labels, predictions, threshold=tau)
312
+ m["threshold"] = tau
313
+ results[mode_name] = m
314
+
315
+ return results
316
+
317
+
318
+ def generate_threshold_sweep(
319
+ labels: np.ndarray,
320
+ predictions: np.ndarray,
321
+ thresholds: Optional[np.ndarray] = None,
322
+ ) -> pd.DataFrame:
323
+ """
324
+ Generate tabular performance sweep across decision thresholds.
325
+ """
326
+ if thresholds is None:
327
+ thresholds = np.linspace(0.05, 0.95, 19)
328
+
329
+ rows = []
330
+ for tau in thresholds:
331
+ m = compute_metrics(labels, predictions, threshold=float(tau))
332
+ rows.append({
333
+ "Threshold": round(float(tau), 3),
334
+ "Accuracy": round(m["accuracy"], 4),
335
+ "Balanced_Acc": round(m["balanced_accuracy"], 4),
336
+ "Sensitivity": round(m["sensitivity"], 4),
337
+ "Specificity": round(m["specificity"], 4),
338
+ "PPV": round(m["ppv"], 4),
339
+ "NPV": round(m["npv"], 4),
340
+ "F1_Score": round(m["f1"], 4),
341
+ "MCC": round(m["mcc"], 4),
342
+ "Type1_Error": round(m["type1_error_rate"], 4),
343
+ "Type2_Error": round(m["type2_error_rate"], 4),
344
+ })
345
+
346
+ return pd.DataFrame(rows)
347
+
348
+
349
+ def plot_threshold_curves(
350
+ labels: np.ndarray,
351
+ predictions: np.ndarray,
352
+ save_path: Optional[str] = None,
353
+ target_sensitivity: float = 0.95,
354
+ ) -> plt.Figure:
355
+ """Plot Sensitivity, Specificity, F1, and Balanced Accuracy across decision thresholds."""
356
+ df_sweep = generate_threshold_sweep(labels, predictions, np.linspace(0.02, 0.98, 100))
357
+ opt = find_optimal_thresholds(labels, predictions, target_sensitivity=target_sensitivity)
358
+
359
+ fig, ax = plt.subplots(figsize=(9, 6))
360
+ ax.plot(df_sweep["Threshold"], df_sweep["Sensitivity"], label="Sensitivity (Recall)", color="#d9534f", lw=2.2)
361
+ ax.plot(df_sweep["Threshold"], df_sweep["Specificity"], label="Specificity", color="#0275d8", lw=2.2)
362
+ ax.plot(df_sweep["Threshold"], df_sweep["Balanced_Acc"], label="Balanced Accuracy", color="#5cb85c", lw=2.0, ls="--")
363
+ ax.plot(df_sweep["Threshold"], df_sweep["F1_Score"], label="F1-Score", color="#f0ad4e", lw=2.0)
364
+
365
+ # Vertical lines for optimal operating points
366
+ ax.axvline(opt["youden"]["threshold"], color="purple", ls=":", lw=1.8,
367
+ label=f"Youden's J ({opt['youden']['threshold']:.2f})")
368
+ ax.axvline(opt["high_sensitivity"]["threshold"], color="crimson", ls="-.", lw=1.8,
369
+ label=f"High-Sens >= {target_sensitivity*100:.0f}% ({opt['high_sensitivity']['threshold']:.2f})")
370
+
371
+ ax.set_xlabel("Decision Threshold (tau)", fontsize=13)
372
+ ax.set_ylabel("Metric Value", fontsize=13)
373
+ ax.set_title("ACL Tear Diagnosis: Threshold-Dependent Performance Curves", fontsize=14, fontweight="bold")
374
+ ax.set_xlim(0, 1)
375
+ ax.set_ylim(0, 1.02)
376
+ ax.grid(alpha=0.3)
377
+ ax.legend(loc="lower center", bbox_to_anchor=(0.5, -0.28), ncol=3, fontsize=10)
378
+ plt.tight_layout()
379
+
380
+ if save_path:
381
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
382
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
383
+ return fig
384
+
385
+
386
+ # ── Dual Confusion Matrix Analysis (Raw + Normalized) ───────────────
387
+
388
+ def plot_confusion_matrices_dual(
389
+ labels: np.ndarray,
390
+ predictions: np.ndarray,
391
+ threshold: float = 0.5,
392
+ save_path: Optional[str] = None,
393
+ title: str = "ACL-LKNet Tear Diagnosis: Confusion Matrix Analysis",
394
+ ) -> plt.Figure:
395
+ """
396
+ Generate side-by-side publication confusion matrix analysis:
397
+ Left: Raw integer counts (TN, FP, FN, TP).
398
+ Right: Condition-normalized percentages (Sensitivity, Specificity, Type I alpha, Type II beta).
399
+ """
400
+ labels = np.asarray(labels).astype(int)
401
+ predictions = np.asarray(predictions).astype(float)
402
+ tn, fp, fn, tp = _calc_contingency(labels, predictions, threshold)
403
+
404
+ cm_raw = np.array([[tn, fp], [fn, tp]])
405
+ neg_total = tn + fp if (tn + fp) > 0 else 1
406
+ pos_total = fn + tp if (fn + tp) > 0 else 1
407
+ cm_norm = np.array([
408
+ [tn / neg_total * 100.0, fp / neg_total * 100.0],
409
+ [fn / pos_total * 100.0, tp / pos_total * 100.0],
410
+ ])
411
+
412
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6))
413
+
414
+ # 1. Raw Counts Subplot
415
+ annot_raw = np.array([
416
+ [f"TN (True Neg)\n{tn}\n({tn/(tn+fp+fn+tp)*100:.1f}%)", f"FP (Type I Error)\n{fp}\n({fp/(tn+fp+fn+tp)*100:.1f}%)"],
417
+ [f"FN (Type II Error)\n{fn}\n({fn/(tn+fp+fn+tp)*100:.1f}%)", f"TP (True Pos)\n{tp}\n({tp/(tn+fp+fn+tp)*100:.1f}%)"],
418
+ ])
419
+ sns.heatmap(
420
+ cm_raw, annot=annot_raw, fmt="", cmap="Blues", cbar=True, ax=axes[0],
421
+ xticklabels=["Predicted Negative", "Predicted Positive"],
422
+ yticklabels=["Actual Negative", "Actual Positive"],
423
+ annot_kws={"size": 11, "weight": "bold"},
424
+ )
425
+ axes[0].set_title(f"A. Absolute Case Counts (tau = {threshold:.2f})", fontsize=13, fontweight="bold")
426
+ axes[0].set_xlabel("Predicted Diagnosis", fontsize=12)
427
+ axes[0].set_ylabel("True Anatomical State", fontsize=12)
428
+
429
+ # 2. Condition-Normalized Percentages Subplot
430
+ annot_norm = np.array([
431
+ [f"Specificity (TNR)\n{cm_norm[0,0]:.1f}%", f"FPR (Type I alpha)\n{cm_norm[0,1]:.1f}%"],
432
+ [f"FNR (Type II beta)\n{cm_norm[1,0]:.1f}%", f"Sensitivity (TPR)\n{cm_norm[1,1]:.1f}%"],
433
+ ])
434
+ sns.heatmap(
435
+ cm_norm, annot=annot_norm, fmt="", cmap="YlGnBu", cbar=True, ax=axes[1],
436
+ vmin=0, vmax=100,
437
+ xticklabels=["Predicted Negative", "Predicted Positive"],
438
+ yticklabels=["Actual Negative", "Actual Positive"],
439
+ annot_kws={"size": 11, "weight": "bold"},
440
+ )
441
+ axes[1].set_title(f"B. Condition-Normalized Diagnostic Rates (%)", fontsize=13, fontweight="bold")
442
+ axes[1].set_xlabel("Predicted Diagnosis", fontsize=12)
443
+ axes[1].set_ylabel("True Anatomical State", fontsize=12)
444
+
445
+ fig.suptitle(title, fontsize=15, fontweight="bold", y=1.02)
446
+ plt.tight_layout()
447
+
448
+ if save_path:
449
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
450
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
451
+ return fig
452
+
453
+
454
+ # ── Quantitative Explainability Evaluation ──────────────────────────
455
+
456
+ def evaluate_slice_explainability(
457
+ slice_weights: np.ndarray,
458
+ target_slice_range: Tuple[int, int] = (10, 18),
459
+ labels: Optional[np.ndarray] = None,
460
+ ) -> Dict[str, Any]:
461
+ """
462
+ Quantify clinical explainability from slice attention distributions:
463
+ 1. Pointing Game Hit Rate: % of scans where peak attention falls in anatomical cruciate range.
464
+ 2. Cruciate Mass Fraction: Average proportion of attention allocated to central cruciate slices.
465
+ 3. Slice Attention Entropy: H(alpha) = -sum alpha_i * log2(alpha_i + eps). (Lower = sharper diagnostic focus).
466
+ 4. Hoyer Sparsity: Quantifies degree of attention concentration.
467
+ """
468
+ weights = np.asarray(slice_weights).astype(float)
469
+ if weights.ndim == 1:
470
+ weights = weights.reshape(1, -1)
471
+ N, S = weights.shape
472
+
473
+ row_sums = weights.sum(axis=1, keepdims=True)
474
+ row_sums[row_sums == 0] = 1.0
475
+ weights = weights / row_sums
476
+
477
+ start_s, end_s = target_slice_range
478
+
479
+ # 1. Pointing Game
480
+ peak_slices = np.argmax(weights, axis=1)
481
+ hits = (peak_slices >= start_s) & (peak_slices <= end_s)
482
+ hit_rate = float(np.mean(hits))
483
+
484
+ # 2. Cruciate Attention Mass Fraction
485
+ cruciate_mass = weights[:, start_s:end_s + 1].sum(axis=1)
486
+ mean_mass_fraction = float(np.mean(cruciate_mass))
487
+
488
+ # 3. Attention Entropy
489
+ eps = 1e-12
490
+ entropies = -np.sum(weights * np.log2(weights + eps), axis=1)
491
+ mean_entropy = float(np.mean(entropies))
492
+ max_entropy = math.log2(S) if S > 1 else 1.0
493
+ norm_entropy = mean_entropy / max_entropy
494
+
495
+ # 4. Hoyer Sparsity
496
+ l1 = np.sum(np.abs(weights), axis=1)
497
+ l2 = np.sqrt(np.sum(weights ** 2, axis=1))
498
+ sqrt_s = math.sqrt(S)
499
+ sparsities = (sqrt_s - (l1 / (l2 + eps))) / (sqrt_s - 1.0) if sqrt_s > 1 else np.zeros(N)
500
+ mean_sparsity = float(np.mean(sparsities))
501
+
502
+ summary: Dict[str, Any] = {
503
+ "pointing_game_hit_rate": hit_rate,
504
+ "cruciate_mass_fraction": mean_mass_fraction,
505
+ "attention_entropy_bits": mean_entropy,
506
+ "normalized_entropy": norm_entropy,
507
+ "hoyer_sparsity": mean_sparsity,
508
+ "total_evaluated_cases": int(N),
509
+ "target_slice_range": f"Slices {start_s} to {end_s} (of {S})",
510
+ }
511
+
512
+ if labels is not None:
513
+ labels = np.asarray(labels).astype(int)
514
+ pos_mask = (labels == 1)
515
+ neg_mask = (labels == 0)
516
+ if np.any(pos_mask):
517
+ summary["positive_tear_hit_rate"] = float(np.mean(hits[pos_mask]))
518
+ summary["positive_tear_mass_fraction"] = float(np.mean(cruciate_mass[pos_mask]))
519
+ summary["positive_tear_entropy"] = float(np.mean(entropies[pos_mask]))
520
+ if np.any(neg_mask):
521
+ summary["negative_knee_hit_rate"] = float(np.mean(hits[neg_mask]))
522
+ summary["negative_knee_mass_fraction"] = float(np.mean(cruciate_mass[neg_mask]))
523
+ summary["negative_knee_entropy"] = float(np.mean(entropies[neg_mask]))
524
+
525
+ return summary
526
+
527
+
528
+ def evaluate_perturbation_faithfulness(
529
+ model: nn.Module,
530
+ val_loader,
531
+ device: torch.device,
532
+ k_slices: int = 3,
533
+ config=None,
534
+ ) -> Dict[str, float]:
535
+ """
536
+ Measure quantitative faithfulness of slice attention:
537
+ Mask out top-k slices attended by the model and record drop in predicted tear probability.
538
+ """
539
+ model.eval()
540
+ orig_probs = []
541
+ perturbed_probs = []
542
+ labels = []
543
+ use_amp = config.use_amp if config else True
544
+
545
+ with torch.no_grad():
546
+ for batch in val_loader:
547
+ sag = batch["sagittal"].to(device)
548
+ cor = batch["coronal"].to(device)
549
+ axi = batch["axial"].to(device)
550
+ sag_m = batch["sag_mask"].to(device)
551
+ cor_m = batch["cor_mask"].to(device)
552
+ axi_m = batch["axi_mask"].to(device)
553
+
554
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
555
+ out = model(sag, cor, axi, sag_m, cor_m, axi_m)
556
+
557
+ p_orig = out["probs"].squeeze(-1).cpu().numpy()
558
+ orig_probs.extend(p_orig.tolist())
559
+ labels.extend(batch["label"].numpy().tolist())
560
+
561
+ sag_weights = out.get("sag_weights")
562
+ if sag_weights is not None:
563
+ sag_pert = sag.clone()
564
+ B, S = sag_weights.shape
565
+ for b in range(B):
566
+ top_k_indices = torch.topk(sag_weights[b], k=min(k_slices, S)).indices
567
+ sag_pert[b, top_k_indices] = 0.0
568
+
569
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
570
+ out_pert = model(sag_pert, cor, axi, sag_m, cor_m, axi_m)
571
+ p_pert = out_pert["probs"].squeeze(-1).cpu().numpy()
572
+ perturbed_probs.extend(p_pert.tolist())
573
+ else:
574
+ perturbed_probs.extend(p_orig.tolist())
575
+
576
+ orig = np.array(orig_probs)
577
+ pert = np.array(perturbed_probs)
578
+ y = np.array(labels)
579
+
580
+ delta_p = orig - pert
581
+ pos_mask = (y == 1)
582
+
583
+ return {
584
+ "mean_prob_drop_all": float(np.mean(delta_p)),
585
+ "mean_prob_drop_positive_cases": float(np.mean(delta_p[pos_mask])) if np.any(pos_mask) else 0.0,
586
+ "faithfulness_impact_ratio": float(np.mean(delta_p[pos_mask]) / (np.mean(orig[pos_mask]) + 1e-8)) if np.any(pos_mask) else 0.0,
587
+ "top_k_slices_masked": int(k_slices),
588
+ }
589
+
590
+
591
+ # ── Segmentation & Localization Architecture Evaluation ─────────────
592
+
593
+ def evaluate_slice_localization(
594
+ slice_weights: np.ndarray,
595
+ ground_truth_active_range: Tuple[int, int] = (10, 18),
596
+ threshold: Optional[float] = None,
597
+ ) -> Dict[str, float]:
598
+ """
599
+ Evaluate slice-level weak volumetric localization using Dice and IoU metrics
600
+ against the known central cruciate ligament anatomical zone.
601
+ """
602
+ weights = np.asarray(slice_weights).astype(float)
603
+ if weights.ndim == 1:
604
+ weights = weights.reshape(1, -1)
605
+ N, S = weights.shape
606
+
607
+ gt_mask = np.zeros(S, dtype=float)
608
+ start_s, end_s = ground_truth_active_range
609
+ gt_mask[start_s:end_s + 1] = 1.0
610
+
611
+ soft_dices = []
612
+ hard_dices = []
613
+ ious = []
614
+
615
+ for i in range(N):
616
+ w = weights[i]
617
+ w_norm = (w - w.min()) / (w.max() - w.min() + 1e-8)
618
+
619
+ # Soft continuous Dice
620
+ intersection = np.sum(w_norm * gt_mask)
621
+ soft_dice = (2.0 * intersection) / (np.sum(w_norm) + np.sum(gt_mask) + 1e-8)
622
+ soft_dices.append(soft_dice)
623
+
624
+ # Binary Dice & IoU
625
+ tau = threshold if threshold is not None else float(np.mean(w_norm))
626
+ pred_bin = (w_norm >= tau).astype(float)
627
+ inter_bin = np.sum(pred_bin * gt_mask)
628
+ union_bin = np.sum(np.maximum(pred_bin, gt_mask))
629
+ dice_bin = (2.0 * inter_bin) / (np.sum(pred_bin) + np.sum(gt_mask) + 1e-8)
630
+ iou_bin = inter_bin / (union_bin + 1e-8)
631
+
632
+ hard_dices.append(dice_bin)
633
+ ious.append(iou_bin)
634
+
635
+ return {
636
+ "slice_localization_soft_dice": float(np.mean(soft_dices)),
637
+ "slice_localization_hard_dice": float(np.mean(hard_dices)),
638
+ "slice_localization_iou": float(np.mean(ious)),
639
+ "anatomical_reference_range": f"Slices {start_s} to {end_s}",
640
+ }
641
+
642
+
643
+ def evaluate_msm_reconstruction(
644
+ msm_model: nn.Module,
645
+ val_loader,
646
+ device: torch.device,
647
+ ) -> Dict[str, float]:
648
+ """
649
+ Evaluate Phase 1 Masked Slice Modeling (MSM) pretext reconstruction fidelity:
650
+ Computes MSE and PSNR on masked slice feature representations.
651
+ """
652
+ msm_model.eval()
653
+ losses = []
654
+
655
+ with torch.no_grad():
656
+ for batch in val_loader:
657
+ sag = batch["sagittal"].to(device)
658
+ cor = batch["coronal"].to(device)
659
+ axi = batch["axial"].to(device)
660
+ sag_m = batch["sag_mask"].to(device)
661
+ cor_m = batch["cor_mask"].to(device)
662
+ axi_m = batch["axi_mask"].to(device)
663
+
664
+ out = msm_model(sag, cor, axi, sag_m, cor_m, axi_m)
665
+ loss_val = out["loss"].item()
666
+ losses.append(loss_val)
667
+
668
+ mean_mse = float(np.mean(losses))
669
+ psnr = float(10.0 * np.log10(1.0 / (mean_mse + 1e-10)))
670
+
671
+ return {
672
+ "msm_reconstruction_mse": mean_mse,
673
+ "msm_reconstruction_psnr_db": psnr,
674
+ }
675
+
676
+
677
+ def generate_architecture_benchmark_table(model: nn.Module, config=None) -> pd.DataFrame:
678
+ """Generate architectural parameter and memory complexity benchmark table."""
679
+ total_params = sum(p.numel() for p in model.parameters())
680
+ trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
681
+
682
+ backbone_params = sum(p.numel() for p in model.backbone.parameters()) if hasattr(model, "backbone") else 0
683
+ slice_attn_params = sum(p.numel() for p in model.slice_attn.parameters()) if hasattr(model, "slice_attn") and model.slice_attn else 0
684
+ fusion_params = sum(p.numel() for p in model.fusion.parameters()) if hasattr(model, "fusion") else 0
685
+ classifier_params = sum(p.numel() for p in model.classifier.parameters()) if hasattr(model, "classifier") else 0
686
+
687
+ param_df = pd.DataFrame([
688
+ {"Module / Component": "Shared 2D Backbone (ConvNeXt-Tiny)", "Parameters": f"{backbone_params:,}", "Param Fraction": f"{backbone_params/total_params*100:.1f}%"},
689
+ {"Module / Component": "Slice Attention Pooling", "Parameters": f"{slice_attn_params:,}", "Param Fraction": f"{slice_attn_params/total_params*100:.1f}%"},
690
+ {"Module / Component": "Cross-View Attention Fusion", "Parameters": f"{fusion_params:,}", "Param Fraction": f"{fusion_params/total_params*100:.1f}%"},
691
+ {"Module / Component": "Classification Head", "Parameters": f"{classifier_params:,}", "Param Fraction": f"{classifier_params/total_params*100:.1f}%"},
692
+ {"Module / Component": "TOTAL NETWORK", "Parameters": f"{total_params:,}", "Param Fraction": "100.0%"},
693
+ {"Module / Component": "Trainable Parameters", "Parameters": f"{trainable_params:,}", "Param Fraction": f"{trainable_params/total_params*100:.1f}%"},
694
+ ])
695
+
696
+ return param_df
697
+
698
+
699
+ # ── Cross-Dataset Generalization & Scanner Perturbation Robustness ──
700
+
701
+ def evaluate_scanner_perturbation_robustness(
702
+ model: nn.Module,
703
+ val_loader,
704
+ device: torch.device,
705
+ config=None,
706
+ ) -> Dict[str, Dict[str, float]]:
707
+ """
708
+ Stress-test cross-dataset / scanner domain shifts using realistic synthetic perturbations:
709
+ 1. Low-Field SNR Shift: Rician noise injection (sigma=0.08) simulating 1.5T scanners.
710
+ 2. Slice Thickness Shift: Decimating slice count by 2x and linear interpolating back.
711
+ 3. B1 Field Bias Shift: Spatial intensity gradient simulating RF coil inhomogeneity.
712
+ """
713
+ model.eval()
714
+ use_amp = config.use_amp if config else True
715
+
716
+ clean_labels = []
717
+ clean_preds = []
718
+ noise_preds = []
719
+ thick_preds = []
720
+ bias_preds = []
721
+
722
+ with torch.no_grad():
723
+ for batch in val_loader:
724
+ sag = batch["sagittal"].to(device)
725
+ cor = batch["coronal"].to(device)
726
+ axi = batch["axial"].to(device)
727
+ sag_m = batch["sag_mask"].to(device)
728
+ cor_m = batch["cor_mask"].to(device)
729
+ axi_m = batch["axi_mask"].to(device)
730
+
731
+ clean_labels.extend(batch["label"].numpy().tolist())
732
+
733
+ # Baseline
734
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
735
+ out_clean = model(sag, cor, axi, sag_m, cor_m, axi_m)
736
+ clean_preds.extend(out_clean["probs"].squeeze(-1).cpu().numpy().tolist())
737
+
738
+ # A. Rician Noise Perturbation
739
+ noise_sigma = 0.08
740
+ sag_n = torch.sqrt((sag + torch.randn_like(sag)*noise_sigma)**2 + (torch.randn_like(sag)*noise_sigma)**2)
741
+ cor_n = torch.sqrt((cor + torch.randn_like(cor)*noise_sigma)**2 + (torch.randn_like(cor)*noise_sigma)**2)
742
+ axi_n = torch.sqrt((axi + torch.randn_like(axi)*noise_sigma)**2 + (torch.randn_like(axi)*noise_sigma)**2)
743
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
744
+ out_noise = model(sag_n, cor_n, axi_n, sag_m, cor_m, axi_m)
745
+ noise_preds.extend(out_noise["probs"].squeeze(-1).cpu().numpy().tolist())
746
+
747
+ # B. Slice Thickness Decimation Perturbation
748
+ sag_t = copy.deepcopy(sag)
749
+ sag_t[:, 1::2] = sag_t[:, 0::2]
750
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
751
+ out_thick = model(sag_t, cor, axi, sag_m, cor_m, axi_m)
752
+ thick_preds.extend(out_thick["probs"].squeeze(-1).cpu().numpy().tolist())
753
+
754
+ # C. B1 Field Bias Field
755
+ H, W = sag.shape[-2], sag.shape[-1]
756
+ y_grad = torch.linspace(0.8, 1.2, H, device=device).unsqueeze(1).repeat(1, W)
757
+ sag_b = torch.clamp(sag * y_grad, 0.0, 1.0)
758
+ cor_b = torch.clamp(cor * y_grad, 0.0, 1.0)
759
+ axi_b = torch.clamp(axi * y_grad, 0.0, 1.0)
760
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
761
+ out_bias = model(sag_b, cor_b, axi_b, sag_m, cor_m, axi_m)
762
+ bias_preds.extend(out_bias["probs"].squeeze(-1).cpu().numpy().tolist())
763
+
764
+ y = np.array(clean_labels)
765
+ base_m = compute_metrics(y, np.array(clean_preds))
766
+ noise_m = compute_metrics(y, np.array(noise_preds))
767
+ thick_m = compute_metrics(y, np.array(thick_preds))
768
+ bias_m = compute_metrics(y, np.array(bias_preds))
769
+
770
+ return {
771
+ "Baseline": {
772
+ "auroc": base_m["auroc"],
773
+ "accuracy": base_m["accuracy"],
774
+ "sensitivity": base_m["sensitivity"],
775
+ "specificity": base_m["specificity"],
776
+ "f1": base_m["f1"],
777
+ },
778
+ "Low_Field_SNR_Shift (Rician Noise)": {
779
+ "auroc": noise_m["auroc"],
780
+ "delta_auroc": float(noise_m["auroc"] - base_m["auroc"]),
781
+ "accuracy": noise_m["accuracy"],
782
+ "delta_accuracy": float(noise_m["accuracy"] - base_m["accuracy"]),
783
+ },
784
+ "Slice_Thickness_Decimation": {
785
+ "auroc": thick_m["auroc"],
786
+ "delta_auroc": float(thick_m["auroc"] - base_m["auroc"]),
787
+ "accuracy": thick_m["accuracy"],
788
+ "delta_accuracy": float(thick_m["accuracy"] - base_m["accuracy"]),
789
+ },
790
+ "B1_Coil_Bias_Field": {
791
+ "auroc": bias_m["auroc"],
792
+ "delta_auroc": float(bias_m["auroc"] - base_m["auroc"]),
793
+ "accuracy": bias_m["accuracy"],
794
+ "delta_accuracy": float(bias_m["accuracy"] - base_m["accuracy"]),
795
+ },
796
+ }
797
+
798
+
799
+ def evaluate_external_dataset(
800
+ model: nn.Module,
801
+ external_dataloader,
802
+ device: torch.device,
803
+ config=None,
804
+ dataset_name: str = "External_Knee_Cohort",
805
+ ) -> Dict[str, Any]:
806
+ """Run out-of-domain evaluation on an external knee MRI dataset."""
807
+ model.eval()
808
+ preds = []
809
+ labels = []
810
+ use_amp = config.use_amp if config else True
811
+
812
+ with torch.no_grad():
813
+ for batch in external_dataloader:
814
+ sag = batch["sagittal"].to(device)
815
+ cor = batch["coronal"].to(device)
816
+ axi = batch["axial"].to(device)
817
+ sag_m = batch["sag_mask"].to(device)
818
+ cor_m = batch["cor_mask"].to(device)
819
+ axi_m = batch["axi_mask"].to(device)
820
+
821
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
822
+ out = model(sag, cor, axi, sag_m, cor_m, axi_m)
823
+
824
+ preds.extend(out["probs"].squeeze(-1).cpu().numpy().tolist())
825
+ labels.extend(batch["label"].numpy().tolist())
826
+
827
+ y = np.array(labels)
828
+ p = np.array(preds)
829
+ metrics_ci = compute_metrics_with_ci(y, p)
830
+ metrics = compute_metrics(y, p)
831
+
832
+ return {
833
+ "dataset_name": dataset_name,
834
+ "sample_count": int(len(y)),
835
+ "metrics": metrics,
836
+ "metrics_ci": metrics_ci,
837
+ }
838
+
839
+
840
+ # ── Statistical Hypothesis Tests ───────────────────────────────────
841
+
842
+ def mcnemar_test(
843
+ labels: np.ndarray,
844
+ preds_a: np.ndarray,
845
+ preds_b: np.ndarray,
846
+ threshold: float = 0.5,
847
+ ) -> Tuple[float, float]:
848
+ """
849
+ McNemar's test for comparing paired classifier accuracy on the same cohort.
850
+ Returns: (chi2_statistic, p_value)
851
+ """
852
+ binary_a = (preds_a >= threshold).astype(int)
853
+ binary_b = (preds_b >= threshold).astype(int)
854
+
855
+ correct_a = (binary_a == labels)
856
+ correct_b = (binary_b == labels)
857
+
858
+ b = int(np.sum(correct_a & ~correct_b))
859
+ c = int(np.sum(~correct_a & correct_b))
860
+
861
+ if b + c == 0:
862
+ return 0.0, 1.0
863
+
864
+ chi2 = float((abs(b - c) - 1) ** 2 / (b + c))
865
+ p_value = float(1.0 - stats.chi2.cdf(chi2, df=1))
866
+ return chi2, p_value
867
+
868
+
869
+ def delong_test(
870
+ labels: np.ndarray,
871
+ preds_a: np.ndarray,
872
+ preds_b: np.ndarray,
873
+ ) -> Tuple[float, float]:
874
+ """
875
+ DeLong's test for comparing AUROCs of two models on paired test set.
876
+ Returns: (z_statistic, p_value)
877
+ """
878
+ auc_a = float(roc_auc_score(labels, preds_a))
879
+ auc_b = float(roc_auc_score(labels, preds_b))
880
+
881
+ n1 = int(np.sum(labels == 1))
882
+ n0 = int(np.sum(labels == 0))
883
+
884
+ if n1 == 0 or n0 == 0:
885
+ return 0.0, 1.0
886
+
887
+ q1_a = auc_a / (2.0 - auc_a)
888
+ q2_a = 2.0 * auc_a**2 / (1.0 + auc_a)
889
+ se_a = np.sqrt((auc_a * (1.0 - auc_a) + (n1 - 1) * (q1_a - auc_a**2) +
890
+ (n0 - 1) * (q2_a - auc_a**2)) / (n1 * n0))
891
+
892
+ q1_b = auc_b / (2.0 - auc_b)
893
+ q2_b = 2.0 * auc_b**2 / (1.0 + auc_b)
894
+ se_b = np.sqrt((auc_b * (1.0 - auc_b) + (n1 - 1) * (q1_b - auc_b**2) +
895
+ (n0 - 1) * (q2_b - auc_b**2)) / (n1 * n0))
896
+
897
+ se_diff = float(np.sqrt(se_a**2 + se_b**2))
898
+ if se_diff == 0:
899
+ return 0.0, 1.0
900
+
901
+ z = float((auc_a - auc_b) / se_diff)
902
+ p_value = float(2.0 * (1.0 - stats.norm.cdf(abs(z))))
903
+ return z, p_value
904
+
905
+
906
+ # ── Visualization Helpers ──────────────────────────────────────────
907
+
908
+ def plot_roc_curve(
909
+ labels: np.ndarray,
910
+ predictions: np.ndarray,
911
+ save_path: Optional[str] = None,
912
+ title: str = "Receiver Operating Characteristic (ROC)",
913
+ model_name: str = "ACL-LKNet",
914
+ ) -> plt.Figure:
915
+ """Plot publication ROC curve."""
916
+ fpr, tpr, _ = roc_curve(labels, predictions)
917
+ auc = float(roc_auc_score(labels, predictions))
918
+
919
+ fig, ax = plt.subplots(figsize=(7, 7))
920
+ ax.plot(fpr, tpr, color="#0275d8", lw=2.5, label=f"{model_name} (AUROC = {auc:.3f})")
921
+ ax.plot([0, 1], [0, 1], color="gray", ls="--", alpha=0.6, label="Chance Reference (AUC = 0.500)")
922
+ ax.set_xlabel("False Positive Rate (1 - Specificity)", fontsize=12)
923
+ ax.set_ylabel("True Positive Rate (Sensitivity)", fontsize=12)
924
+ ax.set_title(title, fontsize=14, fontweight="bold")
925
+ ax.legend(loc="lower right", fontsize=11)
926
+ ax.grid(alpha=0.3)
927
+ plt.tight_layout()
928
+
929
+ if save_path:
930
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
931
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
932
+ return fig
933
+
934
+
935
+ def plot_precision_recall_curve(
936
+ labels: np.ndarray,
937
+ predictions: np.ndarray,
938
+ save_path: Optional[str] = None,
939
+ title: str = "Precision-Recall Curve (AUPRC)",
940
+ model_name: str = "ACL-LKNet",
941
+ ) -> plt.Figure:
942
+ """Plot publication Precision-Recall curve."""
943
+ precision, recall, _ = precision_recall_curve(labels, predictions)
944
+ auprc = float(average_precision_score(labels, predictions))
945
+ prevalence = float(labels.sum() / len(labels))
946
+
947
+ fig, ax = plt.subplots(figsize=(7, 7))
948
+ ax.plot(recall, precision, color="#5cb85c", lw=2.5, label=f"{model_name} (AUPRC = {auprc:.3f})")
949
+ ax.axhline(prevalence, color="gray", ls="--", alpha=0.6, label=f"Prevalence Baseline ({prevalence:.3f})")
950
+ ax.set_xlabel("Recall (Sensitivity)", fontsize=12)
951
+ ax.set_ylabel("Precision (PPV)", fontsize=12)
952
+ ax.set_title(title, fontsize=14, fontweight="bold")
953
+ ax.legend(loc="upper right", fontsize=11)
954
+ ax.grid(alpha=0.3)
955
+ plt.tight_layout()
956
+
957
+ if save_path:
958
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
959
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
960
+ return fig
961
+
962
+
963
+ def plot_training_curves(
964
+ train_history: List[dict],
965
+ val_history: List[dict],
966
+ save_path: Optional[str] = None,
967
+ ) -> plt.Figure:
968
+ """Plot multi-panel training and validation dynamics."""
969
+ fig, axes = plt.subplots(1, 3, figsize=(18, 5))
970
+ train_epochs = [h.get("epoch", i) for i, h in enumerate(train_history)]
971
+ val_epochs = [h.get("epoch", i) for i, h in enumerate(val_history)]
972
+
973
+ # Loss
974
+ if train_history and "train_loss" in train_history[0]:
975
+ axes[0].plot(train_epochs, [h["train_loss"] for h in train_history], label="Train Loss", color="#0275d8", lw=2)
976
+ if val_history and "val_loss" in val_history[0]:
977
+ axes[0].plot(val_epochs, [h["val_loss"] for h in val_history], label="Val Loss", color="#d9534f", lw=2)
978
+ axes[0].set_title("Cross-Entropy / BCE Loss", fontsize=13, fontweight="bold")
979
+ axes[0].set_xlabel("Epoch", fontsize=11)
980
+ axes[0].legend()
981
+ axes[0].grid(alpha=0.3)
982
+
983
+ # AUROC
984
+ if val_history and "val_auroc" in val_history[0]:
985
+ axes[1].plot(val_epochs, [h["val_auroc"] for h in val_history], label="Validation AUROC", color="#5cb85c", lw=2)
986
+ axes[1].axhline(0.95, color="k", ls="--", alpha=0.5, label="Target (0.95)")
987
+ axes[1].set_title("Validation AUROC", fontsize=13, fontweight="bold")
988
+ axes[1].set_xlabel("Epoch", fontsize=11)
989
+ axes[1].legend()
990
+ axes[1].grid(alpha=0.3)
991
+
992
+ # Accuracy
993
+ if val_history and "val_accuracy" in val_history[0]:
994
+ axes[2].plot(val_epochs, [h["val_accuracy"] for h in val_history], label="Validation Accuracy", color="#f0ad4e", lw=2)
995
+ axes[2].axhline(0.90, color="k", ls="--", alpha=0.5, label="Target (0.90)")
996
+ axes[2].set_title("Validation Accuracy", fontsize=13, fontweight="bold")
997
+ axes[2].set_xlabel("Epoch", fontsize=11)
998
+ axes[2].legend()
999
+ axes[2].grid(alpha=0.3)
1000
+
1001
+ plt.tight_layout()
1002
+ if save_path:
1003
+ os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True)
1004
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
1005
+ return fig
1006
+
1007
+
1008
+ def print_results_table(results: Dict[str, Tuple[float, float, float]]):
1009
+ """Print clean results table with 95% Bootstrap CIs."""
1010
+ print("\n" + "=" * 65)
1011
+ print(f"{'Clinical Metric':<20} {'Point Estimate':>15} {'95% Bootstrap CI':>25}")
1012
+ print("-" * 65)
1013
+ for name, (point, lower, upper) in results.items():
1014
+ print(f"{name:<20} {point:>15.4f} [{lower:.4f}, {upper:.4f}]")
1015
+ print("=" * 65 + "\n")
1016
+
1017
+
1018
+ # ── Master Full Evaluation Orchestrator ──────────────────────────────
1019
+
1020
+ def full_evaluation(
1021
+ model: nn.Module,
1022
+ val_loader,
1023
+ config,
1024
+ device: torch.device,
1025
+ save_dir: Optional[str] = None,
1026
+ ) -> Dict[str, Any]:
1027
+ """
1028
+ Execute the master scientific evaluation protocol across all 8 reviewer dimensions:
1029
+ 1. Extended diagnostic classification metrics with 95% Bootstrap CIs.
1030
+ 2. Thresholding-based evaluations (Youden's J, F1-optimal, high-sensitivity).
1031
+ 3. Dual confusion matrix generation (counts + condition-normalized).
1032
+ 4. Quantitative slice explainability (pointing game, entropy, sparsity).
1033
+ 5. Weak slice localization (Dice, IoU).
1034
+ 6. Architecture benchmark summary.
1035
+ 7. Scanner perturbation domain-shift robustness.
1036
+ """
1037
+ model.eval()
1038
+ all_preds = []
1039
+ all_labels = []
1040
+ sag_weights_list = []
1041
+
1042
+ use_amp = config.use_amp if config else True
1043
+
1044
+ with torch.no_grad():
1045
+ for batch in val_loader:
1046
+ sag = batch["sagittal"].to(device)
1047
+ cor = batch["coronal"].to(device)
1048
+ axi = batch["axial"].to(device)
1049
+ sag_m = batch["sag_mask"].to(device)
1050
+ cor_m = batch["cor_mask"].to(device)
1051
+ axi_m = batch["axi_mask"].to(device)
1052
+
1053
+ with torch.amp.autocast("cuda", enabled=use_amp and torch.cuda.is_available()):
1054
+ output = model(sag, cor, axi, sag_m, cor_m, axi_m)
1055
+
1056
+ probs = output["probs"].squeeze(-1).cpu().numpy().tolist()
1057
+ all_preds.extend(probs)
1058
+ all_labels.extend(batch["label"].numpy().tolist())
1059
+
1060
+ if "sag_weights" in output and output["sag_weights"] is not None:
1061
+ sag_weights_list.append(output["sag_weights"].cpu().numpy())
1062
+
1063
+ labels = np.array(all_labels)
1064
+ preds = np.array(all_preds)
1065
+
1066
+ # 1. Classification Metrics & Bootstrap CIs
1067
+ metrics_ci = compute_metrics_with_ci(labels, preds, n_bootstrap=config.bootstrap_n)
1068
+ print_results_table(metrics_ci)
1069
+
1070
+ # 2. Thresholding-based Optimization
1071
+ target_sens = getattr(config, "target_sensitivity", 0.95)
1072
+ opt_thresholds = find_optimal_thresholds(labels, preds, target_sensitivity=target_sens)
1073
+ threshold_sweep_df = generate_threshold_sweep(labels, preds)
1074
+
1075
+ chosen_mode = getattr(config, "eval_threshold_mode", "youden")
1076
+ active_tau = opt_thresholds.get(chosen_mode, opt_thresholds["default"])["threshold"]
1077
+
1078
+ # 3. Quantitative Explainability & Localization (if slice weights exist)
1079
+ explainability_results = {}
1080
+ localization_results = {}
1081
+ if len(sag_weights_list) > 0:
1082
+ all_sag_weights = np.concatenate(sag_weights_list, axis=0)
1083
+ explain_range = getattr(config, "explainability_target_slice_range", (10, 18))
1084
+ explainability_results = evaluate_slice_explainability(all_sag_weights, target_slice_range=explain_range, labels=labels)
1085
+ localization_results = evaluate_slice_localization(all_sag_weights, ground_truth_active_range=explain_range)
1086
+
1087
+ # 4. Scanner Perturbation Robustness
1088
+ robustness_results = evaluate_scanner_perturbation_robustness(model, val_loader, device, config=config)
1089
+
1090
+ # 5. Architecture Benchmark
1091
+ arch_df = generate_architecture_benchmark_table(model, config=config)
1092
+
1093
+ # 6. Save Artifacts & Visualizations
1094
+ if save_dir:
1095
+ os.makedirs(save_dir, exist_ok=True)
1096
+ # Visualizations
1097
+ plot_roc_curve(labels, preds, os.path.join(save_dir, "roc_curve.png"))
1098
+ plot_precision_recall_curve(labels, preds, os.path.join(save_dir, "pr_curve.png"))
1099
+ plot_confusion_matrices_dual(labels, preds, threshold=active_tau, save_path=os.path.join(save_dir, "confusion_matrix_dual.png"))
1100
+ plot_threshold_curves(labels, preds, save_path=os.path.join(save_dir, "threshold_curves.png"), target_sensitivity=target_sens)
1101
+
1102
+ # Tabular data exports
1103
+ threshold_sweep_df.to_csv(os.path.join(save_dir, "threshold_sweep.csv"), index=False)
1104
+ arch_df.to_csv(os.path.join(save_dir, "architecture_benchmark.csv"), index=False)
1105
+
1106
+ # Save training configuration table if config has export methods
1107
+ if hasattr(config, "export_config_markdown"):
1108
+ config.export_config_markdown(os.path.join(save_dir, "training_configuration.md"))
1109
+ if hasattr(config, "export_config_latex"):
1110
+ config.export_config_latex(os.path.join(save_dir, "training_configuration.tex"))
1111
+
1112
+ # Save summary JSON
1113
+ summary_payload = {
1114
+ "metrics": compute_metrics(labels, preds, threshold=active_tau),
1115
+ "metrics_ci": {k: {"point": float(v[0]), "lower_95": float(v[1]), "upper_95": float(v[2])} for k, v in metrics_ci.items()},
1116
+ "optimal_thresholds": opt_thresholds,
1117
+ "explainability": explainability_results,
1118
+ "localization": localization_results,
1119
+ "scanner_robustness": robustness_results,
1120
+ }
1121
+ import json
1122
+ with open(os.path.join(save_dir, "metrics.json"), "w") as f:
1123
+ json.dump(summary_payload, f, indent=2)
1124
+
1125
+ return {
1126
+ "labels": labels,
1127
+ "predictions": preds,
1128
+ "metrics_ci": metrics_ci,
1129
+ "metrics": compute_metrics(labels, preds, threshold=active_tau),
1130
+ "optimal_thresholds": opt_thresholds,
1131
+ "threshold_sweep": threshold_sweep_df,
1132
+ "explainability": explainability_results,
1133
+ "localization": localization_results,
1134
+ "robustness": robustness_results,
1135
+ "architecture_benchmark": arch_df,
1136
+ }
src/models/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .acl_lknet import ACLLKNet
2
+ from .msm import MaskedSliceModeling
src/models/acl_lknet.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ACL-LKNet: Full model assembly.
3
+
4
+ Combines all components into the complete pipeline:
5
+ Backbone → Slice Attention → Cross-View Fusion → Classifier
6
+
7
+ Design:
8
+ - Shared backbone processes slices from all 3 views (parameter-efficient)
9
+ - Slices are processed in memory-efficient chunks (for T4 GPU)
10
+ - Supports switching between attention/concat fusion and pool/attention aggregation
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+ from torch.utils.checkpoint import checkpoint as grad_checkpoint
17
+
18
+ from .backbone import create_backbone
19
+ from .slice_attention import SliceAttention
20
+ from .cross_view_fusion import create_fusion
21
+
22
+
23
+ class ACLLKNet(nn.Module):
24
+ """
25
+ ACL-LKNet: Hierarchical Self-Supervised Large-Kernel Network
26
+ for ACL Tear Detection in Knee MRI.
27
+
28
+ Architecture:
29
+ 1. Shared LKNet backbone extracts per-slice features
30
+ 2. Slice attention aggregates variable-length slice sequences per view
31
+ 3. Cross-view attention fuses the 3 plane embeddings
32
+ 4. Classification head outputs ACL tear probability
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ backbone_name: str = "convnext_tiny",
38
+ pretrained: bool = True,
39
+ feature_dim: int = 0,
40
+ attn_hidden_dim: int = 256,
41
+ fusion_type: str = "attention",
42
+ fusion_num_heads: int = 2,
43
+ fusion_dropout: float = 0.1,
44
+ classifier_hidden: int = 256,
45
+ classifier_dropout: float = 0.3,
46
+ aggregation: str = "attention", # 'attention', 'max', 'mean'
47
+ use_grad_checkpoint: bool = True,
48
+ slice_chunk_size: int = 8,
49
+ use_large_kernels: bool = False,
50
+ large_kernel_sizes: list = None,
51
+ ):
52
+ super().__init__()
53
+ self.slice_chunk_size = slice_chunk_size
54
+ self.aggregation_type = aggregation
55
+ self.use_grad_checkpoint = use_grad_checkpoint
56
+
57
+ # ── Backbone ──
58
+ self.backbone = create_backbone(
59
+ name=backbone_name,
60
+ pretrained=pretrained,
61
+ use_grad_checkpoint=use_grad_checkpoint,
62
+ use_large_kernels=use_large_kernels,
63
+ large_kernel_sizes=large_kernel_sizes,
64
+ )
65
+ feat_dim = feature_dim if feature_dim > 0 else self.backbone.feature_dim
66
+
67
+ # ── Slice Attention (per view) ──
68
+ if aggregation == "attention":
69
+ self.slice_attn = SliceAttention(feat_dim, attn_hidden_dim)
70
+ else:
71
+ self.slice_attn = None
72
+
73
+ # ── Cross-View Fusion ──
74
+ self.fusion = create_fusion(
75
+ fusion_type=fusion_type,
76
+ feature_dim=feat_dim,
77
+ num_heads=fusion_num_heads,
78
+ dropout=fusion_dropout,
79
+ )
80
+
81
+ # ── Classification Head ──
82
+ self.classifier = nn.Sequential(
83
+ nn.LayerNorm(feat_dim),
84
+ nn.Linear(feat_dim, classifier_hidden),
85
+ nn.GELU(),
86
+ nn.Dropout(classifier_dropout),
87
+ nn.Linear(classifier_hidden, 1),
88
+ )
89
+
90
+ self._feat_dim = feat_dim
91
+
92
+ @property
93
+ def feature_dim(self):
94
+ return self._feat_dim
95
+
96
+ def _extract_slice_features(self, slices: torch.Tensor) -> torch.Tensor:
97
+ """
98
+ Extract features from a batch of slices, processing in memory-efficient chunks.
99
+
100
+ Args:
101
+ slices: (B, S, H, W) — grayscale MRI slices for ONE view
102
+
103
+ Returns:
104
+ features: (B, S, D) — per-slice feature vectors
105
+ """
106
+ B, S, H, W = slices.shape
107
+ all_features = []
108
+
109
+ for i in range(0, S, self.slice_chunk_size):
110
+ chunk = slices[:, i : i + self.slice_chunk_size] # (B, chunk, H, W)
111
+ chunk_size = chunk.shape[1]
112
+
113
+ # Reshape: (B, chunk, H, W) → (B*chunk, 1, H, W)
114
+ chunk = chunk.reshape(B * chunk_size, 1, H, W)
115
+
116
+ # Forward through backbone
117
+ feat = self.backbone(chunk) # (B*chunk, D)
118
+
119
+ # Reshape back: (B, chunk, D)
120
+ feat = feat.reshape(B, chunk_size, -1)
121
+ all_features.append(feat)
122
+
123
+ features = torch.cat(all_features, dim=1) # (B, S, D)
124
+ return features
125
+
126
+ def _aggregate_slices(
127
+ self, features: torch.Tensor, mask: torch.Tensor = None
128
+ ) -> tuple:
129
+ """
130
+ Aggregate slice features into a single view embedding.
131
+
132
+ Args:
133
+ features: (B, S, D)
134
+ mask: (B, S) — True for valid slices
135
+
136
+ Returns:
137
+ embedding: (B, D)
138
+ attn_weights: (B, S) or None
139
+ """
140
+ if self.aggregation_type == "attention" and self.slice_attn is not None:
141
+ return self.slice_attn(features, mask)
142
+ elif self.aggregation_type == "max":
143
+ if mask is not None:
144
+ features = features.masked_fill(~mask.unsqueeze(-1), float("-inf"))
145
+ return features.max(dim=1)[0], None
146
+ elif self.aggregation_type == "mean":
147
+ if mask is not None:
148
+ features = features * mask.unsqueeze(-1).float()
149
+ return features.sum(dim=1) / mask.sum(dim=1, keepdim=True).float(), None
150
+ return features.mean(dim=1), None
151
+ else:
152
+ raise ValueError(f"Unknown aggregation: {self.aggregation_type}")
153
+
154
+ def forward(
155
+ self,
156
+ sagittal: torch.Tensor,
157
+ coronal: torch.Tensor,
158
+ axial: torch.Tensor,
159
+ sag_mask: torch.Tensor = None,
160
+ cor_mask: torch.Tensor = None,
161
+ axi_mask: torch.Tensor = None,
162
+ ) -> dict:
163
+ """
164
+ Full forward pass.
165
+
166
+ Args:
167
+ sagittal: (B, S_sag, H, W) — sagittal MRI slices
168
+ coronal: (B, S_cor, H, W) — coronal MRI slices
169
+ axial: (B, S_axi, H, W) — axial MRI slices
170
+ sag_mask: (B, S_sag) — optional padding mask
171
+ cor_mask: (B, S_cor) — optional padding mask
172
+ axi_mask: (B, S_axi) — optional padding mask
173
+
174
+ Returns:
175
+ dict with:
176
+ 'logits': (B, 1) — raw logits
177
+ 'probs': (B, 1) — sigmoid probabilities
178
+ 'sag_weights': (B, S_sag) — slice attention weights
179
+ 'cor_weights': (B, S_cor) — slice attention weights
180
+ 'axi_weights': (B, S_axi) — slice attention weights
181
+ """
182
+ # Extract slice features (shared backbone)
183
+ sag_feats = self._extract_slice_features(sagittal)
184
+ cor_feats = self._extract_slice_features(coronal)
185
+ axi_feats = self._extract_slice_features(axial)
186
+
187
+ # Aggregate slices → view embeddings
188
+ sag_emb, sag_w = self._aggregate_slices(sag_feats, sag_mask)
189
+ cor_emb, cor_w = self._aggregate_slices(cor_feats, cor_mask)
190
+ axi_emb, axi_w = self._aggregate_slices(axi_feats, axi_mask)
191
+
192
+ # Cross-view fusion
193
+ fused = self.fusion(sag_emb, cor_emb, axi_emb) # (B, D)
194
+
195
+ # Classification
196
+ logits = self.classifier(fused) # (B, 1)
197
+ probs = torch.sigmoid(logits)
198
+
199
+ return {
200
+ "logits": logits,
201
+ "probs": probs,
202
+ "sag_weights": sag_w,
203
+ "cor_weights": cor_w,
204
+ "axi_weights": axi_w,
205
+ }
206
+
207
+ def get_slice_features(
208
+ self,
209
+ sagittal: torch.Tensor,
210
+ coronal: torch.Tensor,
211
+ axial: torch.Tensor,
212
+ ) -> dict:
213
+ """
214
+ Extract slice features only (for MSM pretraining).
215
+ Does not run attention/fusion/classifier.
216
+
217
+ Returns:
218
+ dict with 'sagittal', 'coronal', 'axial' — each (B, S, D)
219
+ """
220
+ return {
221
+ "sagittal": self._extract_slice_features(sagittal),
222
+ "coronal": self._extract_slice_features(coronal),
223
+ "axial": self._extract_slice_features(axial),
224
+ }
225
+
226
+
227
+ def create_model_from_config(config) -> ACLLKNet:
228
+ """Create an ACLLKNet model from a Config object."""
229
+ return ACLLKNet(
230
+ backbone_name=config.backbone,
231
+ pretrained=config.pretrained,
232
+ feature_dim=config.feature_dim,
233
+ attn_hidden_dim=config.attn_hidden_dim,
234
+ fusion_type=config.fusion_type,
235
+ fusion_num_heads=config.fusion_num_heads,
236
+ fusion_dropout=config.fusion_dropout,
237
+ classifier_hidden=config.classifier_hidden,
238
+ classifier_dropout=config.classifier_dropout,
239
+ aggregation=getattr(config, "aggregation", "attention"),
240
+ use_grad_checkpoint=config.grad_checkpoint,
241
+ slice_chunk_size=config.slice_chunk_size,
242
+ use_large_kernels=config.use_large_kernels,
243
+ large_kernel_sizes=config.large_kernel_sizes,
244
+ )
src/models/backbone.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Backbone feature extractor for ACL-LKNet.
3
+
4
+ Uses timm for standard pretrained backbones (ConvNeXt, ResNet, EfficientNet).
5
+ Includes optional large-kernel modification for kernel-size ablation.
6
+
7
+ Design decisions:
8
+ - ConvNeXt-Tiny is the default (28M params, 7×7 DW conv, T4-friendly)
9
+ - ResNet-50 included for RadImageNet comparison (RadImageNet provides ResNet50 weights)
10
+ - Large-kernel variant replaces ConvNeXt DW convolutions with larger kernels
11
+ - All backbones output a single feature vector per input image via global avg pool
12
+ """
13
+
14
+ import torch
15
+ import torch.nn as nn
16
+ import torch.nn.functional as F
17
+ import timm
18
+ from torch.utils.checkpoint import checkpoint as grad_checkpoint
19
+
20
+
21
+ class BackboneWrapper(nn.Module):
22
+ """
23
+ Wraps a timm backbone to:
24
+ 1. Accept 1-channel grayscale input (converts to 3-channel)
25
+ 2. Strip the classification head
26
+ 3. Return a feature vector via global average pooling
27
+ 4. Support gradient checkpointing for T4 memory
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ name: str = "convnext_tiny",
33
+ pretrained: bool = True,
34
+ use_grad_checkpoint: bool = True,
35
+ ):
36
+ super().__init__()
37
+ self.name = name
38
+ self.use_grad_checkpoint = use_grad_checkpoint
39
+
40
+ # Create backbone from timm (no classification head)
41
+ self.backbone = timm.create_model(
42
+ name,
43
+ pretrained=pretrained,
44
+ num_classes=0, # Remove classifier → feature extractor
45
+ global_pool="avg", # Global average pooling
46
+ )
47
+ self.feature_dim = self.backbone.num_features
48
+
49
+ # 1-channel → 3-channel adapter
50
+ # We use a lightweight conv instead of simple replication so the model
51
+ # can learn an optimal channel mapping for grayscale MRI
52
+ self.channel_adapter = nn.Sequential(
53
+ nn.Conv2d(1, 3, kernel_size=1, bias=False),
54
+ nn.BatchNorm2d(3),
55
+ )
56
+ # Initialize adapter to approximate channel replication
57
+ nn.init.constant_(self.channel_adapter[0].weight, 1.0 / 3.0)
58
+
59
+ if use_grad_checkpoint and hasattr(self.backbone, "set_grad_checkpointing"):
60
+ self.backbone.set_grad_checkpointing(enable=True)
61
+
62
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
63
+ """
64
+ Args:
65
+ x: (B, 1, H, W) grayscale MRI slices
66
+ Returns:
67
+ features: (B, D) feature vectors
68
+ """
69
+ # Grayscale → 3-channel
70
+ x = self.channel_adapter(x)
71
+ # Extract features
72
+ features = self.backbone(x)
73
+ return features
74
+
75
+
76
+ class LargeKernelBlock(nn.Module):
77
+ """
78
+ Large-kernel depth-wise convolution block inspired by RepLKNet.
79
+
80
+ Uses depth-wise separable convolution with a large kernel,
81
+ plus SE (Squeeze-and-Excitation) channel attention.
82
+
83
+ For efficiency, large DW convolutions are decomposed into:
84
+ depth-wise conv (large kernel) + point-wise conv (1×1)
85
+ The large DW conv has very few parameters (kernel_size² × channels).
86
+ """
87
+
88
+ def __init__(self, dim: int, kernel_size: int = 31, drop_path: float = 0.0):
89
+ super().__init__()
90
+ padding = kernel_size // 2
91
+
92
+ self.norm = nn.BatchNorm2d(dim)
93
+ # Large-kernel depth-wise convolution
94
+ self.dw_conv = nn.Conv2d(
95
+ dim, dim, kernel_size=kernel_size, padding=padding,
96
+ groups=dim, bias=False
97
+ )
98
+ # SE attention
99
+ self.se = nn.Sequential(
100
+ nn.AdaptiveAvgPool2d(1),
101
+ nn.Flatten(),
102
+ nn.Linear(dim, dim // 4),
103
+ nn.GELU(),
104
+ nn.Linear(dim // 4, dim),
105
+ nn.Sigmoid(),
106
+ )
107
+ # Point-wise (1×1) expansion
108
+ self.pw_conv1 = nn.Conv2d(dim, dim * 4, kernel_size=1)
109
+ self.act = nn.GELU()
110
+ self.pw_conv2 = nn.Conv2d(dim * 4, dim, kernel_size=1)
111
+
112
+ # Stochastic depth
113
+ self.drop_path = DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
114
+
115
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
116
+ residual = x
117
+ x = self.norm(x)
118
+ x = self.dw_conv(x)
119
+ # SE attention
120
+ se_weight = self.se(x).unsqueeze(-1).unsqueeze(-1)
121
+ x = x * se_weight
122
+ # FFN
123
+ x = self.pw_conv1(x)
124
+ x = self.act(x)
125
+ x = self.pw_conv2(x)
126
+ x = self.drop_path(x) + residual
127
+ return x
128
+
129
+
130
+ class DropPath(nn.Module):
131
+ """Stochastic depth — drops entire residual branches during training."""
132
+
133
+ def __init__(self, drop_prob: float = 0.0):
134
+ super().__init__()
135
+ self.drop_prob = drop_prob
136
+
137
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
138
+ if not self.training or self.drop_prob == 0.0:
139
+ return x
140
+ keep_prob = 1 - self.drop_prob
141
+ shape = (x.shape[0],) + (1,) * (x.ndim - 1)
142
+ mask = torch.empty(shape, device=x.device).bernoulli_(keep_prob)
143
+ return x * mask / keep_prob
144
+
145
+
146
+ class LKNetBackbone(nn.Module):
147
+ """
148
+ Custom Large-Kernel Network backbone for kernel-size ablation.
149
+
150
+ 4-stage hierarchical design with configurable kernel sizes per stage.
151
+ Used when we need to isolate the effect of kernel size independently
152
+ of the backbone architecture (ConvNeXt vs ResNet, etc.).
153
+
154
+ Stage dims: [64, 128, 256, 512]
155
+ Stage depths: [2, 2, 6, 2]
156
+ Default kernels: [7, 13, 21, 31]
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ in_channels: int = 1,
162
+ dims: list = None,
163
+ depths: list = None,
164
+ kernel_sizes: list = None,
165
+ drop_path_rate: float = 0.1,
166
+ ):
167
+ super().__init__()
168
+ dims = dims or [64, 128, 256, 512]
169
+ depths = depths or [2, 2, 6, 2]
170
+ kernel_sizes = kernel_sizes or [7, 13, 21, 31]
171
+
172
+ self.feature_dim = dims[-1]
173
+
174
+ # Stem: 4× downsampling with small kernels (stable)
175
+ self.stem = nn.Sequential(
176
+ nn.Conv2d(in_channels, dims[0], kernel_size=4, stride=4),
177
+ nn.BatchNorm2d(dims[0]),
178
+ )
179
+
180
+ # Build stages
181
+ self.stages = nn.ModuleList()
182
+ dp_rates = [x.item() for x in torch.linspace(0, drop_path_rate, sum(depths))]
183
+ cur = 0
184
+
185
+ for i in range(4):
186
+ # Downsampling between stages (except first)
187
+ if i > 0:
188
+ downsample = nn.Sequential(
189
+ nn.BatchNorm2d(dims[i - 1]),
190
+ nn.Conv2d(dims[i - 1], dims[i], kernel_size=2, stride=2),
191
+ )
192
+ else:
193
+ downsample = nn.Identity()
194
+
195
+ # Stack of LK blocks
196
+ blocks = nn.Sequential(*[
197
+ LargeKernelBlock(dims[i], kernel_sizes[i], dp_rates[cur + j])
198
+ for j in range(depths[i])
199
+ ])
200
+ cur += depths[i]
201
+
202
+ self.stages.append(nn.Sequential(downsample, blocks))
203
+
204
+ self.norm = nn.LayerNorm(dims[-1])
205
+ self.pool = nn.AdaptiveAvgPool2d(1)
206
+
207
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
208
+ """
209
+ Args:
210
+ x: (B, C, H, W)
211
+ Returns:
212
+ features: (B, feature_dim)
213
+ """
214
+ x = self.stem(x)
215
+ for stage in self.stages:
216
+ x = stage(x)
217
+ x = self.pool(x).flatten(1)
218
+ x = self.norm(x)
219
+ return x
220
+
221
+
222
+ def create_backbone(
223
+ name: str = "convnext_tiny",
224
+ pretrained: bool = True,
225
+ use_grad_checkpoint: bool = True,
226
+ use_large_kernels: bool = False,
227
+ large_kernel_sizes: list = None,
228
+ ) -> nn.Module:
229
+ """
230
+ Factory function to create a backbone feature extractor.
231
+
232
+ Args:
233
+ name: Backbone name ('convnext_tiny', 'resnet18', 'resnet50',
234
+ 'efficientnet_b0', 'lknet')
235
+ pretrained: Load ImageNet pretrained weights (for timm models)
236
+ use_grad_checkpoint: Enable gradient checkpointing
237
+ use_large_kernels: Replace DW convolutions with larger kernels
238
+ large_kernel_sizes: Kernel sizes per stage [s1, s2, s3, s4]
239
+
240
+ Returns:
241
+ backbone: nn.Module with .feature_dim attribute
242
+ """
243
+ if name == "lknet":
244
+ # Custom LK backbone (no pretrained weights — train from scratch or MSM)
245
+ backbone = LKNetBackbone(
246
+ in_channels=1,
247
+ kernel_sizes=large_kernel_sizes or [7, 13, 21, 31],
248
+ )
249
+ return backbone
250
+
251
+ # Standard timm backbone
252
+ backbone = BackboneWrapper(
253
+ name=name,
254
+ pretrained=pretrained,
255
+ use_grad_checkpoint=use_grad_checkpoint,
256
+ )
257
+ return backbone
258
+
259
+
260
+ def load_radimagenet_weights(model: nn.Module, weights_path: str):
261
+ """
262
+ Load RadImageNet pretrained weights into a ResNet/DenseNet backbone.
263
+
264
+ RadImageNet provides weights for ResNet50, DenseNet121, InceptionV3.
265
+ These are loaded into the backbone's internal model.
266
+
267
+ Args:
268
+ model: BackboneWrapper with a timm ResNet50 backbone
269
+ weights_path: Path to RadImageNet .pt/.h5 weights file
270
+ """
271
+ state_dict = torch.load(weights_path, map_location="cpu", weights_only=True)
272
+ # RadImageNet weights may have different key names — attempt flexible loading
273
+ model_dict = model.backbone.state_dict()
274
+ filtered = {k: v for k, v in state_dict.items() if k in model_dict and v.shape == model_dict[k].shape}
275
+ model_dict.update(filtered)
276
+ model.backbone.load_state_dict(model_dict, strict=False)
277
+ print(f"Loaded {len(filtered)}/{len(model_dict)} layers from RadImageNet weights")
src/models/cross_view_fusion.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cross-View Attention Fusion for ACL-LKNet.
3
+
4
+ Fuses plane-level representations from sagittal, coronal, and axial views
5
+ using multi-head attention. This allows the model to learn cross-view
6
+ dependencies rather than merely concatenating independent features.
7
+
8
+ Supports two modes:
9
+ - 'attention': Multi-head self-attention over 3 view embeddings
10
+ - 'concat': Simple concatenation + FC (ablation baseline)
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+
16
+
17
+ class CrossViewAttentionFusion(nn.Module):
18
+ """
19
+ Fuses 3 plane embeddings using multi-head self-attention.
20
+
21
+ Input: 3 view embeddings, each of shape (B, D)
22
+ Process:
23
+ 1. Stack into sequence of length 3: (B, 3, D)
24
+ 2. Add learnable view position embeddings
25
+ 3. Multi-head self-attention
26
+ 4. Mean-pool the 3 output embeddings → (B, D)
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ feature_dim: int,
32
+ num_heads: int = 2,
33
+ dropout: float = 0.1,
34
+ ):
35
+ super().__init__()
36
+ self.feature_dim = feature_dim
37
+
38
+ # Learnable position embeddings for each view
39
+ # (sagittal=0, coronal=1, axial=2)
40
+ self.view_pos_embed = nn.Parameter(torch.zeros(1, 3, feature_dim))
41
+ nn.init.trunc_normal_(self.view_pos_embed, std=0.02)
42
+
43
+ # Pre-norm
44
+ self.norm = nn.LayerNorm(feature_dim)
45
+
46
+ # Multi-head self-attention
47
+ self.mha = nn.MultiheadAttention(
48
+ embed_dim=feature_dim,
49
+ num_heads=num_heads,
50
+ dropout=dropout,
51
+ batch_first=True,
52
+ )
53
+
54
+ # Post-attention FFN
55
+ self.ffn = nn.Sequential(
56
+ nn.LayerNorm(feature_dim),
57
+ nn.Linear(feature_dim, feature_dim * 2),
58
+ nn.GELU(),
59
+ nn.Dropout(dropout),
60
+ nn.Linear(feature_dim * 2, feature_dim),
61
+ nn.Dropout(dropout),
62
+ )
63
+
64
+ def forward(
65
+ self,
66
+ sagittal: torch.Tensor,
67
+ coronal: torch.Tensor,
68
+ axial: torch.Tensor,
69
+ ) -> torch.Tensor:
70
+ """
71
+ Args:
72
+ sagittal: (B, D) sagittal plane embedding
73
+ coronal: (B, D) coronal plane embedding
74
+ axial: (B, D) axial plane embedding
75
+
76
+ Returns:
77
+ fused: (B, D) fused representation
78
+ """
79
+ # Stack into sequence: (B, 3, D)
80
+ x = torch.stack([sagittal, coronal, axial], dim=1)
81
+
82
+ # Add view position embeddings
83
+ x = x + self.view_pos_embed
84
+
85
+ # Self-attention with residual
86
+ x_norm = self.norm(x)
87
+ attn_out, _ = self.mha(x_norm, x_norm, x_norm)
88
+ x = x + attn_out
89
+
90
+ # FFN with residual
91
+ x = x + self.ffn(x)
92
+
93
+ # Mean-pool over the 3 views
94
+ fused = x.mean(dim=1) # (B, D)
95
+ return fused
96
+
97
+
98
+ class ConcatFusion(nn.Module):
99
+ """
100
+ Simple concatenation + FC fusion (ablation baseline).
101
+
102
+ Concatenates 3 view embeddings → FC → output embedding.
103
+ """
104
+
105
+ def __init__(self, feature_dim: int, dropout: float = 0.1):
106
+ super().__init__()
107
+ self.fusion = nn.Sequential(
108
+ nn.Linear(feature_dim * 3, feature_dim),
109
+ nn.GELU(),
110
+ nn.Dropout(dropout),
111
+ )
112
+
113
+ def forward(
114
+ self,
115
+ sagittal: torch.Tensor,
116
+ coronal: torch.Tensor,
117
+ axial: torch.Tensor,
118
+ ) -> torch.Tensor:
119
+ x = torch.cat([sagittal, coronal, axial], dim=-1) # (B, 3D)
120
+ return self.fusion(x) # (B, D)
121
+
122
+
123
+ def create_fusion(
124
+ fusion_type: str,
125
+ feature_dim: int,
126
+ num_heads: int = 2,
127
+ dropout: float = 0.1,
128
+ ) -> nn.Module:
129
+ """Factory for fusion modules."""
130
+ if fusion_type == "attention":
131
+ return CrossViewAttentionFusion(feature_dim, num_heads, dropout)
132
+ elif fusion_type == "concat":
133
+ return ConcatFusion(feature_dim, dropout)
134
+ else:
135
+ raise ValueError(f"Unknown fusion type: {fusion_type}")
src/models/msm.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Masked Slice Modeling (MSM) — Self-Supervised Pretraining for ACL-LKNet.
3
+
4
+ This is the PRIMARY RESEARCH CONTRIBUTION.
5
+
6
+ Core idea: MRI volumes have ordered slices with anatomical continuity.
7
+ We exploit this structure by masking some slices and training the model
8
+ to reconstruct their features from the remaining (unmasked) context.
9
+
10
+ Research question:
11
+ Can a self-supervised objective that explicitly models inter-slice
12
+ anatomical context produce better transferable representations for
13
+ ACL injury detection than established pretraining?
14
+
15
+ Masking strategies (research axis):
16
+ - random: Mask 50% of slices uniformly at random
17
+ - contiguous: Mask contiguous blocks of 3-5 adjacent slices
18
+ - structured: Preferentially mask central slices (clinically relevant)
19
+ - mixed: Alternate random and contiguous per batch
20
+ """
21
+
22
+ import math
23
+ import random as py_random
24
+ from typing import Tuple, Optional
25
+
26
+ import torch
27
+ import torch.nn as nn
28
+ import torch.nn.functional as F
29
+
30
+
31
+ class MaskedSliceModeling(nn.Module):
32
+ """
33
+ Self-supervised pretraining via Masked Slice Modeling.
34
+
35
+ Architecture:
36
+ 1. Encode all slices through the shared backbone → slice features
37
+ 2. Replace masked slice features with learnable [MASK] tokens
38
+ 3. Add positional encoding (sinusoidal — respects slice ordering)
39
+ 4. Pass through lightweight Transformer decoder
40
+ 5. Predict the original features of masked slices
41
+
42
+ Loss: MSE between predicted and actual features of masked slices
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ feature_dim: int,
48
+ decoder_dim: int = 256,
49
+ decoder_layers: int = 2,
50
+ decoder_heads: int = 4,
51
+ max_slices: int = 48,
52
+ mask_ratio: float = 0.5,
53
+ mask_strategy: str = "random",
54
+ ):
55
+ super().__init__()
56
+ self.feature_dim = feature_dim
57
+ self.decoder_dim = decoder_dim
58
+ self.mask_ratio = mask_ratio
59
+ self.mask_strategy = mask_strategy
60
+ self.max_slices = max_slices
61
+
62
+ # Learnable [MASK] token
63
+ self.mask_token = nn.Parameter(torch.zeros(1, 1, feature_dim))
64
+ nn.init.trunc_normal_(self.mask_token, std=0.02)
65
+
66
+ # Project encoder features → decoder dimension
67
+ self.encoder_to_decoder = nn.Linear(feature_dim, decoder_dim)
68
+
69
+ # Sinusoidal positional encoding (respects spatial ordering of slices)
70
+ self.register_buffer(
71
+ "pos_encoding", self._sinusoidal_encoding(max_slices, decoder_dim)
72
+ )
73
+
74
+ # Lightweight Transformer decoder
75
+ decoder_layer = nn.TransformerEncoderLayer(
76
+ d_model=decoder_dim,
77
+ nhead=decoder_heads,
78
+ dim_feedforward=decoder_dim * 4,
79
+ dropout=0.1,
80
+ activation="gelu",
81
+ batch_first=True,
82
+ norm_first=True,
83
+ )
84
+ self.decoder = nn.TransformerEncoder(
85
+ decoder_layer,
86
+ num_layers=decoder_layers,
87
+ )
88
+
89
+ # Predict original features from decoded representations
90
+ self.predictor = nn.Sequential(
91
+ nn.LayerNorm(decoder_dim),
92
+ nn.Linear(decoder_dim, feature_dim),
93
+ )
94
+
95
+ @staticmethod
96
+ def _sinusoidal_encoding(max_len: int, dim: int) -> torch.Tensor:
97
+ """Generate sinusoidal positional encoding."""
98
+ pe = torch.zeros(max_len, dim)
99
+ position = torch.arange(0, max_len).unsqueeze(1).float()
100
+ div_term = torch.exp(
101
+ torch.arange(0, dim, 2).float() * (-math.log(10000.0) / dim)
102
+ )
103
+ pe[:, 0::2] = torch.sin(position * div_term)
104
+ pe[:, 1::2] = torch.cos(position * div_term)
105
+ return pe.unsqueeze(0) # (1, max_len, dim)
106
+
107
+ def generate_mask(
108
+ self, num_slices: int, strategy: Optional[str] = None
109
+ ) -> torch.Tensor:
110
+ """
111
+ Generate a binary mask indicating which slices to mask.
112
+
113
+ Args:
114
+ num_slices: Number of slices in the volume
115
+ strategy: Override the default masking strategy
116
+
117
+ Returns:
118
+ mask: (num_slices,) boolean tensor. True = masked (to predict)
119
+ """
120
+ strategy = strategy or self.mask_strategy
121
+ num_mask = max(1, int(num_slices * self.mask_ratio))
122
+ # Always keep at least 2 slices unmasked for context
123
+ num_mask = min(num_mask, num_slices - 2)
124
+
125
+ mask = torch.zeros(num_slices, dtype=torch.bool)
126
+
127
+ if strategy == "random":
128
+ indices = torch.randperm(num_slices)[:num_mask]
129
+ mask[indices] = True
130
+
131
+ elif strategy == "contiguous":
132
+ # Mask contiguous blocks of 3-5 slices
133
+ remaining = num_mask
134
+ while remaining > 0:
135
+ block_size = min(py_random.randint(3, 5), remaining)
136
+ max_start = num_slices - block_size
137
+ if max_start <= 0:
138
+ start = 0
139
+ else:
140
+ start = py_random.randint(0, max_start)
141
+ mask[start : start + block_size] = True
142
+ remaining = num_mask - mask.sum().item()
143
+
144
+ elif strategy == "structured":
145
+ # Preferentially mask central slices (where ACL is typically visible)
146
+ center = num_slices // 2
147
+ # Create probability distribution peaked at center
148
+ positions = torch.arange(num_slices).float()
149
+ probs = torch.exp(-0.5 * ((positions - center) / (num_slices / 4)) ** 2)
150
+ probs = probs / probs.sum()
151
+ indices = torch.multinomial(probs, num_mask, replacement=False)
152
+ mask[indices] = True
153
+
154
+ elif strategy == "mixed":
155
+ # Randomly choose between random and contiguous per call
156
+ sub_strategy = py_random.choice(["random", "contiguous"])
157
+ mask = self.generate_mask(num_slices, strategy=sub_strategy)
158
+
159
+ else:
160
+ raise ValueError(f"Unknown mask strategy: {strategy}")
161
+
162
+ return mask
163
+
164
+ def forward(
165
+ self,
166
+ slice_features: torch.Tensor,
167
+ slice_mask: Optional[torch.Tensor] = None,
168
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
169
+ """
170
+ Forward pass for MSM pretraining.
171
+
172
+ Args:
173
+ slice_features: (B, S, D) — encoded slice features from backbone
174
+ slice_mask: (B, S) — optional padding mask (True = valid)
175
+
176
+ Returns:
177
+ loss: scalar MSE loss on masked slices
178
+ predictions: (B, S, D) — predicted features for all slices
179
+ mask: (B, S) — boolean mask of which slices were masked
180
+ """
181
+ B, S, D = slice_features.shape
182
+
183
+ # Generate masks for each sample in the batch
184
+ masks = torch.stack([self.generate_mask(S) for _ in range(B)]) # (B, S)
185
+ masks = masks.to(slice_features.device)
186
+
187
+ # Replace masked positions with [MASK] token
188
+ mask_tokens = self.mask_token.expand(B, S, -1) # (B, S, D)
189
+ masked_features = slice_features.clone()
190
+ masked_features[masks] = mask_tokens[masks]
191
+
192
+ # Project to decoder dimension
193
+ x = self.encoder_to_decoder(masked_features) # (B, S, decoder_dim)
194
+
195
+ # Add positional encoding
196
+ x = x + self.pos_encoding[:, :S, :]
197
+
198
+ # Transformer decoder
199
+ x = self.decoder(x) # (B, S, decoder_dim)
200
+
201
+ # Predict original features
202
+ predictions = self.predictor(x) # (B, S, D)
203
+
204
+ # Compute loss only on masked positions
205
+ if masks.any():
206
+ pred_masked = predictions[masks] # (num_masked, D)
207
+ target_masked = slice_features[masks] # (num_masked, D)
208
+ loss = F.mse_loss(pred_masked, target_masked)
209
+ else:
210
+ loss = torch.tensor(0.0, device=slice_features.device)
211
+
212
+ return loss, predictions, masks
src/models/slice_attention.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Slice Attention module for ACL-LKNet.
3
+
4
+ Aggregates a variable-length sequence of slice features into a single
5
+ exam-level representation using learned attention weights.
6
+
7
+ Why attention over max-pool:
8
+ - Max-pool discards information about WHICH slices are informative
9
+ - Attention learns to weight slices showing pathology more heavily
10
+ - Attention weights are interpretable (can visualize which slices matter)
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+
18
+ class SliceAttention(nn.Module):
19
+ """
20
+ Attention-based aggregation over a sequence of slice features.
21
+
22
+ Given slice features [f_1, f_2, ..., f_S], computes:
23
+ α_i = softmax(w^T · tanh(W_1 · f_i + b_1))
24
+ v = Σ α_i · f_i
25
+
26
+ This is Bahdanau-style (additive) attention with a single attention head.
27
+ """
28
+
29
+ def __init__(self, feature_dim: int, hidden_dim: int = 256):
30
+ """
31
+ Args:
32
+ feature_dim: Dimension of input slice features
33
+ hidden_dim: Hidden dimension in attention computation
34
+ """
35
+ super().__init__()
36
+ self.attention = nn.Sequential(
37
+ nn.Linear(feature_dim, hidden_dim),
38
+ nn.Tanh(),
39
+ nn.Linear(hidden_dim, 1),
40
+ )
41
+
42
+ def forward(
43
+ self, features: torch.Tensor, mask: torch.Tensor = None
44
+ ) -> tuple:
45
+ """
46
+ Args:
47
+ features: (B, S, D) — batch of slice feature sequences
48
+ mask: (B, S) — optional boolean mask (True = valid slice)
49
+
50
+ Returns:
51
+ aggregated: (B, D) — attention-weighted feature vector
52
+ weights: (B, S) — attention weights (for visualization)
53
+ """
54
+ # Compute attention scores
55
+ scores = self.attention(features).squeeze(-1) # (B, S)
56
+
57
+ # Mask invalid slices (from padding)
58
+ if mask is not None:
59
+ scores = scores.masked_fill(~mask, float("-inf"))
60
+
61
+ # Softmax → attention weights
62
+ weights = F.softmax(scores, dim=1) # (B, S)
63
+
64
+ # Weighted sum
65
+ aggregated = torch.bmm(
66
+ weights.unsqueeze(1), features
67
+ ).squeeze(1) # (B, D)
68
+
69
+ return aggregated, weights
70
+
71
+
72
+ class MultiHeadSliceAttention(nn.Module):
73
+ """
74
+ Multi-head variant of slice attention for richer aggregation.
75
+
76
+ Each head learns to attend to different aspects of the slices
77
+ (e.g., one head for anatomy, another for pathology signal).
78
+ Outputs are concatenated and projected.
79
+ """
80
+
81
+ def __init__(self, feature_dim: int, num_heads: int = 4, hidden_dim: int = 256):
82
+ super().__init__()
83
+ assert feature_dim % num_heads == 0, "feature_dim must be divisible by num_heads"
84
+ self.num_heads = num_heads
85
+ self.head_dim = feature_dim // num_heads
86
+
87
+ self.heads = nn.ModuleList([
88
+ SliceAttention(feature_dim, hidden_dim)
89
+ for _ in range(num_heads)
90
+ ])
91
+ self.projection = nn.Linear(feature_dim * num_heads, feature_dim)
92
+
93
+ def forward(
94
+ self, features: torch.Tensor, mask: torch.Tensor = None
95
+ ) -> tuple:
96
+ """
97
+ Args:
98
+ features: (B, S, D)
99
+ mask: (B, S)
100
+ Returns:
101
+ aggregated: (B, D)
102
+ weights: (B, num_heads, S) — per-head attention weights
103
+ """
104
+ head_outputs = []
105
+ all_weights = []
106
+
107
+ for head in self.heads:
108
+ out, w = head(features, mask)
109
+ head_outputs.append(out)
110
+ all_weights.append(w)
111
+
112
+ # Concat heads and project
113
+ concatenated = torch.cat(head_outputs, dim=-1) # (B, D * num_heads)
114
+ aggregated = self.projection(concatenated) # (B, D)
115
+ weights = torch.stack(all_weights, dim=1) # (B, num_heads, S)
116
+
117
+ return aggregated, weights
src/train.py ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training loops for ACL-LKNet.
3
+
4
+ Two phases:
5
+ Phase 1: SSL Pretraining (Masked Slice Modeling)
6
+ - Train backbone to reconstruct masked slice features
7
+ - Monitor pretext loss, stop when plateaus
8
+
9
+ Phase 2: Supervised Fine-tuning
10
+ - Load SSL-pretrained backbone
11
+ - Train full model with differential LR
12
+ - Weighted BCE loss for class imbalance
13
+ - Early stopping on validation AUROC
14
+
15
+ Both phases support:
16
+ - Mixed precision (FP16) for T4 memory
17
+ - Gradient accumulation for effective batch size
18
+ - Gradient clipping for stability
19
+ - EMA model for better generalization
20
+ - Full checkpoint save/load for Colab session recovery
21
+ """
22
+
23
+ import os
24
+ import copy
25
+ import time
26
+ import math
27
+ import logging
28
+ from typing import Optional, Dict, Tuple, Any, List
29
+
30
+ import numpy as np
31
+ import torch
32
+ import torch.nn as nn
33
+ from torch.utils.data import DataLoader
34
+ from tqdm import tqdm
35
+
36
+ from .config import Config
37
+ from .models.acl_lknet import ACLLKNet, create_model_from_config
38
+ from .models.msm import MaskedSliceModeling
39
+ from .dataset import create_dataloaders
40
+ from .utils import (
41
+ set_seed, EMAModel, save_checkpoint, load_checkpoint,
42
+ find_latest_checkpoint, setup_logging, format_metrics,
43
+ get_gpu_memory_info, clear_gpu_memory,
44
+ )
45
+ from .evaluate import compute_metrics
46
+
47
+
48
+ # ── Loss Functions ──────────────────────────────────────────────────
49
+
50
+ def create_loss_fn(config: Config, device: torch.device = None) -> nn.Module:
51
+ """Create weighted BCE loss with label smoothing.
52
+
53
+ Args:
54
+ config: Config with pos_weight setting
55
+ device: Target device for pos_weight tensor (avoids CPU/GPU mismatch)
56
+ """
57
+ pos_weight = torch.tensor([config.pos_weight], device=device)
58
+ return nn.BCEWithLogitsLoss(pos_weight=pos_weight)
59
+
60
+
61
+ def apply_label_smoothing(labels: torch.Tensor, smoothing: float = 0.05) -> torch.Tensor:
62
+ """Apply label smoothing: 0 → smoothing, 1 → 1-smoothing."""
63
+ return labels * (1 - smoothing) + 0.5 * smoothing
64
+
65
+
66
+ def apply_mixup(
67
+ batch: dict, alpha: float = 0.2
68
+ ) -> Tuple[dict, torch.Tensor, torch.Tensor, float]:
69
+ """
70
+ Apply Mixup augmentation to a batch.
71
+
72
+ Returns modified batch, original labels, shuffled labels, and lambda.
73
+ """
74
+ if alpha <= 0:
75
+ return batch, batch["label"], batch["label"], 1.0
76
+
77
+ lam = np.random.beta(alpha, alpha)
78
+ lam = max(lam, 1 - lam) # Ensure lam >= 0.5
79
+
80
+ B = batch["sagittal"].shape[0]
81
+ if B < 2:
82
+ return batch, batch["label"], batch["label"], 1.0
83
+
84
+ indices = torch.randperm(B)
85
+
86
+ mixed_batch = {}
87
+ for key in ["sagittal", "coronal", "axial"]:
88
+ mixed_batch[key] = lam * batch[key] + (1 - lam) * batch[key][indices]
89
+ for key in ["sag_mask", "cor_mask", "axi_mask"]:
90
+ mixed_batch[key] = batch[key]
91
+ mixed_batch["label"] = batch["label"]
92
+ mixed_batch["case_id"] = batch["case_id"]
93
+
94
+ return mixed_batch, batch["label"], batch["label"][indices], lam
95
+
96
+
97
+ # ── Phase 1: SSL Pretraining ──────────────────────────────────────
98
+
99
+ def pretrain_msm(config: Config) -> str:
100
+ """
101
+ Self-supervised pretraining via Masked Slice Modeling.
102
+
103
+ Returns path to the best checkpoint.
104
+ """
105
+ setup_logging(config.log_dir)
106
+ set_seed(config.seed)
107
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
108
+ logging.info(f"MSM Pretraining | Device: {device} | Strategy: {config.mask_strategy}")
109
+ logging.info(f"GPU: {get_gpu_memory_info()}")
110
+
111
+ # Data
112
+ train_loader, val_loader = create_dataloaders(config, ssl=True)
113
+ logging.info(f"Train: {len(train_loader.dataset)} exams | Val: {len(val_loader.dataset)} exams")
114
+
115
+ # Model
116
+ model = create_model_from_config(config).to(device)
117
+ msm = MaskedSliceModeling(
118
+ feature_dim=model.feature_dim,
119
+ decoder_dim=config.msm_decoder_dim,
120
+ decoder_layers=config.msm_decoder_layers,
121
+ decoder_heads=config.msm_decoder_heads,
122
+ max_slices=config.max_slices,
123
+ mask_ratio=config.mask_ratio,
124
+ mask_strategy=config.mask_strategy,
125
+ ).to(device)
126
+
127
+ # Optimizer
128
+ params = list(model.parameters()) + list(msm.parameters())
129
+ optimizer = torch.optim.AdamW(params, lr=config.ssl_lr, weight_decay=config.ssl_weight_decay)
130
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)
131
+ scaler = torch.amp.GradScaler("cuda", enabled=config.use_amp)
132
+
133
+ # Checkpoint recovery
134
+ start_epoch = 0
135
+ best_loss = float("inf")
136
+ train_history = []
137
+ val_history = []
138
+ patience_counter = 0
139
+
140
+ ckpt_path = find_latest_checkpoint(config.checkpoint_dir, phase="ssl")
141
+ if ckpt_path:
142
+ logging.info(f"Resuming SSL from checkpoint: {ckpt_path}")
143
+ ckpt = load_checkpoint(ckpt_path, model, optimizer, scheduler, scaler)
144
+ start_epoch = ckpt["epoch"] + 1
145
+ best_loss = ckpt["best_metric"]
146
+ train_history = ckpt.get("train_history", [])
147
+ val_history = ckpt.get("val_history", [])
148
+ patience_counter = ckpt.get("patience_counter", 0)
149
+ # Load MSM state if saved
150
+ if "msm_state_dict" in ckpt:
151
+ msm.load_state_dict(ckpt["msm_state_dict"])
152
+
153
+ best_ckpt_path = os.path.join(config.checkpoint_dir, "ssl_best.pt")
154
+
155
+ # Training loop
156
+ epoch = start_epoch
157
+ while True: # No fixed epoch count — stop when loss plateaus
158
+ model.train()
159
+ msm.train()
160
+ epoch_loss = 0.0
161
+ num_batches = 0
162
+
163
+ pbar = tqdm(train_loader, desc=f"SSL Epoch {epoch}", leave=False)
164
+ optimizer.zero_grad()
165
+
166
+ for step, batch in enumerate(pbar):
167
+ sag = batch["sagittal"].to(device)
168
+ cor = batch["coronal"].to(device)
169
+ axi = batch["axial"].to(device)
170
+
171
+ with torch.amp.autocast("cuda", enabled=config.use_amp):
172
+ # Extract features
173
+ feats = model.get_slice_features(sag, cor, axi)
174
+
175
+ # MSM on each view independently
176
+ total_loss = 0.0
177
+ for view_name in ["sagittal", "coronal", "axial"]:
178
+ loss, _, _ = msm(feats[view_name])
179
+ total_loss = total_loss + loss
180
+ total_loss = total_loss / 3.0 # Average over views
181
+
182
+ # Gradient accumulation
183
+ total_loss = total_loss / config.accumulation_steps
184
+
185
+ scaler.scale(total_loss).backward()
186
+
187
+ if (step + 1) % config.accumulation_steps == 0:
188
+ scaler.unscale_(optimizer)
189
+ torch.nn.utils.clip_grad_norm_(params, config.gradient_clip)
190
+ scaler.step(optimizer)
191
+ scaler.update()
192
+ optimizer.zero_grad()
193
+
194
+ epoch_loss += total_loss.item() * config.accumulation_steps
195
+ num_batches += 1
196
+ pbar.set_postfix(loss=f"{total_loss.item() * config.accumulation_steps:.4f}")
197
+
198
+ avg_loss = epoch_loss / max(num_batches, 1)
199
+ scheduler.step()
200
+ train_history.append({"epoch": epoch, "loss": avg_loss})
201
+
202
+ # Validation
203
+ val_loss = _validate_msm(model, msm, val_loader, config, device)
204
+ val_history.append({"epoch": epoch, "loss": val_loss})
205
+
206
+ logging.info(
207
+ f"SSL Epoch {epoch} | Train Loss: {avg_loss:.4f} | Val Loss: {val_loss:.4f} | "
208
+ f"LR: {optimizer.param_groups[0]['lr']:.2e}"
209
+ )
210
+
211
+ # Check improvement
212
+ if val_loss < best_loss:
213
+ best_loss = val_loss
214
+ patience_counter = 0
215
+ # Save best
216
+ _save_ssl_checkpoint(
217
+ best_ckpt_path, epoch, model, msm, optimizer, scheduler,
218
+ scaler, best_loss, train_history, val_history, patience_counter, config,
219
+ )
220
+ logging.info(f" ✓ New best SSL loss: {best_loss:.4f}")
221
+ else:
222
+ patience_counter += 1
223
+ logging.info(f" ✗ No improvement ({patience_counter}/{config.ssl_patience})")
224
+
225
+ # Periodic save
226
+ if (epoch + 1) % config.ssl_save_every == 0:
227
+ periodic_path = os.path.join(config.checkpoint_dir, f"ssl_epoch{epoch}.pt")
228
+ _save_ssl_checkpoint(
229
+ periodic_path, epoch, model, msm, optimizer, scheduler,
230
+ scaler, best_loss, train_history, val_history, patience_counter, config,
231
+ )
232
+
233
+ # Early stopping
234
+ if patience_counter >= config.ssl_patience:
235
+ logging.info(f"SSL early stopping at epoch {epoch}")
236
+ break
237
+
238
+ epoch += 1
239
+ clear_gpu_memory()
240
+
241
+ logging.info(f"SSL pretraining complete. Best loss: {best_loss:.4f}")
242
+ return best_ckpt_path
243
+
244
+
245
+ def _validate_msm(model, msm, val_loader, config, device) -> float:
246
+ """Run MSM validation pass."""
247
+ model.eval()
248
+ msm.eval()
249
+ total_loss = 0.0
250
+ count = 0
251
+
252
+ with torch.no_grad():
253
+ for batch in val_loader:
254
+ sag = batch["sagittal"].to(device)
255
+ cor = batch["coronal"].to(device)
256
+ axi = batch["axial"].to(device)
257
+
258
+ with torch.amp.autocast("cuda", enabled=config.use_amp):
259
+ feats = model.get_slice_features(sag, cor, axi)
260
+ loss = 0.0
261
+ for view_name in ["sagittal", "coronal", "axial"]:
262
+ l, _, _ = msm(feats[view_name])
263
+ loss = loss + l.item()
264
+ loss /= 3.0
265
+
266
+ total_loss += loss
267
+ count += 1
268
+
269
+ return total_loss / max(count, 1)
270
+
271
+
272
+ def _save_ssl_checkpoint(path, epoch, model, msm, optimizer, scheduler, scaler,
273
+ best_loss, train_history, val_history, patience_counter, config):
274
+ """Save SSL checkpoint including MSM state."""
275
+ os.makedirs(os.path.dirname(path), exist_ok=True)
276
+ from .utils import get_rng_states
277
+ checkpoint = {
278
+ "epoch": epoch,
279
+ "phase": "ssl",
280
+ "model_state_dict": model.state_dict(),
281
+ "msm_state_dict": msm.state_dict(),
282
+ "optimizer_state_dict": optimizer.state_dict(),
283
+ "scheduler_state_dict": scheduler.state_dict(),
284
+ "scaler_state_dict": scaler.state_dict() if scaler else None,
285
+ "best_metric": best_loss,
286
+ "best_epoch": epoch,
287
+ "train_history": train_history,
288
+ "val_history": val_history,
289
+ "patience_counter": patience_counter,
290
+ "rng_states": get_rng_states(),
291
+ "config": config.to_dict(),
292
+ }
293
+ torch.save(checkpoint, path)
294
+
295
+
296
+ def train_supervised(
297
+ config: Config,
298
+ ssl_checkpoint: Optional[str] = None,
299
+ train_cases: Optional[list] = None,
300
+ val_cases: Optional[list] = None,
301
+ train_split: str = "train",
302
+ val_split: str = "valid",
303
+ ) -> str:
304
+ """
305
+ Supervised fine-tuning for ACL tear detection.
306
+
307
+ Args:
308
+ config: Training config
309
+ ssl_checkpoint: Path to SSL pretrained checkpoint (optional)
310
+ train_cases: Optional explicit list of training case IDs (for CV)
311
+ val_cases: Optional explicit list of validation case IDs (for CV)
312
+ train_split: Dataset split directory name for training
313
+ val_split: Dataset split directory name for validation
314
+
315
+ Returns:
316
+ Path to the best checkpoint (by val AUROC)
317
+ """
318
+ setup_logging(config.log_dir)
319
+ set_seed(config.seed)
320
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
321
+ logging.info(f"Supervised Training | Device: {device}")
322
+ logging.info(f"GPU: {get_gpu_memory_info()}")
323
+
324
+ # Data
325
+ train_loader, val_loader = create_dataloaders(
326
+ config, ssl=False, train_cases=train_cases, val_cases=val_cases,
327
+ train_split=train_split, val_split=val_split,
328
+ )
329
+ logging.info(f"Train: {len(train_loader.dataset)} exams | Val: {len(val_loader.dataset)} exams")
330
+
331
+ # Model
332
+ model = create_model_from_config(config).to(device)
333
+
334
+ # Load SSL pretrained weights
335
+ if ssl_checkpoint and os.path.exists(ssl_checkpoint):
336
+ ssl_ckpt = torch.load(ssl_checkpoint, map_location=device, weights_only=False)
337
+ model.load_state_dict(ssl_ckpt["model_state_dict"], strict=False)
338
+ logging.info(f"Loaded SSL checkpoint: {ssl_checkpoint}")
339
+
340
+
341
+ # Differential LR: lower for backbone, higher for new layers
342
+ backbone_params = list(model.backbone.parameters())
343
+ new_params = [p for n, p in model.named_parameters()
344
+ if not n.startswith("backbone")]
345
+
346
+ optimizer = torch.optim.AdamW([
347
+ {"params": backbone_params, "lr": config.backbone_lr},
348
+ {"params": new_params, "lr": config.lr},
349
+ ], weight_decay=config.weight_decay)
350
+
351
+ # Cosine Annealing scheduler (resume-safe — unlike OneCycleLR, does not
352
+ # crash when total_steps is exceeded after checkpoint restoration)
353
+ max_epochs = getattr(config, "num_epochs", 40)
354
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
355
+ optimizer,
356
+ T_max=max(max_epochs - config.warmup_epochs, 1),
357
+ eta_min=1e-7,
358
+ )
359
+
360
+ scaler = torch.amp.GradScaler("cuda", enabled=config.use_amp)
361
+ criterion = create_loss_fn(config, device=device)
362
+ ema = EMAModel(model, decay=config.ema_decay)
363
+
364
+ # Checkpoint recovery
365
+ start_epoch = 0
366
+ best_auc = 0.0
367
+ best_epoch = 0
368
+ train_history = []
369
+ val_history = []
370
+ patience_counter = 0
371
+
372
+ ckpt_path = find_latest_checkpoint(config.checkpoint_dir, phase="finetune")
373
+ if ckpt_path:
374
+ logging.info(f"Resuming supervised training from: {ckpt_path}")
375
+ ckpt = load_checkpoint(ckpt_path, model, optimizer, scheduler, scaler, ema)
376
+ start_epoch = ckpt["epoch"] + 1
377
+ best_auc = ckpt["best_metric"]
378
+ best_epoch = ckpt.get("best_epoch", 0)
379
+ train_history = ckpt.get("train_history", [])
380
+ val_history = ckpt.get("val_history", [])
381
+ patience_counter = ckpt.get("patience_counter", 0)
382
+
383
+ best_ckpt_path = os.path.join(config.checkpoint_dir, "finetune_best.pt")
384
+
385
+ # Training loop
386
+ epoch = start_epoch
387
+ while True: # Monitor-based stopping
388
+ model.train()
389
+ epoch_loss = 0.0
390
+ all_preds = []
391
+ all_labels = []
392
+ num_batches = 0
393
+
394
+ pbar = tqdm(train_loader, desc=f"Epoch {epoch}", leave=False)
395
+ optimizer.zero_grad()
396
+
397
+ for step, batch in enumerate(pbar):
398
+ sag = batch["sagittal"].to(device)
399
+ cor = batch["coronal"].to(device)
400
+ axi = batch["axial"].to(device)
401
+ sag_m = batch["sag_mask"].to(device)
402
+ cor_m = batch["cor_mask"].to(device)
403
+ axi_m = batch["axi_mask"].to(device)
404
+ labels = batch["label"].to(device)
405
+
406
+ # Mixup
407
+ if config.mixup_alpha > 0 and epoch >= config.warmup_epochs:
408
+ mixed, labels_a, labels_b, lam = apply_mixup(batch, config.mixup_alpha)
409
+ sag = mixed["sagittal"].to(device)
410
+ cor = mixed["coronal"].to(device)
411
+ axi = mixed["axial"].to(device)
412
+ labels_a = labels_a.to(device)
413
+ labels_b = labels_b.to(device)
414
+ else:
415
+ labels_a = labels_b = labels
416
+ lam = 1.0
417
+
418
+ with torch.amp.autocast("cuda", enabled=config.use_amp):
419
+ output = model(sag, cor, axi, sag_m, cor_m, axi_m)
420
+ logits = output["logits"].squeeze(-1)
421
+
422
+ # Label smoothing
423
+ smooth_a = apply_label_smoothing(labels_a, config.label_smoothing)
424
+ smooth_b = apply_label_smoothing(labels_b, config.label_smoothing)
425
+
426
+ # Mixup loss
427
+ loss = lam * criterion(logits, smooth_a) + (1 - lam) * criterion(logits, smooth_b)
428
+ loss = loss / config.accumulation_steps
429
+
430
+ scaler.scale(loss).backward()
431
+
432
+ if (step + 1) % config.accumulation_steps == 0:
433
+ scaler.unscale_(optimizer)
434
+ torch.nn.utils.clip_grad_norm_(model.parameters(), config.gradient_clip)
435
+ scaler.step(optimizer)
436
+ scaler.update()
437
+ optimizer.zero_grad()
438
+
439
+ # Update EMA
440
+ ema.update(model)
441
+
442
+ epoch_loss += loss.item() * config.accumulation_steps
443
+ all_preds.extend(output["probs"].squeeze(-1).detach().cpu().numpy().tolist())
444
+ all_labels.extend(labels.cpu().numpy().tolist())
445
+ num_batches += 1
446
+
447
+ pbar.set_postfix(loss=f"{loss.item() * config.accumulation_steps:.4f}")
448
+
449
+ # ENH-2: Flush any remaining accumulated gradients from the tail batch
450
+ # (when dataset size is not divisible by accumulation_steps)
451
+ if (step + 1) % config.accumulation_steps != 0:
452
+ scaler.unscale_(optimizer)
453
+ torch.nn.utils.clip_grad_norm_(model.parameters(), config.gradient_clip)
454
+ scaler.step(optimizer)
455
+ scaler.update()
456
+ optimizer.zero_grad()
457
+ ema.update(model)
458
+
459
+ # Step the cosine annealing scheduler once per epoch (after warmup)
460
+ if epoch >= config.warmup_epochs:
461
+ scheduler.step()
462
+ else:
463
+ # Linear warmup: scale LR from 0 to target over warmup_epochs
464
+ warmup_factor = (epoch + 1) / config.warmup_epochs
465
+ for pg_idx, pg in enumerate(optimizer.param_groups):
466
+ base_lr = config.backbone_lr if pg_idx == 0 else config.lr
467
+ pg['lr'] = base_lr * warmup_factor
468
+
469
+ avg_loss = epoch_loss / max(num_batches, 1)
470
+
471
+ # Train metrics
472
+ train_metrics = compute_metrics(
473
+ np.array(all_labels), np.array(all_preds), prefix="train"
474
+ )
475
+ train_metrics["train_loss"] = avg_loss
476
+ train_history.append({"epoch": epoch, **train_metrics})
477
+
478
+ # Validation (check online model and EMA shadow model, use best)
479
+ val_online = validate_supervised(model, val_loader, criterion, config, device)
480
+ val_ema = validate_supervised(ema.eval_model(), val_loader, criterion, config, device)
481
+ val_metrics = val_ema if val_ema["val_auroc"] >= val_online["val_auroc"] else val_online
482
+ val_history.append({"epoch": epoch, **val_metrics})
483
+
484
+ logging.info(
485
+ f"Ep{epoch} | Loss:{avg_loss:.4f} | "
486
+ f"Online_AUROC:{val_online['val_auroc']:.4f} | EMA_AUROC:{val_ema['val_auroc']:.4f} | "
487
+ f"Best_AUROC:{val_metrics['val_auroc']:.4f} | Acc:{val_metrics['val_accuracy']:.4f} | "
488
+ f"Sens:{val_metrics.get('val_sensitivity', 0):.4f} | "
489
+ f"LR:{optimizer.param_groups[0]['lr']:.2e}"
490
+ )
491
+
492
+ # Check improvement
493
+ current_auc = val_metrics["val_auroc"]
494
+ if current_auc > best_auc:
495
+ best_auc = current_auc
496
+ best_epoch = epoch
497
+ patience_counter = 0
498
+ save_checkpoint(
499
+ best_ckpt_path, epoch, "finetune", model, optimizer, scheduler,
500
+ scaler, ema, best_auc, best_epoch, train_history, val_history,
501
+ patience_counter, config,
502
+ )
503
+ logging.info(f" ✓ New best AUROC: {best_auc:.4f}")
504
+ else:
505
+ patience_counter += 1
506
+ logging.info(f" ✗ No improvement ({patience_counter}/{config.patience})")
507
+
508
+ # Periodic save
509
+ if (epoch + 1) % config.save_every == 0:
510
+ periodic_path = os.path.join(config.checkpoint_dir, f"finetune_epoch{epoch}.pt")
511
+ save_checkpoint(
512
+ periodic_path, epoch, "finetune", model, optimizer, scheduler,
513
+ scaler, ema, best_auc, best_epoch, train_history, val_history,
514
+ patience_counter, config,
515
+ )
516
+
517
+ # Early stopping
518
+ if patience_counter >= config.patience:
519
+ logging.info(f"Early stopping at epoch {epoch}. Best AUROC: {best_auc:.4f} at epoch {best_epoch}")
520
+ break
521
+
522
+ epoch += 1
523
+ clear_gpu_memory()
524
+
525
+ logging.info(f"Training complete. Best AUROC: {best_auc:.4f} at epoch {best_epoch}")
526
+ return best_ckpt_path
527
+
528
+
529
+ def validate_supervised(
530
+ model: nn.Module,
531
+ val_loader: DataLoader,
532
+ criterion: nn.Module,
533
+ config: Config,
534
+ device: torch.device,
535
+ ) -> Dict[str, float]:
536
+ """Run validation and compute metrics."""
537
+ model.eval()
538
+ all_preds = []
539
+ all_labels = []
540
+ total_loss = 0.0
541
+ count = 0
542
+
543
+ with torch.no_grad():
544
+ for batch in val_loader:
545
+ sag = batch["sagittal"].to(device)
546
+ cor = batch["coronal"].to(device)
547
+ axi = batch["axial"].to(device)
548
+ sag_m = batch["sag_mask"].to(device)
549
+ cor_m = batch["cor_mask"].to(device)
550
+ axi_m = batch["axi_mask"].to(device)
551
+ labels = batch["label"].to(device)
552
+
553
+ with torch.amp.autocast("cuda", enabled=config.use_amp):
554
+ output = model(sag, cor, axi, sag_m, cor_m, axi_m)
555
+ logits = output["logits"].squeeze(-1)
556
+ loss = criterion(logits, labels)
557
+
558
+ total_loss += loss.item()
559
+ all_preds.extend(output["probs"].squeeze(-1).cpu().numpy().tolist())
560
+ all_labels.extend(labels.cpu().numpy().tolist())
561
+ count += 1
562
+
563
+ metrics = compute_metrics(np.array(all_labels), np.array(all_preds), prefix="val")
564
+ metrics["val_loss"] = total_loss / max(count, 1)
565
+ return metrics
566
+
567
+
568
+ # ── 5-Fold Cross-Validation Protocol ───────────────────────────────
569
+
570
+ def train_5fold_cross_validation(
571
+ config: Config,
572
+ ssl_checkpoint: Optional[str] = None,
573
+ task: str = "acl",
574
+ ) -> Dict[str, Any]:
575
+ """
576
+ Execute patient-stratified 5-Fold Cross-Validation for ACL-LKNet.
577
+
578
+ Partitions the dataset into 5 balanced folds, trains a separate model on each fold,
579
+ evaluates out-of-fold predictions, and reports Mean ± Std across folds for all
580
+ academic metrics (AUROC, Accuracy, Sensitivity, Specificity, F1, MCC).
581
+
582
+ Returns:
583
+ Dict with fold-by-fold results, aggregated Mean ± Std, and out-of-fold metrics.
584
+ """
585
+ from .dataset import get_stratified_folds
586
+ import json
587
+
588
+ setup_logging(config.log_dir)
589
+ logging.info(f"=== Starting Stratified {config.n_splits}-Fold Cross-Validation ===")
590
+
591
+ folds = get_stratified_folds(
592
+ config.data_dir, split="train", n_splits=config.n_splits, seed=config.seed, task=task
593
+ )
594
+
595
+ base_exp_name = config.experiment_name
596
+ fold_summaries = []
597
+ all_oof_labels = []
598
+ all_oof_preds = []
599
+
600
+ for fold_info in folds:
601
+ fold_idx = fold_info["fold"]
602
+ logging.info(f"\n--- Running Fold {fold_idx + 1} / {config.n_splits} ---")
603
+ logging.info(f"Train Cases: {len(fold_info['train_cases'])} | Val Cases: {len(fold_info['val_cases'])}")
604
+
605
+ # Create fold-specific configuration
606
+ fold_config = copy.deepcopy(config)
607
+ fold_config.experiment_name = f"{base_exp_name}_fold{fold_idx}"
608
+ fold_config.__post_init__()
609
+ os.makedirs(fold_config.checkpoint_dir, exist_ok=True)
610
+ os.makedirs(fold_config.log_dir, exist_ok=True)
611
+
612
+ # Train supervised on this fold
613
+ best_ckpt = train_supervised(
614
+ fold_config,
615
+ ssl_checkpoint=ssl_checkpoint,
616
+ train_cases=fold_info["train_cases"],
617
+ val_cases=fold_info["val_cases"],
618
+ train_split="train",
619
+ val_split="train",
620
+ )
621
+
622
+ # Evaluate best model on fold validation set
623
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
624
+ model = create_model_from_config(fold_config).to(device)
625
+ load_checkpoint(best_ckpt, model)
626
+ model.eval()
627
+
628
+ _, fold_val_loader = create_dataloaders(
629
+ fold_config, ssl=False,
630
+ val_cases=fold_info["val_cases"],
631
+ val_split="train",
632
+ )
633
+
634
+ fold_preds = []
635
+ fold_labels = []
636
+ with torch.no_grad():
637
+ for batch in fold_val_loader:
638
+ sag = batch["sagittal"].to(device)
639
+ cor = batch["coronal"].to(device)
640
+ axi = batch["axial"].to(device)
641
+ sag_m = batch["sag_mask"].to(device)
642
+ cor_m = batch["cor_mask"].to(device)
643
+ axi_m = batch["axi_mask"].to(device)
644
+
645
+ with torch.amp.autocast("cuda", enabled=fold_config.use_amp and torch.cuda.is_available()):
646
+ out = model(sag, cor, axi, sag_m, cor_m, axi_m)
647
+
648
+ fold_preds.extend(out["probs"].squeeze(-1).cpu().numpy().tolist())
649
+ fold_labels.extend(batch["label"].numpy().tolist())
650
+
651
+ fold_m = compute_metrics(np.array(fold_labels), np.array(fold_preds))
652
+ fold_summaries.append({
653
+ "fold": fold_idx + 1,
654
+ "best_checkpoint": best_ckpt,
655
+ "accuracy": fold_m["accuracy"],
656
+ "balanced_accuracy": fold_m["balanced_accuracy"],
657
+ "auroc": fold_m["auroc"],
658
+ "auprc": fold_m["auprc"],
659
+ "sensitivity": fold_m["sensitivity"],
660
+ "specificity": fold_m["specificity"],
661
+ "f1": fold_m["f1"],
662
+ "mcc": fold_m["mcc"],
663
+ })
664
+
665
+ all_oof_labels.extend(fold_labels)
666
+ all_oof_preds.extend(fold_preds)
667
+
668
+ # Compute Mean and Standard Deviation across folds
669
+ metrics_to_agg = ["accuracy", "balanced_accuracy", "auroc", "auprc", "sensitivity", "specificity", "f1", "mcc"]
670
+ aggregated = {}
671
+ for m in metrics_to_agg:
672
+ vals = [f[m] for f in fold_summaries]
673
+ aggregated[m] = {
674
+ "mean": float(np.mean(vals)),
675
+ "std": float(np.std(vals)),
676
+ "formatted": f"{np.mean(vals):.4f} ± {np.std(vals):.4f}",
677
+ }
678
+
679
+ # Compute Pooled Out-Of-Fold (OOF) metrics
680
+ oof_y = np.array(all_oof_labels)
681
+ oof_p = np.array(all_oof_preds)
682
+ oof_metrics = compute_metrics(oof_y, oof_p)
683
+
684
+ cv_results = {
685
+ "n_splits": config.n_splits,
686
+ "fold_results": fold_summaries,
687
+ "mean_std_summary": aggregated,
688
+ "pooled_oof_metrics": oof_metrics,
689
+ }
690
+
691
+ # Log and Save CV Summary Report
692
+ logging.info("\n" + "=" * 75)
693
+ logging.info(f"=== {config.n_splits}-FOLD CROSS-VALIDATION SUMMARY RESULTS ===")
694
+ logging.info("-" * 75)
695
+ logging.info(f"{'Metric':<25} {'Mean ± Std Across Folds':<30} {'Pooled OOF':<15}")
696
+ logging.info("-" * 75)
697
+ for m in metrics_to_agg:
698
+ logging.info(f"{m:<25} {aggregated[m]['formatted']:<30} {oof_metrics[m]:.4f}")
699
+ logging.info("=" * 75 + "\n")
700
+
701
+ results_dir = os.path.join(config.drive_dir, "results")
702
+ os.makedirs(results_dir, exist_ok=True)
703
+ with open(os.path.join(results_dir, "5fold_cv_summary.json"), "w") as f:
704
+ json.dump(cv_results, f, indent=2)
705
+
706
+ # Export formatted Markdown summary table
707
+ md_lines = [
708
+ f"# {config.n_splits}-Fold Stratified Cross-Validation Results",
709
+ "",
710
+ f"**Model Backbone:** `{config.backbone}` | **Dataset:** MRNet ACL | **Folds:** {config.n_splits}",
711
+ "",
712
+ "| Metric | Mean ± Std Across Folds | Pooled Out-of-Fold | Fold 1 | Fold 2 | Fold 3 | Fold 4 | Fold 5 |",
713
+ "| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |",
714
+ ]
715
+ for m in metrics_to_agg:
716
+ f_vals = " | ".join([f"{f[m]:.4f}" for f in fold_summaries])
717
+ md_lines.append(f"| **{m.replace('_', ' ').title()}** | `{aggregated[m]['formatted']}` | `{oof_metrics[m]:.4f}` | {f_vals} |")
718
+
719
+ with open(os.path.join(results_dir, "5fold_cv_summary.md"), "w") as f:
720
+ f.write("\n".join(md_lines) + "\n")
721
+
722
+ return cv_results
723
+
src/utils.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions for ACL-LKNet.
3
+
4
+ Includes: reproducibility seeding, EMA model, checkpoint save/load,
5
+ logging helpers, and Google Drive integration.
6
+ """
7
+
8
+ import os
9
+ import copy
10
+ import random
11
+ import logging
12
+ from typing import Dict, Any, Optional
13
+
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn as nn
17
+
18
+
19
+ # ── Reproducibility ─────────────────────────────────────────────────
20
+
21
+ def set_seed(seed: int = 42):
22
+ """Set all random seeds for reproducibility."""
23
+ random.seed(seed)
24
+ np.random.seed(seed)
25
+ torch.manual_seed(seed)
26
+ torch.cuda.manual_seed_all(seed)
27
+ torch.backends.cudnn.deterministic = True
28
+ torch.backends.cudnn.benchmark = False
29
+ os.environ["PYTHONHASHSEED"] = str(seed)
30
+
31
+
32
+ def get_rng_states() -> Dict[str, Any]:
33
+ """Capture all RNG states for exact checkpoint reproducibility."""
34
+ states = {
35
+ "python": random.getstate(),
36
+ "numpy": np.random.get_state(),
37
+ "torch": torch.get_rng_state(),
38
+ }
39
+ if torch.cuda.is_available():
40
+ states["cuda"] = torch.cuda.get_rng_state_all()
41
+ return states
42
+
43
+
44
+ def set_rng_states(states: Dict[str, Any]):
45
+ """Restore RNG states from checkpoint."""
46
+ random.setstate(states["python"])
47
+ np.random.set_state(states["numpy"])
48
+ torch.set_rng_state(states["torch"])
49
+ if "cuda" in states and torch.cuda.is_available():
50
+ torch.cuda.set_rng_state_all(states["cuda"])
51
+
52
+
53
+ # ── Exponential Moving Average ──────────────────────────────────────
54
+
55
+ class EMAModel:
56
+ """
57
+ Exponential Moving Average of model parameters.
58
+
59
+ Maintains a shadow copy of model weights that is updated as:
60
+ shadow = decay * shadow + (1 - decay) * current
61
+
62
+ Use the EMA model for evaluation — it typically generalizes better.
63
+ """
64
+
65
+ def __init__(self, model: nn.Module, decay: float = 0.999):
66
+ self.decay = decay
67
+ self.shadow = copy.deepcopy(model)
68
+ self.shadow.eval()
69
+ for p in self.shadow.parameters():
70
+ p.requires_grad_(False)
71
+
72
+ @torch.no_grad()
73
+ def update(self, model: nn.Module):
74
+ """Update shadow weights with current model weights and buffers."""
75
+ for s_param, m_param in zip(self.shadow.parameters(), model.parameters()):
76
+ s_param.data.mul_(self.decay).add_(m_param.data, alpha=1.0 - self.decay)
77
+ # Sync BatchNorm running statistics (buffers are not EMA-averaged,
78
+ # they should directly mirror the training model's batch statistics)
79
+ for s_buf, m_buf in zip(self.shadow.buffers(), model.buffers()):
80
+ s_buf.data.copy_(m_buf.data)
81
+
82
+ def state_dict(self):
83
+ return self.shadow.state_dict()
84
+
85
+ def load_state_dict(self, state_dict):
86
+ self.shadow.load_state_dict(state_dict)
87
+
88
+ def eval_model(self) -> nn.Module:
89
+ """Return the shadow model for evaluation."""
90
+ return self.shadow
91
+
92
+
93
+ # ── Checkpoint Management ───────────────────────────────────────────
94
+
95
+ def save_checkpoint(
96
+ path: str,
97
+ epoch: int,
98
+ phase: str,
99
+ model: nn.Module,
100
+ optimizer: torch.optim.Optimizer,
101
+ scheduler: Any,
102
+ scaler: Optional[torch.amp.GradScaler],
103
+ ema: Optional[EMAModel],
104
+ best_metric: float,
105
+ best_epoch: int,
106
+ train_history: list,
107
+ val_history: list,
108
+ patience_counter: int,
109
+ config: Any,
110
+ ):
111
+ """
112
+ Save a full training checkpoint to Google Drive.
113
+
114
+ Captures everything needed to resume training exactly:
115
+ model, optimizer, scheduler, AMP scaler, EMA, RNG states, histories.
116
+ """
117
+ os.makedirs(os.path.dirname(path), exist_ok=True)
118
+
119
+ checkpoint = {
120
+ "epoch": epoch,
121
+ "phase": phase,
122
+ "model_state_dict": model.state_dict(),
123
+ "optimizer_state_dict": optimizer.state_dict(),
124
+ "scheduler_state_dict": scheduler.state_dict() if scheduler else None,
125
+ "scaler_state_dict": scaler.state_dict() if scaler else None,
126
+ "ema_state_dict": ema.state_dict() if ema else None,
127
+ "best_metric": best_metric,
128
+ "best_epoch": best_epoch,
129
+ "train_history": train_history,
130
+ "val_history": val_history,
131
+ "patience_counter": patience_counter,
132
+ "rng_states": get_rng_states(),
133
+ "config": config.to_dict() if hasattr(config, "to_dict") else str(config),
134
+ }
135
+ torch.save(checkpoint, path)
136
+ logging.info(f"Checkpoint saved: {path}")
137
+
138
+
139
+ def load_checkpoint(
140
+ path: str,
141
+ model: nn.Module,
142
+ optimizer: Optional[torch.optim.Optimizer] = None,
143
+ scheduler: Any = None,
144
+ scaler: Optional[torch.amp.GradScaler] = None,
145
+ ema: Optional[EMAModel] = None,
146
+ ) -> Dict[str, Any]:
147
+ """
148
+ Load a checkpoint and restore all training state.
149
+
150
+ Returns the checkpoint dict for extracting histories, epoch, etc.
151
+ """
152
+ checkpoint = torch.load(path, map_location="cpu", weights_only=False)
153
+
154
+ model.load_state_dict(checkpoint["model_state_dict"])
155
+ if optimizer and "optimizer_state_dict" in checkpoint:
156
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
157
+ if scheduler and checkpoint.get("scheduler_state_dict"):
158
+ scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
159
+ if scaler and checkpoint.get("scaler_state_dict"):
160
+ scaler.load_state_dict(checkpoint["scaler_state_dict"])
161
+ if ema and checkpoint.get("ema_state_dict"):
162
+ ema.load_state_dict(checkpoint["ema_state_dict"])
163
+ if "rng_states" in checkpoint:
164
+ set_rng_states(checkpoint["rng_states"])
165
+
166
+ logging.info(f"Checkpoint loaded: {path} (epoch {checkpoint['epoch']})")
167
+ return checkpoint
168
+
169
+
170
+ def find_latest_checkpoint(checkpoint_dir: str, phase: str = "finetune") -> Optional[str]:
171
+ """Find the latest checkpoint file in the checkpoint directory."""
172
+ if not os.path.exists(checkpoint_dir):
173
+ return None
174
+
175
+ checkpoints = [
176
+ f for f in os.listdir(checkpoint_dir)
177
+ if f.startswith(f"{phase}_") and f.endswith(".pt")
178
+ ]
179
+ if not checkpoints:
180
+ return None
181
+
182
+ # Sort by epoch number
183
+ def extract_epoch(fname):
184
+ try:
185
+ parts = fname.replace(".pt", "").split("_epoch")
186
+ return int(parts[-1])
187
+ except (ValueError, IndexError):
188
+ return -1
189
+
190
+ checkpoints.sort(key=extract_epoch)
191
+ return os.path.join(checkpoint_dir, checkpoints[-1])
192
+
193
+
194
+ # ── Logging ─────────────────────────────────────────────────────────
195
+
196
+ def setup_logging(log_dir: Optional[str] = None, level=logging.INFO):
197
+ """Configure logging to console and optionally to file."""
198
+ handlers = [logging.StreamHandler()]
199
+ if log_dir:
200
+ os.makedirs(log_dir, exist_ok=True)
201
+ handlers.append(logging.FileHandler(os.path.join(log_dir, "training.log")))
202
+
203
+ logging.basicConfig(
204
+ level=level,
205
+ format="%(asctime)s [%(levelname)s] %(message)s",
206
+ datefmt="%Y-%m-%d %H:%M:%S",
207
+ handlers=handlers,
208
+ force=True,
209
+ )
210
+
211
+
212
+ # ── Metrics Formatting ─────────────────────────────────────────────
213
+
214
+ def format_metrics(metrics: Dict[str, float]) -> str:
215
+ """Format a metrics dict into a readable string."""
216
+ parts = []
217
+ for k, v in metrics.items():
218
+ if isinstance(v, float):
219
+ parts.append(f"{k}: {v:.4f}")
220
+ else:
221
+ parts.append(f"{k}: {v}")
222
+ return " | ".join(parts)
223
+
224
+
225
+ # ── Memory Utils ────────────────────────────────────────────────────
226
+
227
+ def get_gpu_memory_info() -> Dict[str, float]:
228
+ """Get GPU memory usage in MB."""
229
+ if not torch.cuda.is_available():
230
+ return {"allocated_mb": 0, "reserved_mb": 0, "total_mb": 0}
231
+ try:
232
+ props = torch.cuda.get_device_properties(0)
233
+ total = getattr(props, "total_memory", getattr(props, "total_mem", 0)) / 1024**2
234
+ return {
235
+ "allocated_mb": torch.cuda.memory_allocated() / 1024**2,
236
+ "reserved_mb": torch.cuda.memory_reserved() / 1024**2,
237
+ "total_mb": total,
238
+ }
239
+ except Exception:
240
+ return {"allocated_mb": 0, "reserved_mb": 0, "total_mb": 0}
241
+
242
+
243
+ def clear_gpu_memory():
244
+ """Force GPU memory cleanup."""
245
+ if torch.cuda.is_available():
246
+ torch.cuda.empty_cache()
247
+ torch.cuda.synchronize()
train_cv.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ACL-LKNet Training CLI
4
+ ======================
5
+ Standalone command-line entrypoint for training ACL-LKNet models.
6
+ Supports single-fold training, full 5-fold stratified cross-validation,
7
+ and Phase 1 self-supervised pretraining (Masked Slice Modeling).
8
+
9
+ Usage Examples:
10
+ # Train Fold 1 with default configuration:
11
+ python train_cv.py --data_dir /path/to/mrnet --fold 1
12
+
13
+ # Train all 5 folds sequentially for cross-validation:
14
+ python train_cv.py --data_dir /path/to/mrnet --cv
15
+
16
+ # Run Phase 1 Masked Slice Modeling (SSL) pretraining:
17
+ python train_cv.py --data_dir /path/to/mrnet --ssl_pretrain
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import argparse
23
+ import logging
24
+ import torch
25
+
26
+ # Ensure local package imports work seamlessly
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+
29
+ from src.config import Config
30
+ from src.train import train_supervised, train_ssl, train_5fold_cross_validation
31
+ from src.utils import set_seed, setup_logging
32
+
33
+
34
+ def parse_args():
35
+ parser = argparse.ArgumentParser(
36
+ description="Train ACL-LKNet (Phase 1 SSL Pretraining or Phase 2 Supervised Fine-Tuning)."
37
+ )
38
+ parser.add_argument(
39
+ "--data_dir", type=str, default="./data/mrnet",
40
+ help="Path to Stanford MRNet dataset root directory (containing train, valid, and test folders)."
41
+ )
42
+ parser.add_argument(
43
+ "--output_dir", type=str, default="./checkpoints",
44
+ help="Directory where model checkpoints and logs will be saved."
45
+ )
46
+ parser.add_argument(
47
+ "--backbone", type=str, default="convnext_tiny",
48
+ choices=["convnext_tiny", "resnet18", "replknet"],
49
+ help="Backbone architecture (ConvNeXt-Tiny recommended for large receptive field)."
50
+ )
51
+ parser.add_argument(
52
+ "--fold", type=int, default=1, choices=[1, 2, 3, 4, 5],
53
+ help="Fold index to train (1-5) when not running full cross-validation."
54
+ )
55
+ parser.add_argument(
56
+ "--cv", action="store_true",
57
+ help="Run complete 5-fold stratified cross-validation."
58
+ )
59
+ parser.add_argument(
60
+ "--ssl_pretrain", action="store_true",
61
+ help="Run Phase 1 Masked Slice Modeling (MSM) self-supervised pretraining."
62
+ )
63
+ parser.add_argument(
64
+ "--ssl_checkpoint", type=str, default=None,
65
+ help="Optional path to SSL-pretrained backbone checkpoint to initialize Phase 2 training."
66
+ )
67
+ parser.add_argument(
68
+ "--epochs", type=int, default=30,
69
+ help="Maximum training epochs (monitored by early stopping patience=20)."
70
+ )
71
+ parser.add_argument(
72
+ "--lr_backbone", type=float, default=1.0e-5,
73
+ help="Differential learning rate for pretrained backbone."
74
+ )
75
+ parser.add_argument(
76
+ "--lr_head", type=float, default=3.0e-4,
77
+ help="Learning rate for newly initialized attention and classification heads."
78
+ )
79
+ parser.add_argument(
80
+ "--batch_size", type=int, default=1,
81
+ help="Physical batch size (keep = 1 on 15-16 GB GPUs to prevent OOM)."
82
+ )
83
+ parser.add_argument(
84
+ "--accum_steps", type=int, default=8,
85
+ help="Gradient accumulation steps to achieve effective batch size = 8."
86
+ )
87
+ parser.add_argument(
88
+ "--device", type=str, default="cuda" if torch.cuda.is_available() else "cpu",
89
+ help="Compute device ('cuda' or 'cpu')."
90
+ )
91
+ parser.add_argument(
92
+ "--seed", type=int, default=42,
93
+ help="Random seed for reproducibility across PyTorch, NumPy, and Python."
94
+ )
95
+ return parser.parse_args()
96
+
97
+
98
+ def main():
99
+ args = parse_args()
100
+
101
+ # Initialize master configuration
102
+ config = Config(
103
+ data_dir=args.data_dir,
104
+ checkpoint_dir=args.output_dir,
105
+ log_dir=os.path.join(args.output_dir, "logs"),
106
+ backbone_name=args.backbone,
107
+ epochs=args.epochs,
108
+ backbone_lr=args.lr_backbone,
109
+ lr=args.lr_head,
110
+ batch_size=args.batch_size,
111
+ accumulation_steps=args.accum_steps,
112
+ device=args.device,
113
+ seed=args.seed,
114
+ )
115
+
116
+ os.makedirs(config.checkpoint_dir, exist_ok=True)
117
+ os.makedirs(config.log_dir, exist_ok=True)
118
+ setup_logging(config.log_dir)
119
+ set_seed(config.seed)
120
+
121
+ logging.info(f"Initialized ACL-LKNet with Backbone: {config.backbone_name} on {config.device}")
122
+
123
+ if args.ssl_pretrain:
124
+ logging.info("Starting Phase 1: Masked Slice Modeling (MSM) SSL Pretraining...")
125
+ train_ssl(config)
126
+ elif args.cv:
127
+ logging.info(f"Starting 5-Fold Stratified Cross-Validation on MRNet...")
128
+ results = train_5fold_cross_validation(config, ssl_checkpoint=args.ssl_checkpoint)
129
+ print("\n" + "=" * 60)
130
+ print("Cross-Validation Complete! Summary:")
131
+ print(f"Mean Val AUROC: {results.get('mean_val_auc', 'N/A')}")
132
+ print("=" * 60)
133
+ else:
134
+ logging.info(f"Starting Single Fold Training: Fold {args.fold}...")
135
+ config.experiment_name = f"acl_lknet_fold{args.fold}"
136
+ train_supervised(config, fold_idx=args.fold - 1, ssl_checkpoint=args.ssl_checkpoint)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()