Image Classification
timm
English
medical-imaging
knee-mri
acl-tear-detection
deep-learning
convnext
self-attention
masked-slice-modeling
radiology
orthopedics
Eval Results (legacy)
Instructions to use shareefch1413/ACL-LKNet with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- timm
How to use shareefch1413/ACL-LKNet with timm:
import timm model = timm.create_model("hf_hub:shareefch1413/ACL-LKNet", pretrained=True) - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +99 -120
- evaluate_ensemble.py +202 -0
- generate_gradcam.py +214 -0
- requirements.txt +13 -0
- src/__init__.py +29 -0
- src/config.py +221 -0
- src/dataset.py +434 -0
- src/evaluate.py +1136 -0
- src/models/__init__.py +2 -0
- src/models/acl_lknet.py +244 -0
- src/models/backbone.py +277 -0
- src/models/cross_view_fusion.py +135 -0
- src/models/msm.py +212 -0
- src/models/slice_attention.py +117 -0
- src/train.py +723 -0
- src/utils.py +247 -0
- train_cv.py +140 -0
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 |
-
|
| 54 |
|
| 55 |
-
|
| 56 |
-
[](https://opensource.org/licenses/MIT)
|
| 57 |
-
[](https://github.com)
|
| 58 |
-
[](https://github.com)
|
| 59 |
|
| 60 |
-
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
---
|
| 70 |
|
| 71 |
-
##
|
| 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 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
| **Brier Score** | **0.1184** | **[0.0891, 0.1520]** | -- | Well-Calibrated |
|
| 84 |
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
---
|
| 88 |
|
| 89 |
-
##
|
| 90 |
|
| 91 |
-
``
|
| 92 |
-
import torch
|
| 93 |
-
from huggingface_hub import hf_hub_download
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
)
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
| 111 |
|
| 112 |
-
|
| 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 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
---
|
| 128 |
|
| 129 |
-
##
|
| 130 |
-
|
| 131 |
-
* **
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
---
|
| 136 |
|
| 137 |
-
##
|
| 138 |
|
| 139 |
-
```
|
| 140 |
-
|
| 141 |
-
|
| 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()
|