Spaces:
Running on Zero
Running on Zero
Add online inference demo with 20 samples
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- BrainAnytime/altas/AAL116_standard.nii.gz +3 -0
- BrainAnytime/anatomy_masking.py +595 -0
- BrainAnytime/downstream_dataloader.py +737 -0
- BrainAnytime/finetune_main.py +854 -0
- BrainAnytime/models/__init__.py +0 -0
- BrainAnytime/models/multimae3d.py +997 -0
- BrainAnytime/models/multimae3d_utils.py +344 -0
- BrainAnytime/pretrain_dataloader_v2.py +313 -0
- BrainAnytime/test_main.py +355 -0
- README.md +30 -143
- app.py +517 -40
- demo_samples/AGE/T/sample_016/sample_016_T1.nii.gz +3 -0
- demo_samples/AGE/T/sample_016/sample_016_T1_preview.png +0 -0
- demo_samples/AGE/T/sample_016/sample_016_meta.json +33 -0
- demo_samples/AGE/TF/sample_017/sample_017_Flair.nii.gz +3 -0
- demo_samples/AGE/TF/sample_017/sample_017_Flair_preview.png +0 -0
- demo_samples/AGE/TF/sample_017/sample_017_T1.nii.gz +3 -0
- demo_samples/AGE/TF/sample_017/sample_017_T1_preview.png +0 -0
- demo_samples/AGE/TF/sample_017/sample_017_meta.json +36 -0
- demo_samples/AGE/TFP/sample_019/sample_019_Flair.nii.gz +3 -0
- demo_samples/AGE/TFP/sample_019/sample_019_Flair_preview.png +0 -0
- demo_samples/AGE/TFP/sample_019/sample_019_PET.nii.gz +3 -0
- demo_samples/AGE/TFP/sample_019/sample_019_PET_preview.png +0 -0
- demo_samples/AGE/TFP/sample_019/sample_019_T1.nii.gz +3 -0
- demo_samples/AGE/TFP/sample_019/sample_019_T1_preview.png +0 -0
- demo_samples/AGE/TFP/sample_019/sample_019_meta.json +39 -0
- demo_samples/AGE/TMF/sample_018/sample_018_Flair.nii.gz +3 -0
- demo_samples/AGE/TMF/sample_018/sample_018_Flair_preview.png +0 -0
- demo_samples/AGE/TMF/sample_018/sample_018_T1.nii.gz +3 -0
- demo_samples/AGE/TMF/sample_018/sample_018_T1_preview.png +0 -0
- demo_samples/AGE/TMF/sample_018/sample_018_T2.nii.gz +3 -0
- demo_samples/AGE/TMF/sample_018/sample_018_T2_preview.png +0 -0
- demo_samples/AGE/TMF/sample_018/sample_018_meta.json +39 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_Flair.nii.gz +3 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_Flair_preview.png +0 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_PET.nii.gz +3 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_PET_preview.png +0 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_T1.nii.gz +3 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_T1_preview.png +0 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_T2.nii.gz +3 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_T2_preview.png +0 -0
- demo_samples/AGE/TMFP/sample_020/sample_020_meta.json +42 -0
- demo_samples/CN_vs_AD/T/sample_001/sample_001_T1.nii.gz +3 -0
- demo_samples/CN_vs_AD/T/sample_001/sample_001_T1_preview.png +0 -0
- demo_samples/CN_vs_AD/T/sample_001/sample_001_meta.json +33 -0
- demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair.nii.gz +3 -0
- demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair_preview.png +0 -0
- demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1.nii.gz +3 -0
- demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1_preview.png +0 -0
- demo_samples/CN_vs_AD/TF/sample_002/sample_002_meta.json +36 -0
BrainAnytime/altas/AAL116_standard.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1a1e8d16d6b7616f3904f4319683692dda934010a52ace85de082d12702d2732
|
| 3 |
+
size 203563
|
BrainAnytime/anatomy_masking.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Anatomy-Aware Adaptive Masking for MultiMAE3D Pretraining.
|
| 3 |
+
|
| 4 |
+
Four components:
|
| 5 |
+
1. Patch-Region Mapping: Maps 512 patches (8x8x8 grid) to AAL116 brain atlas regions
|
| 6 |
+
2. Region Importance Scoring: Static (AD prior) + Dynamic (EMA teacher attention)
|
| 7 |
+
3. Mask Probability Generation: Softmax with temperature control
|
| 8 |
+
4. Curriculum Scheduler: Three-phase training schedule
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
masking = AnatomyAwareMasking(
|
| 12 |
+
img_size=128, patch_size=16,
|
| 13 |
+
atlas_path='altas/AAL116_standard.nii.gz',
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# In training loop:
|
| 17 |
+
mask_probs = masking.get_mask_probs(epoch, total_epochs)
|
| 18 |
+
output = model(images, observed, patch_mask_probs=mask_probs)
|
| 19 |
+
|
| 20 |
+
# EMA teacher attention update (every N iterations):
|
| 21 |
+
attn = extract_teacher_attention(ema_teacher, images, observed)
|
| 22 |
+
masking.update_dynamic_importance(attn)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import os
|
| 26 |
+
import math
|
| 27 |
+
import copy
|
| 28 |
+
import numpy as np
|
| 29 |
+
import torch
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
import nibabel as nib
|
| 33 |
+
HAS_NIBABEL = True
|
| 34 |
+
except ImportError:
|
| 35 |
+
HAS_NIBABEL = False
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# AAL116 Atlas: Label-to-Name Mapping + AD Importance
|
| 40 |
+
# =============================================================================
|
| 41 |
+
|
| 42 |
+
AAL116_LABEL_NAMES = {
|
| 43 |
+
1: 'Precentral_L', 2: 'Precentral_R',
|
| 44 |
+
3: 'Frontal_Sup_L', 4: 'Frontal_Sup_R',
|
| 45 |
+
5: 'Frontal_Sup_Orb_L', 6: 'Frontal_Sup_Orb_R',
|
| 46 |
+
7: 'Frontal_Mid_L', 8: 'Frontal_Mid_R',
|
| 47 |
+
9: 'Frontal_Mid_Orb_L', 10: 'Frontal_Mid_Orb_R',
|
| 48 |
+
11: 'Frontal_Inf_Oper_L', 12: 'Frontal_Inf_Oper_R',
|
| 49 |
+
13: 'Frontal_Inf_Tri_L', 14: 'Frontal_Inf_Tri_R',
|
| 50 |
+
15: 'Frontal_Inf_Orb_L', 16: 'Frontal_Inf_Orb_R',
|
| 51 |
+
17: 'Rolandic_Oper_L', 18: 'Rolandic_Oper_R',
|
| 52 |
+
19: 'Supp_Motor_Area_L', 20: 'Supp_Motor_Area_R',
|
| 53 |
+
21: 'Olfactory_L', 22: 'Olfactory_R',
|
| 54 |
+
23: 'Frontal_Sup_Medial_L', 24: 'Frontal_Sup_Medial_R',
|
| 55 |
+
25: 'Frontal_Med_Orb_L', 26: 'Frontal_Med_Orb_R',
|
| 56 |
+
27: 'Rectus_L', 28: 'Rectus_R',
|
| 57 |
+
29: 'Insula_L', 30: 'Insula_R',
|
| 58 |
+
31: 'Cingulum_Ant_L', 32: 'Cingulum_Ant_R',
|
| 59 |
+
33: 'Cingulum_Mid_L', 34: 'Cingulum_Mid_R',
|
| 60 |
+
35: 'Cingulum_Post_L', 36: 'Cingulum_Post_R',
|
| 61 |
+
37: 'Hippocampus_L', 38: 'Hippocampus_R',
|
| 62 |
+
39: 'ParaHippocampal_L', 40: 'ParaHippocampal_R',
|
| 63 |
+
41: 'Amygdala_L', 42: 'Amygdala_R',
|
| 64 |
+
43: 'Calcarine_L', 44: 'Calcarine_R',
|
| 65 |
+
45: 'Cuneus_L', 46: 'Cuneus_R',
|
| 66 |
+
47: 'Lingual_L', 48: 'Lingual_R',
|
| 67 |
+
49: 'Occipital_Sup_L', 50: 'Occipital_Sup_R',
|
| 68 |
+
51: 'Occipital_Mid_L', 52: 'Occipital_Mid_R',
|
| 69 |
+
53: 'Occipital_Inf_L', 54: 'Occipital_Inf_R',
|
| 70 |
+
55: 'Fusiform_L', 56: 'Fusiform_R',
|
| 71 |
+
57: 'Postcentral_L', 58: 'Postcentral_R',
|
| 72 |
+
59: 'Parietal_Sup_L', 60: 'Parietal_Sup_R',
|
| 73 |
+
61: 'Parietal_Inf_L', 62: 'Parietal_Inf_R',
|
| 74 |
+
63: 'SupraMarginal_L', 64: 'SupraMarginal_R',
|
| 75 |
+
65: 'Angular_L', 66: 'Angular_R',
|
| 76 |
+
67: 'Precuneus_L', 68: 'Precuneus_R',
|
| 77 |
+
69: 'Paracentral_Lobule_L', 70: 'Paracentral_Lobule_R',
|
| 78 |
+
71: 'Caudate_L', 72: 'Caudate_R',
|
| 79 |
+
73: 'Putamen_L', 74: 'Putamen_R',
|
| 80 |
+
75: 'Pallidum_L', 76: 'Pallidum_R',
|
| 81 |
+
77: 'Thalamus_L', 78: 'Thalamus_R',
|
| 82 |
+
79: 'Heschl_L', 80: 'Heschl_R',
|
| 83 |
+
81: 'Temporal_Sup_L', 82: 'Temporal_Sup_R',
|
| 84 |
+
83: 'Temporal_Pole_Sup_L', 84: 'Temporal_Pole_Sup_R',
|
| 85 |
+
85: 'Temporal_Mid_L', 86: 'Temporal_Mid_R',
|
| 86 |
+
87: 'Temporal_Pole_Mid_L', 88: 'Temporal_Pole_Mid_R',
|
| 87 |
+
89: 'Temporal_Inf_L', 90: 'Temporal_Inf_R',
|
| 88 |
+
91: 'Cerebelum_Crus1_L', 92: 'Cerebelum_Crus1_R',
|
| 89 |
+
93: 'Cerebelum_Crus2_L', 94: 'Cerebelum_Crus2_R',
|
| 90 |
+
95: 'Cerebelum_3_L', 96: 'Cerebelum_3_R',
|
| 91 |
+
97: 'Cerebelum_4_5_L', 98: 'Cerebelum_4_5_R',
|
| 92 |
+
99: 'Cerebelum_6_L', 100: 'Cerebelum_6_R',
|
| 93 |
+
101: 'Cerebelum_7b_L', 102: 'Cerebelum_7b_R',
|
| 94 |
+
103: 'Cerebelum_8_L', 104: 'Cerebelum_8_R',
|
| 95 |
+
105: 'Cerebelum_9_L', 106: 'Cerebelum_9_R',
|
| 96 |
+
107: 'Cerebelum_10_L', 108: 'Cerebelum_10_R',
|
| 97 |
+
109: 'Vermis_1_2', 110: 'Vermis_3',
|
| 98 |
+
111: 'Vermis_4_5', 112: 'Vermis_6',
|
| 99 |
+
113: 'Vermis_7', 114: 'Vermis_8',
|
| 100 |
+
115: 'Vermis_9', 116: 'Vermis_10',
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# AD-relevant regions: base name (without _L/_R) -> importance level
|
| 104 |
+
# Based on Braak staging and AD pathology literature
|
| 105 |
+
AD_REGION_IMPORTANCE = {
|
| 106 |
+
# Hippocampus (Braak III-IV)
|
| 107 |
+
'Hippocampus': 'high',
|
| 108 |
+
# Parahippocampal / Entorhinal cortex (Braak I-II, earliest involvement)
|
| 109 |
+
'ParaHippocampal': 'high',
|
| 110 |
+
# Amygdala (Braak III-IV)
|
| 111 |
+
'Amygdala': 'high',
|
| 112 |
+
# Posterior cingulate cortex (early metabolic changes in AD)
|
| 113 |
+
'Cingulum_Post': 'high',
|
| 114 |
+
# Precuneus (default mode network hub, early amyloid deposition)
|
| 115 |
+
'Precuneus': 'high',
|
| 116 |
+
# Inferior temporal (early cortical atrophy)
|
| 117 |
+
'Temporal_Inf': 'high',
|
| 118 |
+
# Middle temporal
|
| 119 |
+
'Temporal_Mid': 'high',
|
| 120 |
+
# Fusiform gyrus
|
| 121 |
+
'Fusiform': 'high',
|
| 122 |
+
# Angular gyrus (default mode network)
|
| 123 |
+
'Angular': 'high',
|
| 124 |
+
# Medial orbitofrontal (default mode network)
|
| 125 |
+
'Frontal_Med_Orb': 'high',
|
| 126 |
+
# Temporal poles
|
| 127 |
+
'Temporal_Pole_Sup': 'high',
|
| 128 |
+
'Temporal_Pole_Mid': 'high',
|
| 129 |
+
# Insula
|
| 130 |
+
'Insula': 'high',
|
| 131 |
+
# Thalamus (subcortical relay)
|
| 132 |
+
'Thalamus': 'high',
|
| 133 |
+
# Caudate (striatal amyloid)
|
| 134 |
+
'Caudate': 'high',
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _get_region_importance(region_name):
|
| 139 |
+
"""Match an AAL116 region name to its AD importance level."""
|
| 140 |
+
base = region_name
|
| 141 |
+
if base.endswith('_L') or base.endswith('_R'):
|
| 142 |
+
base = base[:-2]
|
| 143 |
+
return AD_REGION_IMPORTANCE.get(base, 'mid')
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# =============================================================================
|
| 147 |
+
# Patch-Region Mapping
|
| 148 |
+
# =============================================================================
|
| 149 |
+
|
| 150 |
+
def build_patch_region_mapping(atlas_data, img_size, patch_size):
|
| 151 |
+
"""Build mapping from 3D patches to atlas regions.
|
| 152 |
+
|
| 153 |
+
For each patch, computes the fraction of voxels belonging to each region.
|
| 154 |
+
Patch ordering matches einops rearrange:
|
| 155 |
+
"b c (nd pd) (nh ph) (nw pw) -> b (nd nh nw) c pd ph pw"
|
| 156 |
+
patch_index = d_idx * (grid_h * grid_w) + h_idx * grid_w + w_idx
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
atlas_data: [D, H, W] integer numpy array (0 = background)
|
| 160 |
+
img_size: (D, H, W) tuple
|
| 161 |
+
patch_size: (pd, ph, pw) tuple
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
membership: [N_patches, K] float32 tensor (region membership fractions)
|
| 165 |
+
region_labels: sorted list of unique non-zero integer labels
|
| 166 |
+
"""
|
| 167 |
+
grid = tuple(img_size[i] // patch_size[i] for i in range(3))
|
| 168 |
+
N = grid[0] * grid[1] * grid[2]
|
| 169 |
+
|
| 170 |
+
labels = sorted([int(l) for l in np.unique(atlas_data) if l > 0])
|
| 171 |
+
K = len(labels)
|
| 172 |
+
label_to_idx = {l: i for i, l in enumerate(labels)}
|
| 173 |
+
|
| 174 |
+
membership = np.zeros((N, K), dtype=np.float32)
|
| 175 |
+
voxels_per_patch = patch_size[0] * patch_size[1] * patch_size[2]
|
| 176 |
+
|
| 177 |
+
patch_idx = 0
|
| 178 |
+
for d in range(grid[0]):
|
| 179 |
+
for h in range(grid[1]):
|
| 180 |
+
for w in range(grid[2]):
|
| 181 |
+
block = atlas_data[
|
| 182 |
+
d * patch_size[0]:(d + 1) * patch_size[0],
|
| 183 |
+
h * patch_size[1]:(h + 1) * patch_size[1],
|
| 184 |
+
w * patch_size[2]:(w + 1) * patch_size[2],
|
| 185 |
+
].flatten()
|
| 186 |
+
|
| 187 |
+
for label in labels:
|
| 188 |
+
count = np.sum(block == label)
|
| 189 |
+
if count > 0:
|
| 190 |
+
membership[patch_idx, label_to_idx[label]] = count / voxels_per_patch
|
| 191 |
+
|
| 192 |
+
patch_idx += 1
|
| 193 |
+
|
| 194 |
+
return torch.from_numpy(membership), labels
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# =============================================================================
|
| 198 |
+
# Main Class
|
| 199 |
+
# =============================================================================
|
| 200 |
+
|
| 201 |
+
class AnatomyAwareMasking:
|
| 202 |
+
"""Anatomy-aware adaptive masking with curriculum learning.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
img_size: Input volume size (default 128)
|
| 206 |
+
patch_size: Patch size (default 16)
|
| 207 |
+
atlas_path: Path to AAL116 atlas NIfTI (128x128x128, labels 1-116)
|
| 208 |
+
w_high / w_mid / w_low: Importance weights for AD-critical / gray matter / non-brain regions
|
| 209 |
+
temperature_target: Final temperature for softmax (lower = more focused masking)
|
| 210 |
+
temperature_start: Starting temperature at Phase 2 onset
|
| 211 |
+
phase1_end: End of Phase 1 (uniform masking) as fraction of total epochs
|
| 212 |
+
phase2_end: End of Phase 2 (transition) as fraction of total epochs
|
| 213 |
+
ema_momentum: EMA momentum for teacher model updates
|
| 214 |
+
attention_update_freq: Extract teacher attention every N training iterations
|
| 215 |
+
teacher_batch_size: Number of samples for teacher attention extraction
|
| 216 |
+
importance_mode: 'static', 'dynamic', or 'combined'
|
| 217 |
+
dynamic_weight: Weight of dynamic importance in combined mode [0, 1]
|
| 218 |
+
"""
|
| 219 |
+
|
| 220 |
+
def __init__(
|
| 221 |
+
self,
|
| 222 |
+
img_size=128,
|
| 223 |
+
patch_size=16,
|
| 224 |
+
atlas_path=None,
|
| 225 |
+
w_high=3.0,
|
| 226 |
+
w_mid=1.5,
|
| 227 |
+
w_low=0.3,
|
| 228 |
+
temperature_target=1.0,
|
| 229 |
+
temperature_start=5.0,
|
| 230 |
+
phase1_end=0.2,
|
| 231 |
+
phase2_end=0.7,
|
| 232 |
+
ema_momentum=0.998,
|
| 233 |
+
attention_update_freq=200,
|
| 234 |
+
teacher_batch_size=2,
|
| 235 |
+
importance_mode='combined',
|
| 236 |
+
dynamic_weight=0.5,
|
| 237 |
+
):
|
| 238 |
+
self.img_size = (img_size,) * 3 if isinstance(img_size, int) else tuple(img_size)
|
| 239 |
+
self.patch_size = (patch_size,) * 3 if isinstance(patch_size, int) else tuple(patch_size)
|
| 240 |
+
self.grid = tuple(self.img_size[i] // self.patch_size[i] for i in range(3))
|
| 241 |
+
self.num_patches = self.grid[0] * self.grid[1] * self.grid[2]
|
| 242 |
+
|
| 243 |
+
self.w_high = w_high
|
| 244 |
+
self.w_mid = w_mid
|
| 245 |
+
self.w_low = w_low
|
| 246 |
+
|
| 247 |
+
self.temperature_target = temperature_target
|
| 248 |
+
self.temperature_start = temperature_start
|
| 249 |
+
self.phase1_end = phase1_end
|
| 250 |
+
self.phase2_end = phase2_end
|
| 251 |
+
|
| 252 |
+
self.ema_momentum = ema_momentum
|
| 253 |
+
self.attention_update_freq = attention_update_freq
|
| 254 |
+
self.teacher_batch_size = teacher_batch_size
|
| 255 |
+
|
| 256 |
+
self.importance_mode = importance_mode
|
| 257 |
+
self.dynamic_weight = dynamic_weight
|
| 258 |
+
|
| 259 |
+
# Internal state
|
| 260 |
+
self.patch_region_membership = None # [N, K]
|
| 261 |
+
self.region_labels = None # list[int]
|
| 262 |
+
self.static_importance = None # [N]
|
| 263 |
+
self.dynamic_region_importance = None # [K] running average
|
| 264 |
+
self.dynamic_patch_importance = None # [N] fallback without atlas
|
| 265 |
+
|
| 266 |
+
if atlas_path is not None:
|
| 267 |
+
self._load_atlas(atlas_path)
|
| 268 |
+
self._compute_static_importance()
|
| 269 |
+
|
| 270 |
+
# -----------------------------------------------------------------
|
| 271 |
+
# Atlas loading and static importance
|
| 272 |
+
# -----------------------------------------------------------------
|
| 273 |
+
|
| 274 |
+
def _load_atlas(self, atlas_path):
|
| 275 |
+
if not HAS_NIBABEL:
|
| 276 |
+
raise ImportError("nibabel required for atlas loading: pip install nibabel")
|
| 277 |
+
if not os.path.exists(atlas_path):
|
| 278 |
+
raise FileNotFoundError(f"Atlas not found: {atlas_path}")
|
| 279 |
+
|
| 280 |
+
atlas_img = nib.load(atlas_path)
|
| 281 |
+
atlas_data = np.asarray(atlas_img.dataobj, dtype=np.int32)
|
| 282 |
+
|
| 283 |
+
if atlas_data.shape != self.img_size:
|
| 284 |
+
raise ValueError(
|
| 285 |
+
f"Atlas shape {atlas_data.shape} != expected {self.img_size}. "
|
| 286 |
+
f"Resample the atlas to match your data dimensions."
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
self.patch_region_membership, self.region_labels = build_patch_region_mapping(
|
| 290 |
+
atlas_data, self.img_size, self.patch_size
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
def _compute_static_importance(self):
|
| 294 |
+
"""Compute per-patch static importance: s_i = sum_k(r_{i,k} * w_k)."""
|
| 295 |
+
if self.patch_region_membership is None:
|
| 296 |
+
return
|
| 297 |
+
|
| 298 |
+
K = len(self.region_labels)
|
| 299 |
+
region_weights = torch.zeros(K)
|
| 300 |
+
for i, label in enumerate(self.region_labels):
|
| 301 |
+
name = AAL116_LABEL_NAMES.get(label, f"Region_{label}")
|
| 302 |
+
level = _get_region_importance(name)
|
| 303 |
+
if level == 'high':
|
| 304 |
+
region_weights[i] = self.w_high
|
| 305 |
+
elif level == 'mid':
|
| 306 |
+
region_weights[i] = self.w_mid
|
| 307 |
+
else:
|
| 308 |
+
region_weights[i] = self.w_low
|
| 309 |
+
|
| 310 |
+
self.static_importance = self.patch_region_membership @ region_weights # [N]
|
| 311 |
+
|
| 312 |
+
# Penalize non-brain patches (< 10% brain coverage)
|
| 313 |
+
brain_coverage = self.patch_region_membership.sum(dim=1)
|
| 314 |
+
non_brain = brain_coverage < 0.1
|
| 315 |
+
self.static_importance[non_brain] = self.w_low * 0.5
|
| 316 |
+
|
| 317 |
+
# -----------------------------------------------------------------
|
| 318 |
+
# Dynamic importance from EMA teacher
|
| 319 |
+
# -----------------------------------------------------------------
|
| 320 |
+
|
| 321 |
+
def _aggregate_to_regions(self, patch_attention):
|
| 322 |
+
"""Aggregate per-patch attention to region level.
|
| 323 |
+
|
| 324 |
+
w_k = (1/|P_k|) * sum_{i in P_k} a_i
|
| 325 |
+
"""
|
| 326 |
+
if self.patch_region_membership is None:
|
| 327 |
+
return None
|
| 328 |
+
M = self.patch_region_membership # [N, K]
|
| 329 |
+
numerator = M.t() @ patch_attention # [K]
|
| 330 |
+
denominator = M.sum(dim=0).clamp(min=1e-8) # [K]
|
| 331 |
+
return numerator / denominator
|
| 332 |
+
|
| 333 |
+
def update_dynamic_importance(self, patch_attention):
|
| 334 |
+
"""Update dynamic importance from EMA teacher CLS attention.
|
| 335 |
+
|
| 336 |
+
Aggregates to region level for smoothing if atlas available,
|
| 337 |
+
otherwise uses raw per-patch attention.
|
| 338 |
+
"""
|
| 339 |
+
patch_attention = patch_attention.detach().cpu()
|
| 340 |
+
momentum = 0.9
|
| 341 |
+
|
| 342 |
+
if self.patch_region_membership is not None:
|
| 343 |
+
region_imp = self._aggregate_to_regions(patch_attention)
|
| 344 |
+
if self.dynamic_region_importance is None:
|
| 345 |
+
self.dynamic_region_importance = region_imp
|
| 346 |
+
else:
|
| 347 |
+
self.dynamic_region_importance = (
|
| 348 |
+
momentum * self.dynamic_region_importance
|
| 349 |
+
+ (1 - momentum) * region_imp
|
| 350 |
+
)
|
| 351 |
+
else:
|
| 352 |
+
if self.dynamic_patch_importance is None:
|
| 353 |
+
self.dynamic_patch_importance = patch_attention
|
| 354 |
+
else:
|
| 355 |
+
self.dynamic_patch_importance = (
|
| 356 |
+
momentum * self.dynamic_patch_importance
|
| 357 |
+
+ (1 - momentum) * patch_attention
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
def _get_dynamic_scores(self):
|
| 361 |
+
"""Convert dynamic importance to per-patch scores."""
|
| 362 |
+
if self.dynamic_region_importance is not None and self.patch_region_membership is not None:
|
| 363 |
+
return self.patch_region_membership @ self.dynamic_region_importance
|
| 364 |
+
return self.dynamic_patch_importance
|
| 365 |
+
|
| 366 |
+
# -----------------------------------------------------------------
|
| 367 |
+
# Temperature and curriculum
|
| 368 |
+
# -----------------------------------------------------------------
|
| 369 |
+
|
| 370 |
+
def get_temperature(self, epoch, total_epochs):
|
| 371 |
+
"""Three-phase curriculum temperature.
|
| 372 |
+
|
| 373 |
+
Phase 1 (0 to phase1_end): tau = inf (uniform masking)
|
| 374 |
+
Phase 2 (phase1_end to phase2_end): cosine anneal start -> target
|
| 375 |
+
Phase 3 (phase2_end to 1.0): tau = target (stable)
|
| 376 |
+
"""
|
| 377 |
+
progress = epoch / max(total_epochs, 1)
|
| 378 |
+
if progress < self.phase1_end:
|
| 379 |
+
return float('inf')
|
| 380 |
+
elif progress < self.phase2_end:
|
| 381 |
+
phase_progress = (progress - self.phase1_end) / (self.phase2_end - self.phase1_end)
|
| 382 |
+
return self.temperature_target + 0.5 * (
|
| 383 |
+
self.temperature_start - self.temperature_target
|
| 384 |
+
) * (1.0 + math.cos(math.pi * phase_progress))
|
| 385 |
+
else:
|
| 386 |
+
return self.temperature_target
|
| 387 |
+
|
| 388 |
+
# -----------------------------------------------------------------
|
| 389 |
+
# Combined importance scores
|
| 390 |
+
# -----------------------------------------------------------------
|
| 391 |
+
|
| 392 |
+
def get_importance_scores(self):
|
| 393 |
+
"""Get combined per-patch importance scores based on importance_mode."""
|
| 394 |
+
static = self.static_importance
|
| 395 |
+
dynamic = self._get_dynamic_scores()
|
| 396 |
+
|
| 397 |
+
if self.importance_mode == 'static':
|
| 398 |
+
return static
|
| 399 |
+
elif self.importance_mode == 'dynamic':
|
| 400 |
+
return dynamic if dynamic is not None else static
|
| 401 |
+
else: # combined
|
| 402 |
+
if static is None and dynamic is None:
|
| 403 |
+
return None
|
| 404 |
+
if dynamic is None:
|
| 405 |
+
return static
|
| 406 |
+
if static is None:
|
| 407 |
+
return dynamic
|
| 408 |
+
# Normalize both to [0, 1] before combining
|
| 409 |
+
s_norm = (static - static.min()) / (static.max() - static.min() + 1e-8)
|
| 410 |
+
d_norm = (dynamic - dynamic.min()) / (dynamic.max() - dynamic.min() + 1e-8)
|
| 411 |
+
alpha = self.dynamic_weight
|
| 412 |
+
return (1 - alpha) * s_norm + alpha * d_norm
|
| 413 |
+
|
| 414 |
+
# -----------------------------------------------------------------
|
| 415 |
+
# Main API
|
| 416 |
+
# -----------------------------------------------------------------
|
| 417 |
+
|
| 418 |
+
def get_mask_probs(self, epoch, total_epochs):
|
| 419 |
+
"""Get per-patch masking probabilities.
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
[N_patches] tensor (sums to 1), or None for uniform masking.
|
| 423 |
+
Higher value = more likely to be masked.
|
| 424 |
+
"""
|
| 425 |
+
tau = self.get_temperature(epoch, total_epochs)
|
| 426 |
+
if tau == float('inf'):
|
| 427 |
+
return None
|
| 428 |
+
|
| 429 |
+
scores = self.get_importance_scores()
|
| 430 |
+
if scores is None:
|
| 431 |
+
return None
|
| 432 |
+
|
| 433 |
+
return torch.softmax(scores / tau, dim=0)
|
| 434 |
+
|
| 435 |
+
def get_curriculum_info(self, epoch, total_epochs):
|
| 436 |
+
"""Get curriculum state for logging."""
|
| 437 |
+
tau = self.get_temperature(epoch, total_epochs)
|
| 438 |
+
progress = epoch / max(total_epochs, 1)
|
| 439 |
+
|
| 440 |
+
if progress < self.phase1_end:
|
| 441 |
+
phase = 1
|
| 442 |
+
elif progress < self.phase2_end:
|
| 443 |
+
phase = 2
|
| 444 |
+
else:
|
| 445 |
+
phase = 3
|
| 446 |
+
|
| 447 |
+
info = {'phase': phase, 'temperature': tau if tau != float('inf') else -1.0}
|
| 448 |
+
|
| 449 |
+
scores = self.get_importance_scores()
|
| 450 |
+
if scores is not None:
|
| 451 |
+
info['importance_min'] = scores.min().item()
|
| 452 |
+
info['importance_max'] = scores.max().item()
|
| 453 |
+
info['importance_mean'] = scores.mean().item()
|
| 454 |
+
|
| 455 |
+
probs = self.get_mask_probs(epoch, total_epochs)
|
| 456 |
+
if probs is not None:
|
| 457 |
+
info['prob_max'] = probs.max().item()
|
| 458 |
+
info['prob_min'] = probs.min().item()
|
| 459 |
+
info['prob_ratio'] = (probs.max() / probs.min().clamp(min=1e-10)).item()
|
| 460 |
+
|
| 461 |
+
return info
|
| 462 |
+
|
| 463 |
+
# -----------------------------------------------------------------
|
| 464 |
+
# Checkpointing
|
| 465 |
+
# -----------------------------------------------------------------
|
| 466 |
+
|
| 467 |
+
def state_dict(self):
|
| 468 |
+
return {
|
| 469 |
+
'static_importance': self.static_importance,
|
| 470 |
+
'dynamic_region_importance': self.dynamic_region_importance,
|
| 471 |
+
'dynamic_patch_importance': self.dynamic_patch_importance,
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
def load_state_dict(self, state_dict):
|
| 475 |
+
if state_dict is None:
|
| 476 |
+
return
|
| 477 |
+
self.static_importance = state_dict.get('static_importance')
|
| 478 |
+
self.dynamic_region_importance = state_dict.get('dynamic_region_importance')
|
| 479 |
+
self.dynamic_patch_importance = state_dict.get('dynamic_patch_importance')
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
# =============================================================================
|
| 483 |
+
# EMA Teacher Utilities
|
| 484 |
+
# =============================================================================
|
| 485 |
+
|
| 486 |
+
@torch.no_grad()
|
| 487 |
+
def create_ema_teacher(model):
|
| 488 |
+
"""Create an EMA copy of the model (no gradients)."""
|
| 489 |
+
teacher = copy.deepcopy(model)
|
| 490 |
+
for p in teacher.parameters():
|
| 491 |
+
p.requires_grad = False
|
| 492 |
+
return teacher
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
@torch.no_grad()
|
| 496 |
+
def update_ema_teacher(teacher, student, momentum=0.998):
|
| 497 |
+
"""Update EMA teacher: theta_t = m * theta_t + (1 - m) * theta_s."""
|
| 498 |
+
student_model = student.module if hasattr(student, 'module') else student
|
| 499 |
+
teacher_model = teacher.module if hasattr(teacher, 'module') else teacher
|
| 500 |
+
for t_param, s_param in zip(teacher_model.parameters(), student_model.parameters()):
|
| 501 |
+
t_param.data.mul_(momentum).add_(s_param.data, alpha=1 - momentum)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
@torch.no_grad()
|
| 505 |
+
def extract_teacher_attention(teacher, images, observed, num_global_tokens=1):
|
| 506 |
+
"""Extract CLS-to-patch attention from the EMA teacher's last encoder layer.
|
| 507 |
+
|
| 508 |
+
Runs a full (unmasked) forward pass through the teacher encoder and
|
| 509 |
+
extracts attention weights from the final transformer block.
|
| 510 |
+
|
| 511 |
+
Args:
|
| 512 |
+
teacher: EMA teacher model (MultiMAE3D, not DDP-wrapped)
|
| 513 |
+
images: [B, 4, D, H, W]
|
| 514 |
+
observed: [B, 4]
|
| 515 |
+
num_global_tokens: number of CLS tokens (default 1)
|
| 516 |
+
|
| 517 |
+
Returns:
|
| 518 |
+
patch_attention: [num_patches] averaged CLS attention scores
|
| 519 |
+
"""
|
| 520 |
+
from models.multimae3d_utils import patchify
|
| 521 |
+
|
| 522 |
+
teacher_model = teacher.module if hasattr(teacher, 'module') else teacher
|
| 523 |
+
teacher_model.eval()
|
| 524 |
+
|
| 525 |
+
B = images.shape[0]
|
| 526 |
+
device = images.device
|
| 527 |
+
batch = teacher_model._split_modalities(images)
|
| 528 |
+
|
| 529 |
+
# Tokenize all patches (no masking)
|
| 530 |
+
tokens_list = []
|
| 531 |
+
for i, name in enumerate(teacher_model.MODALITY_NAMES):
|
| 532 |
+
patches = patchify(batch[name], teacher_model.patch_size)
|
| 533 |
+
tok = teacher_model.input_adapters[name](patches)
|
| 534 |
+
pos_emb = teacher_model.pos_embed.expand(B, -1, -1)
|
| 535 |
+
tok = tok + pos_emb
|
| 536 |
+
mod_mask = observed[:, i:i + 1].unsqueeze(-1)
|
| 537 |
+
tok = tok * mod_mask
|
| 538 |
+
tokens_list.append(tok)
|
| 539 |
+
|
| 540 |
+
input_tokens = torch.cat(tokens_list, dim=1)
|
| 541 |
+
|
| 542 |
+
if teacher_model.num_global_tokens > 0:
|
| 543 |
+
cls = teacher_model.global_tokens.unsqueeze(0).expand(B, -1, -1)
|
| 544 |
+
input_tokens = torch.cat([cls, input_tokens], dim=1)
|
| 545 |
+
|
| 546 |
+
# Attention mask for missing modalities
|
| 547 |
+
total_tokens = input_tokens.shape[1]
|
| 548 |
+
num_patches = teacher_model.num_patches
|
| 549 |
+
attn_mask = torch.zeros(B, 1, 1, total_tokens, device=device)
|
| 550 |
+
mod_offset = num_global_tokens
|
| 551 |
+
for i in range(len(teacher_model.MODALITY_NAMES)):
|
| 552 |
+
start, end = mod_offset, mod_offset + num_patches
|
| 553 |
+
missing = (observed[:, i] < 0.5)
|
| 554 |
+
if missing.any():
|
| 555 |
+
attn_mask[missing, :, :, start:end] = float("-inf")
|
| 556 |
+
mod_offset = end
|
| 557 |
+
if (attn_mask == 0).all():
|
| 558 |
+
attn_mask = None
|
| 559 |
+
|
| 560 |
+
# Forward through encoder layers 0..L-2
|
| 561 |
+
x = input_tokens
|
| 562 |
+
for block in teacher_model.encoder[:-1]:
|
| 563 |
+
x = block(x, attn_mask=attn_mask)
|
| 564 |
+
|
| 565 |
+
# Last layer: manually extract attention weights
|
| 566 |
+
last_block = teacher_model.encoder[-1]
|
| 567 |
+
x_norm = last_block.norm1(x)
|
| 568 |
+
B_, N_, C_ = x_norm.shape
|
| 569 |
+
num_heads = last_block.attn.num_heads
|
| 570 |
+
head_dim = C_ // num_heads
|
| 571 |
+
|
| 572 |
+
qkv = last_block.attn.qkv(x_norm)
|
| 573 |
+
qkv = qkv.reshape(B_, N_, 3, num_heads, head_dim).permute(2, 0, 3, 1, 4)
|
| 574 |
+
q, k, _ = qkv.unbind(0)
|
| 575 |
+
|
| 576 |
+
attn_weights = (q @ k.transpose(-2, -1)) * last_block.attn.scale
|
| 577 |
+
if attn_mask is not None:
|
| 578 |
+
attn_weights = attn_weights + attn_mask
|
| 579 |
+
attn_weights = attn_weights.softmax(dim=-1) # [B, heads, N, N]
|
| 580 |
+
|
| 581 |
+
# CLS (token 0) attention to patch tokens (skip global tokens)
|
| 582 |
+
cls_attn = attn_weights[:, :, 0, num_global_tokens:] # [B, heads, 4*num_patches]
|
| 583 |
+
cls_attn = cls_attn.mean(dim=1) # avg over heads: [B, 4*num_patches]
|
| 584 |
+
|
| 585 |
+
# Reshape to per-modality and average
|
| 586 |
+
num_modalities = len(teacher_model.MODALITY_NAMES)
|
| 587 |
+
per_mod_attn = cls_attn.reshape(B, num_modalities, num_patches)
|
| 588 |
+
|
| 589 |
+
observed_expanded = observed.unsqueeze(-1) # [B, 4, 1]
|
| 590 |
+
weighted_attn = (per_mod_attn * observed_expanded).sum(dim=1) # [B, num_patches]
|
| 591 |
+
count = observed.sum(dim=1, keepdim=True).clamp(min=1)
|
| 592 |
+
avg_attn = weighted_attn / count
|
| 593 |
+
patch_attention = avg_attn.mean(dim=0) # [num_patches]
|
| 594 |
+
|
| 595 |
+
return patch_attention
|
BrainAnytime/downstream_dataloader.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import nibabel as nib
|
| 5 |
+
import torch
|
| 6 |
+
from torch.utils.data import Dataset, DataLoader
|
| 7 |
+
import torchio as tio
|
| 8 |
+
from typing import List, Dict, Tuple, Optional, Union
|
| 9 |
+
import random
|
| 10 |
+
from itertools import combinations, compress
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MultiModalDownstreamDataset(Dataset):
|
| 14 |
+
"""
|
| 15 |
+
多模态3D医学图像下游任务数据集
|
| 16 |
+
|
| 17 |
+
特点:
|
| 18 |
+
- 支持多个标签(AGE, MMSE, CN vs. MCI, CN vs. AD)
|
| 19 |
+
- AGE使用Z-score归一化(regression_norm),MMSE自动进行Min-Max归一化
|
| 20 |
+
- 自动过滤缺失所需标签的样本
|
| 21 |
+
- 支持数据增强(Spatial transforms)
|
| 22 |
+
- 支持指定特定模态列表加载
|
| 23 |
+
- 支持intersection模式(所有模态都存在)和union模式(至少一种模态存在)
|
| 24 |
+
- 支持模态组合数据增强:训练阶段随机drop模态,验证阶段扩展所有模态组合
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# 统一的模态顺序
|
| 28 |
+
MODALITY_ORDER = ['T1', 'T2', 'Flair', 'PET']
|
| 29 |
+
|
| 30 |
+
# 模态简写映射(用于模态组合索引)
|
| 31 |
+
MODALITY_SHORT = {'T1': 'T', 'T2': 'M', 'Flair': 'F', 'PET': 'P'}
|
| 32 |
+
|
| 33 |
+
# MMSE的全局Min-Max值(基于train/val/test全集计算)
|
| 34 |
+
# 注意:MMSE过滤了<10的离群值
|
| 35 |
+
# - MMSE < 10表示重度认知障碍,在ADNI数据集中极少(16个样本,0.54%)
|
| 36 |
+
# - 这些样本可能是数据质量问题或极端异常值
|
| 37 |
+
# - 过滤后保留2952个样本(99.46%),MMSE范围[10.0, 30.0]
|
| 38 |
+
GLOBAL_MIN_MAX = {
|
| 39 |
+
'MMSE': {'min': 10.0, 'max': 30.0}, # 过滤了MMSE<10的离群值(16个样本)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# DX编码映射: 1=CN, 2=MCI, 3=AD
|
| 43 |
+
DX_MAPPING = {1: 'CN', 2: 'MCI', 3: 'AD'}
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
excel_path: str,
|
| 48 |
+
labels: List[str],
|
| 49 |
+
image_size: Tuple[int, int, int] = (128, 128, 128),
|
| 50 |
+
augmentation: bool = True,
|
| 51 |
+
cache_data: bool = False,
|
| 52 |
+
base_dir: str = "/home/data/Downstream/ADNI/",
|
| 53 |
+
modalities: Optional[List[str]] = None,
|
| 54 |
+
intersection: bool = True,
|
| 55 |
+
exclusive_modalities: bool = False,
|
| 56 |
+
phase: str = 'train',
|
| 57 |
+
modality_dropout: bool = True,
|
| 58 |
+
expand_val_combinations: bool = True,
|
| 59 |
+
regression_norm: Optional[Dict[str, Tuple[float, float]]] = None,
|
| 60 |
+
):
|
| 61 |
+
"""
|
| 62 |
+
Args:
|
| 63 |
+
excel_path: Excel文件路径(train/val/test)
|
| 64 |
+
labels: 需要加载的标签列表,支持:
|
| 65 |
+
- 'AGE' 或 'Age': 年龄(回归任务,自动归一化)
|
| 66 |
+
- 'MMSE': MMSE分数(回归任务,自动归一化)
|
| 67 |
+
- 'CN vs MCI': 二分类(CN=0, MCI=1)
|
| 68 |
+
- 'CN vs AD': 二分类(CN=0, AD=1)
|
| 69 |
+
image_size: 图像尺寸 (D, H, W)
|
| 70 |
+
augmentation: 是否进行数据增强
|
| 71 |
+
cache_data: 是否缓存加载的数据到内存
|
| 72 |
+
base_dir: 图像文件的基础目录,用于拼接相对路径
|
| 73 |
+
modalities: 要加载的模态列表,如 ['T1', 'T2']。如果为None,则加载所有模态
|
| 74 |
+
intersection: 模态过滤模式
|
| 75 |
+
- True: 只加载所有指定模态都存在的样本(交集模式)
|
| 76 |
+
- False: 只要包含其中一种指定模态就可以(并集模式)
|
| 77 |
+
exclusive_modalities: 是否只加载仅包含指定模态的样本(排除有其他模态的样本)
|
| 78 |
+
- True: 只加载样本中存在的模态完全等于指定模态的样本
|
| 79 |
+
- False: 只要包含指定模态即可(默认行为)
|
| 80 |
+
例如:如果指定modalities=['T1'],exclusive_modalities=True时,只加载只有T1的样本,排除同时有T1和T2的样本
|
| 81 |
+
phase: 数据集阶段,'train', 'val', 或 'test'
|
| 82 |
+
modality_dropout: 是否在训练阶段启用模态dropout增强(仅phase='train'时有效)
|
| 83 |
+
expand_val_combinations: 是否在验证阶段扩展所有模态组合(仅phase='val'时有效)
|
| 84 |
+
"""
|
| 85 |
+
self.excel_path = excel_path
|
| 86 |
+
self.labels = [label.upper() for label in labels] # 统一转为大写
|
| 87 |
+
self.image_size = image_size
|
| 88 |
+
self.augmentation = augmentation
|
| 89 |
+
self.cache_data = cache_data
|
| 90 |
+
self.base_dir = base_dir
|
| 91 |
+
self.cache = {}
|
| 92 |
+
self.phase = phase
|
| 93 |
+
self.modality_dropout = modality_dropout
|
| 94 |
+
self.expand_val_combinations = expand_val_combinations
|
| 95 |
+
self.regression_norm = regression_norm or {}
|
| 96 |
+
|
| 97 |
+
# 处理模态参数
|
| 98 |
+
if modalities is None:
|
| 99 |
+
# 默认加载所有模态
|
| 100 |
+
self.modalities = self.MODALITY_ORDER.copy()
|
| 101 |
+
else:
|
| 102 |
+
# 验证模态名称
|
| 103 |
+
modalities_upper = [m.upper() for m in modalities]
|
| 104 |
+
valid_modalities = {m.upper() for m in self.MODALITY_ORDER}
|
| 105 |
+
for mod in modalities_upper:
|
| 106 |
+
if mod not in valid_modalities:
|
| 107 |
+
raise ValueError(f"Invalid modality: {mod}. Valid modalities are: {self.MODALITY_ORDER}")
|
| 108 |
+
# 保持模态顺序与MODALITY_ORDER一致
|
| 109 |
+
self.modalities = [m for m in self.MODALITY_ORDER if m.upper() in modalities_upper]
|
| 110 |
+
|
| 111 |
+
self.intersection = intersection
|
| 112 |
+
self.exclusive_modalities = exclusive_modalities
|
| 113 |
+
|
| 114 |
+
# 生成模态组合索引映射
|
| 115 |
+
modality_short_list = [self.MODALITY_SHORT[m] for m in self.MODALITY_ORDER]
|
| 116 |
+
self.combination_to_index = self._get_modality_combinations(modality_short_list)
|
| 117 |
+
|
| 118 |
+
# 验证标签
|
| 119 |
+
valid_labels = {'AGE', 'MMSE', 'CN VS MCI', 'CN VS AD'}
|
| 120 |
+
for label in self.labels:
|
| 121 |
+
if label not in valid_labels:
|
| 122 |
+
raise ValueError(f"Invalid label: {label}. Valid labels are: {valid_labels}")
|
| 123 |
+
|
| 124 |
+
# 加载并过滤样本
|
| 125 |
+
self.samples = self._load_and_filter_samples()
|
| 126 |
+
|
| 127 |
+
# 在验证阶段扩展所有模态组合
|
| 128 |
+
if self.phase == 'val' and self.expand_val_combinations:
|
| 129 |
+
self.samples = self._expand_val_combinations()
|
| 130 |
+
print(f"After expanding validation combinations: {len(self.samples)} samples")
|
| 131 |
+
|
| 132 |
+
print(f"Loaded {len(self.samples)} samples from {excel_path}")
|
| 133 |
+
print(f"Requested labels: {self.labels}")
|
| 134 |
+
print(f"Requested modalities: {self.modalities}")
|
| 135 |
+
print(f"Phase: {self.phase}")
|
| 136 |
+
print(f"Modality filter mode: {'intersection' if self.intersection else 'union'}")
|
| 137 |
+
if self.exclusive_modalities:
|
| 138 |
+
print(f"Exclusive mode: Only loading samples that contain EXACTLY the specified modalities")
|
| 139 |
+
if self.phase == 'train' and self.modality_dropout:
|
| 140 |
+
print(f"Modality dropout augmentation: Enabled (will randomly drop modalities during training)")
|
| 141 |
+
if self.phase == 'val' and self.expand_val_combinations:
|
| 142 |
+
print(f"Validation combination expansion: Enabled (each sample expanded to all possible modality subsets)")
|
| 143 |
+
|
| 144 |
+
# 初始化数据增强
|
| 145 |
+
if self.augmentation:
|
| 146 |
+
self.spatial_transform = tio.OneOf({
|
| 147 |
+
tio.RandomFlip(axes=0, flip_probability=0.5): 0.33,
|
| 148 |
+
tio.RandomAffine(scales=(0.9, 1.2), degrees=10, p=0.5): 0.33,
|
| 149 |
+
tio.RandomElasticDeformation(
|
| 150 |
+
num_control_points=(10, 10, 10),
|
| 151 |
+
max_displacement=8,
|
| 152 |
+
locked_borders=2,
|
| 153 |
+
p=0.5
|
| 154 |
+
): 0.34,
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
def _get_modality_combinations(self, modalities: List[str]) -> Dict[str, int]:
|
| 158 |
+
"""
|
| 159 |
+
生成所有可能的模态组合并创建组合字符串到索引的映射
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
modalities: 模态简写列表,如 ['T', 'M', 'F', 'P']
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
组合字符串到索引的字典,如 {'T': 0, 'M': 1, ..., 'TMFP': 14}
|
| 166 |
+
"""
|
| 167 |
+
all_combinations = []
|
| 168 |
+
for i in range(len(modalities), 0, -1):
|
| 169 |
+
comb = list(combinations(modalities, i))
|
| 170 |
+
all_combinations.extend(comb)
|
| 171 |
+
|
| 172 |
+
# 创建映射字典
|
| 173 |
+
combination_to_index = {''.join(sorted(comb)): idx for idx, comb in enumerate(all_combinations)}
|
| 174 |
+
return combination_to_index
|
| 175 |
+
|
| 176 |
+
def _observed_to_combination(self, observed: List[int]) -> str:
|
| 177 |
+
"""
|
| 178 |
+
将observed列表转换为模态组合字符串
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
observed: 观察到的模态列表,如 [1, 1, 1, 0] 表示 T1, T2, Flair 存在,PET 不存在
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
模态组合字符串,如 "TMF"
|
| 185 |
+
"""
|
| 186 |
+
modality_short_list = [self.MODALITY_SHORT[m] for m in self.MODALITY_ORDER]
|
| 187 |
+
available_modalities = list(compress(modality_short_list, observed))
|
| 188 |
+
return ''.join(sorted(available_modalities))
|
| 189 |
+
|
| 190 |
+
def _expand_val_combinations(self) -> List[Dict]:
|
| 191 |
+
"""
|
| 192 |
+
在验证阶段,将每个样本扩展为所有可能的非空模态子集
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
扩展后的样本列表
|
| 196 |
+
"""
|
| 197 |
+
expanded_samples = []
|
| 198 |
+
modality_short_list = [self.MODALITY_SHORT[m] for m in self.MODALITY_ORDER]
|
| 199 |
+
|
| 200 |
+
for sample in self.samples:
|
| 201 |
+
# 获取样本中可用的模态(只考虑在指定模态列表中的)
|
| 202 |
+
available_modalities = [m for m in sample['modalities'].keys() if m in self.modalities]
|
| 203 |
+
available_modalities_short = [self.MODALITY_SHORT[m] for m in available_modalities]
|
| 204 |
+
|
| 205 |
+
if len(available_modalities_short) == 0:
|
| 206 |
+
continue
|
| 207 |
+
|
| 208 |
+
# 生成所有可能的非空子集
|
| 209 |
+
for r in range(1, len(available_modalities_short) + 1):
|
| 210 |
+
for subset in combinations(available_modalities_short, r):
|
| 211 |
+
# 将简写转换回完整模态名
|
| 212 |
+
subset_full = [m for m in self.MODALITY_ORDER if self.MODALITY_SHORT[m] in subset]
|
| 213 |
+
|
| 214 |
+
# 创建新的样本,只包含子集中的模态
|
| 215 |
+
new_sample = {
|
| 216 |
+
'subject_id': sample['subject_id'],
|
| 217 |
+
'dataset': sample['dataset'],
|
| 218 |
+
'modalities': {mod: sample['modalities'][mod] for mod in subset_full},
|
| 219 |
+
'labels': sample['labels'].copy(),
|
| 220 |
+
'original_observed': [1 if m in subset_full else 0 for m in self.MODALITY_ORDER],
|
| 221 |
+
'diag_group': sample.get('diag_group', None),
|
| 222 |
+
}
|
| 223 |
+
expanded_samples.append(new_sample)
|
| 224 |
+
|
| 225 |
+
return expanded_samples
|
| 226 |
+
|
| 227 |
+
def _load_and_filter_samples(self) -> List[Dict]:
|
| 228 |
+
"""加载Excel文件并过滤出包含所有所需标签的样本"""
|
| 229 |
+
if not os.path.exists(self.excel_path):
|
| 230 |
+
raise FileNotFoundError(f"Excel file not found: {self.excel_path}")
|
| 231 |
+
|
| 232 |
+
df = pd.read_excel(self.excel_path)
|
| 233 |
+
samples = []
|
| 234 |
+
|
| 235 |
+
# 统计信息
|
| 236 |
+
total_rows = len(df)
|
| 237 |
+
missing_labels_count = 0
|
| 238 |
+
missing_modalities_count = 0
|
| 239 |
+
union_passed_count = 0
|
| 240 |
+
intersection_passed_count = 0
|
| 241 |
+
exclusive_filtered_count = 0
|
| 242 |
+
all_4_modalities_count = 0 # 统计同时包含所有4个指定模态的样本数
|
| 243 |
+
modality_stats = {mod: 0 for mod in self.modalities} # 统计每个指定模态的出现次数
|
| 244 |
+
|
| 245 |
+
# 模态列名映射
|
| 246 |
+
modality_columns = {'T1': 'T1', 'T2': 'T2', 'Flair': 'Flair', 'PET': 'PET'}
|
| 247 |
+
|
| 248 |
+
for idx, row in df.iterrows():
|
| 249 |
+
sample = {
|
| 250 |
+
'subject_id': row.get('SubjectID', f'sample_{idx}'),
|
| 251 |
+
'dataset': row.get('Dataset', 'Unknown'),
|
| 252 |
+
'modalities': {},
|
| 253 |
+
'labels': {},
|
| 254 |
+
'diag_group': None,
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
# Diagnosis group for CN/MCI/AD (used for AGE CN-only train/val and test stratified metrics)
|
| 258 |
+
if 'DX' in df.columns:
|
| 259 |
+
dx = row.get('DX', None)
|
| 260 |
+
if pd.notna(dx):
|
| 261 |
+
try:
|
| 262 |
+
sample['diag_group'] = self.DX_MAPPING.get(int(dx), None)
|
| 263 |
+
except (TypeError, ValueError):
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
# 加载模态路径
|
| 267 |
+
for unified_name, col_name in modality_columns.items():
|
| 268 |
+
if col_name in df.columns:
|
| 269 |
+
path = row[col_name]
|
| 270 |
+
if pd.notna(path) and isinstance(path, str):
|
| 271 |
+
# 如果是相对路径,则与base_dir拼接
|
| 272 |
+
if not os.path.isabs(path):
|
| 273 |
+
full_path = os.path.join(self.base_dir, path)
|
| 274 |
+
else:
|
| 275 |
+
full_path = path
|
| 276 |
+
|
| 277 |
+
if os.path.exists(full_path):
|
| 278 |
+
sample['modalities'][unified_name] = full_path
|
| 279 |
+
|
| 280 |
+
# 检查并加载标签
|
| 281 |
+
has_all_labels = True
|
| 282 |
+
for label in self.labels:
|
| 283 |
+
label_value = None
|
| 284 |
+
|
| 285 |
+
if label == 'AGE':
|
| 286 |
+
if 'Age' in df.columns:
|
| 287 |
+
age = row['Age']
|
| 288 |
+
if pd.notna(age):
|
| 289 |
+
try:
|
| 290 |
+
age_f = float(age)
|
| 291 |
+
if np.isfinite(age_f) and 'AGE' in self.regression_norm:
|
| 292 |
+
mean, std = self.regression_norm['AGE']
|
| 293 |
+
label_value = (age_f - float(mean)) / float(std)
|
| 294 |
+
except (TypeError, ValueError):
|
| 295 |
+
pass
|
| 296 |
+
|
| 297 |
+
elif label == 'MMSE':
|
| 298 |
+
if 'MMSE' in df.columns:
|
| 299 |
+
mmse = row['MMSE']
|
| 300 |
+
if pd.notna(mmse) and mmse >= self.GLOBAL_MIN_MAX['MMSE']['min']:
|
| 301 |
+
# 归一化到[0, 1]
|
| 302 |
+
# 注意:过滤了MMSE < 10的离群值(重度认知障碍,可能是数据质量问题)
|
| 303 |
+
min_val = self.GLOBAL_MIN_MAX['MMSE']['min']
|
| 304 |
+
max_val = self.GLOBAL_MIN_MAX['MMSE']['max']
|
| 305 |
+
label_value = (mmse - min_val) / (max_val - min_val)
|
| 306 |
+
|
| 307 |
+
elif label == 'CN VS MCI':
|
| 308 |
+
if 'DX' in df.columns:
|
| 309 |
+
dx = row['DX']
|
| 310 |
+
if pd.notna(dx):
|
| 311 |
+
dx_int = int(dx)
|
| 312 |
+
if dx_int == 1: # CN
|
| 313 |
+
label_value = 0.0
|
| 314 |
+
elif dx_int == 2: # MCI
|
| 315 |
+
label_value = 1.0
|
| 316 |
+
# DX=3 (AD) 不包含在此任务中,设为None
|
| 317 |
+
|
| 318 |
+
elif label == 'CN VS AD':
|
| 319 |
+
if 'DX' in df.columns:
|
| 320 |
+
dx = row['DX']
|
| 321 |
+
if pd.notna(dx):
|
| 322 |
+
dx_int = int(dx)
|
| 323 |
+
if dx_int == 1: # CN
|
| 324 |
+
label_value = 0.0
|
| 325 |
+
elif dx_int == 3: # AD
|
| 326 |
+
label_value = 1.0
|
| 327 |
+
# DX=2 (MCI) 不包含在此任务中,设为None
|
| 328 |
+
|
| 329 |
+
if label_value is None:
|
| 330 |
+
has_all_labels = False
|
| 331 |
+
break
|
| 332 |
+
else:
|
| 333 |
+
sample['labels'][label] = label_value
|
| 334 |
+
|
| 335 |
+
# 根据intersection参数过滤模态
|
| 336 |
+
if has_all_labels:
|
| 337 |
+
# 检查样本中存在的指定模态
|
| 338 |
+
available_modalities = [mod for mod in self.modalities if mod in sample['modalities']]
|
| 339 |
+
|
| 340 |
+
# 更新模态统计
|
| 341 |
+
for mod in available_modalities:
|
| 342 |
+
modality_stats[mod] += 1
|
| 343 |
+
|
| 344 |
+
# 统计同时包含所有4个指定模态的样本数
|
| 345 |
+
if len(available_modalities) == len(self.modalities):
|
| 346 |
+
all_4_modalities_count += 1
|
| 347 |
+
|
| 348 |
+
# 检查是否通过intersection/union过滤
|
| 349 |
+
passed_modality_filter = False
|
| 350 |
+
if self.intersection:
|
| 351 |
+
# 交集模式:所有指定模态都必须存在
|
| 352 |
+
if len(available_modalities) == len(self.modalities):
|
| 353 |
+
passed_modality_filter = True
|
| 354 |
+
intersection_passed_count += 1
|
| 355 |
+
else:
|
| 356 |
+
missing_modalities_count += 1
|
| 357 |
+
else:
|
| 358 |
+
# 并集模式:至少包含一种指定模态
|
| 359 |
+
if len(available_modalities) >= 1:
|
| 360 |
+
passed_modality_filter = True
|
| 361 |
+
union_passed_count += 1
|
| 362 |
+
else:
|
| 363 |
+
missing_modalities_count += 1
|
| 364 |
+
|
| 365 |
+
# 如果通过了intersection/union过滤,再检查exclusive_modalities
|
| 366 |
+
if passed_modality_filter:
|
| 367 |
+
if self.exclusive_modalities:
|
| 368 |
+
# 检查样本中存在的模态集合是否完全等于指定的模态集合
|
| 369 |
+
sample_modalities_set = set(sample['modalities'].keys())
|
| 370 |
+
specified_modalities_set = set(self.modalities)
|
| 371 |
+
if sample_modalities_set == specified_modalities_set:
|
| 372 |
+
samples.append(sample)
|
| 373 |
+
else:
|
| 374 |
+
exclusive_filtered_count += 1
|
| 375 |
+
else:
|
| 376 |
+
# 非exclusive模式,直接添加
|
| 377 |
+
samples.append(sample)
|
| 378 |
+
else:
|
| 379 |
+
missing_labels_count += 1
|
| 380 |
+
|
| 381 |
+
# 输出详细的统计信息
|
| 382 |
+
print(f"\n数据加载统计信息:")
|
| 383 |
+
print(f" Excel总行数: {total_rows}")
|
| 384 |
+
print(f" 缺失标签的样本数: {missing_labels_count}")
|
| 385 |
+
print(f" 缺失模态的样本数: {missing_modalities_count}")
|
| 386 |
+
print(f" 各指定模态在样本中的出现次数:")
|
| 387 |
+
for mod, count in modality_stats.items():
|
| 388 |
+
print(f" {mod}: {count} 次")
|
| 389 |
+
print(f" 同时包含所有{len(self.modalities)}个指定模态的样本数: {all_4_modalities_count}")
|
| 390 |
+
if self.intersection:
|
| 391 |
+
print(f" 交集模式: 需要所有指定模态({self.modalities})都存在 (通过: {intersection_passed_count})")
|
| 392 |
+
else:
|
| 393 |
+
print(f" 并集模式: 至少一种指定模态存在即可 (通过: {union_passed_count})")
|
| 394 |
+
if self.exclusive_modalities:
|
| 395 |
+
print(f" 独占模式: 只加载样本中存在的模态完全等于指定模态的样本")
|
| 396 |
+
print(f" 因独占模式被过滤的样本数: {exclusive_filtered_count}")
|
| 397 |
+
print(f" 说明: 只有同时包含所有{len(self.modalities)}个指定模态的样本才会被加载")
|
| 398 |
+
print(f" 最终加载的样本数: {len(samples)}")
|
| 399 |
+
|
| 400 |
+
if len(samples) == 0:
|
| 401 |
+
print(f"\n警告: 没有加载到任何样本!")
|
| 402 |
+
print(f" 可能的原因:")
|
| 403 |
+
print(f" 1. Excel文件中没有同时包含所有请求标签的样本")
|
| 404 |
+
print(f" 2. 请求的模态在样本中不存在或文件路径不正确")
|
| 405 |
+
if self.intersection:
|
| 406 |
+
print(f" 3. 交集模式要求所有指定模态({self.modalities})都必须存在")
|
| 407 |
+
else:
|
| 408 |
+
print(f" 3. 并集模式要求至少一种指定模态({self.modalities})存在")
|
| 409 |
+
print(f" 建议: 检查Excel文件内容或尝试使用 --intersection False")
|
| 410 |
+
|
| 411 |
+
return samples
|
| 412 |
+
|
| 413 |
+
def _load_nifti(self, path: str) -> np.ndarray:
|
| 414 |
+
"""加载NIfTI文件"""
|
| 415 |
+
try:
|
| 416 |
+
nii = nib.load(path)
|
| 417 |
+
data = nii.get_fdata().astype(np.float32)
|
| 418 |
+
return data
|
| 419 |
+
except Exception as e:
|
| 420 |
+
print(f"Error loading {path}: {e}")
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
def __len__(self) -> int:
|
| 424 |
+
return len(self.samples)
|
| 425 |
+
|
| 426 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 427 |
+
sample = self.samples[idx]
|
| 428 |
+
|
| 429 |
+
# 获取原始observed状态(样本中实际存在的模态)
|
| 430 |
+
if 'original_observed' in sample:
|
| 431 |
+
# 验证阶段扩展的样本已经有original_observed
|
| 432 |
+
original_observed = sample['original_observed']
|
| 433 |
+
else:
|
| 434 |
+
# 训练/测试阶段,从样本的modalities构建original_observed
|
| 435 |
+
original_observed = [1 if modality in sample['modalities'] else 0 for modality in self.MODALITY_ORDER]
|
| 436 |
+
|
| 437 |
+
# 应用模态dropout(仅在训练阶段且启用时)
|
| 438 |
+
observed = original_observed.copy()
|
| 439 |
+
if self.phase == 'train' and self.modality_dropout:
|
| 440 |
+
# 获取可用的模态(在指定模态列表中的)
|
| 441 |
+
available_modalities = []
|
| 442 |
+
for mod_idx, modality in enumerate(self.MODALITY_ORDER):
|
| 443 |
+
if observed[mod_idx] == 1 and modality in self.modalities:
|
| 444 |
+
available_modalities.append((mod_idx, modality))
|
| 445 |
+
|
| 446 |
+
# 如果有多个可用模态,随机drop一些
|
| 447 |
+
if len(available_modalities) > 1:
|
| 448 |
+
m = len(available_modalities)
|
| 449 |
+
k = random.randint(1, m - 1) # 随机选择要drop的模态数量 (1 <= k < m)
|
| 450 |
+
modalities_to_drop = random.sample(available_modalities, k)
|
| 451 |
+
|
| 452 |
+
# 更新observed列表
|
| 453 |
+
for mod_idx, _ in modalities_to_drop:
|
| 454 |
+
observed[mod_idx] = 0
|
| 455 |
+
|
| 456 |
+
# 检查缓存(使用原始observed作为缓存键的一部分,因为dropout是随机的)
|
| 457 |
+
cache_key = (idx, tuple(original_observed))
|
| 458 |
+
if self.cache_data and cache_key in self.cache:
|
| 459 |
+
cached_data = self.cache[cache_key]
|
| 460 |
+
images = cached_data['images'].clone()
|
| 461 |
+
cached_observed = cached_data['observed'].clone()
|
| 462 |
+
else:
|
| 463 |
+
# 初始化输出张量(始终为4个模态,对应完整的MODALITY_ORDER)
|
| 464 |
+
num_full_modalities = len(self.MODALITY_ORDER) # Always 4
|
| 465 |
+
images = torch.zeros(num_full_modalities, *self.image_size, dtype=torch.float32)
|
| 466 |
+
cached_observed = torch.zeros(num_full_modalities, dtype=torch.float32)
|
| 467 |
+
|
| 468 |
+
# 加载每个模态(按照完整的MODALITY_ORDER顺序)
|
| 469 |
+
# 只加载在original_observed中存在的模态(不考虑dropout)
|
| 470 |
+
for mod_idx, modality in enumerate(self.MODALITY_ORDER):
|
| 471 |
+
# 只加载在指定模态列表中的模态
|
| 472 |
+
if modality in self.modalities:
|
| 473 |
+
if modality in sample['modalities']:
|
| 474 |
+
path = sample['modalities'][modality]
|
| 475 |
+
data = self._load_nifti(path)
|
| 476 |
+
|
| 477 |
+
if data is not None:
|
| 478 |
+
# 确保数据尺寸正确
|
| 479 |
+
if data.shape == self.image_size:
|
| 480 |
+
images[mod_idx] = torch.from_numpy(data)
|
| 481 |
+
cached_observed[mod_idx] = 1.0
|
| 482 |
+
else:
|
| 483 |
+
print(f"Warning: Size mismatch for {path}, expected {self.image_size}, got {data.shape}")
|
| 484 |
+
# 如果模态不在指定的模态列表中,cached_observed[mod_idx]保持为0,images[mod_idx]保持为全零
|
| 485 |
+
|
| 486 |
+
# 缓存数据(使用原始observed)
|
| 487 |
+
if self.cache_data:
|
| 488 |
+
self.cache[cache_key] = {
|
| 489 |
+
'images': images.clone(),
|
| 490 |
+
'observed': cached_observed.clone()
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
# 应用dropout后的observed(将dropout的模态设为0)
|
| 494 |
+
observed_tensor = torch.tensor(observed, dtype=torch.float32)
|
| 495 |
+
# 对于被dropout的模态,将图像也设为0
|
| 496 |
+
images = images.clone()
|
| 497 |
+
for mod_idx in range(len(observed)):
|
| 498 |
+
if observed[mod_idx] == 0:
|
| 499 |
+
images[mod_idx] = torch.zeros_like(images[mod_idx])
|
| 500 |
+
|
| 501 |
+
# 应用空间数据增强(只对observed的模态应用)
|
| 502 |
+
if self.augmentation:
|
| 503 |
+
# 只对observed的模态应用增强(按照MODALITY_ORDER顺序)
|
| 504 |
+
subject_dict = {}
|
| 505 |
+
for mod_idx, modality in enumerate(self.MODALITY_ORDER):
|
| 506 |
+
if observed[mod_idx] == 1.0:
|
| 507 |
+
# TorchIO需要4D张量 (C, D, H, W)
|
| 508 |
+
subject_dict[modality] = tio.ScalarImage(tensor=images[mod_idx:mod_idx+1])
|
| 509 |
+
|
| 510 |
+
if subject_dict:
|
| 511 |
+
subject = tio.Subject(**subject_dict)
|
| 512 |
+
transformed = self.spatial_transform(subject)
|
| 513 |
+
|
| 514 |
+
# 将增强后的数据放回images张量
|
| 515 |
+
for mod_idx, modality in enumerate(self.MODALITY_ORDER):
|
| 516 |
+
if modality in subject_dict:
|
| 517 |
+
images[mod_idx] = transformed[modality].data[0]
|
| 518 |
+
|
| 519 |
+
# 计算模态组合索引
|
| 520 |
+
combination_str = self._observed_to_combination(observed)
|
| 521 |
+
mc = self.combination_to_index.get(combination_str, 0)
|
| 522 |
+
|
| 523 |
+
# 构建标签张量
|
| 524 |
+
labels_tensor = torch.tensor([sample['labels'][label] for label in self.labels], dtype=torch.float32)
|
| 525 |
+
|
| 526 |
+
return {
|
| 527 |
+
'images': images, # (4, D, H, W) - Always 4 modalities in MODALITY_ORDER
|
| 528 |
+
'observed': observed_tensor, # (4,) - After modality dropout (if applied)
|
| 529 |
+
'original_observed': torch.tensor(original_observed, dtype=torch.float32), # (4,) - Original observed before dropout
|
| 530 |
+
'labels': labels_tensor, # (num_labels,)
|
| 531 |
+
'mc': torch.tensor(mc, dtype=torch.long), # Modality combination index
|
| 532 |
+
'subject_id': sample['subject_id'],
|
| 533 |
+
'diag_group': sample.get('diag_group', None),
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def create_downstream_dataloader(
|
| 538 |
+
excel_path: str,
|
| 539 |
+
labels: List[str],
|
| 540 |
+
batch_size: int = 4,
|
| 541 |
+
num_workers: int = 8,
|
| 542 |
+
augmentation: bool = True,
|
| 543 |
+
shuffle: bool = True,
|
| 544 |
+
pin_memory: bool = True,
|
| 545 |
+
cache_data: bool = False,
|
| 546 |
+
image_size: Tuple[int, int, int] = (128, 128, 128),
|
| 547 |
+
base_dir: str = "/home/data/Downstream/ADNI/",
|
| 548 |
+
modalities: Optional[List[str]] = None,
|
| 549 |
+
intersection: bool = True,
|
| 550 |
+
exclusive_modalities: bool = False,
|
| 551 |
+
phase: str = 'train',
|
| 552 |
+
modality_dropout: bool = True,
|
| 553 |
+
expand_val_combinations: bool = True,
|
| 554 |
+
regression_norm: Optional[Dict[str, Tuple[float, float]]] = None,
|
| 555 |
+
) -> DataLoader:
|
| 556 |
+
"""
|
| 557 |
+
创建下游任务数据加载器
|
| 558 |
+
|
| 559 |
+
Args:
|
| 560 |
+
excel_path: Excel文件路径(train/val/test)
|
| 561 |
+
labels: 需要加载的标签列表,支持:
|
| 562 |
+
- 'AGE' 或 'Age': 年龄(回归任务,自动归一化)
|
| 563 |
+
- 'MMSE': MMSE分数(回归任务,自动归一化)
|
| 564 |
+
- 'CN vs MCI': 二分类(CN=0, MCI=1)
|
| 565 |
+
- 'CN vs AD': 二分类(CN=0, AD=1)
|
| 566 |
+
batch_size: 批量大小
|
| 567 |
+
num_workers: 数据加载进程数
|
| 568 |
+
augmentation: 是否数据增强
|
| 569 |
+
shuffle: 是否打乱数据
|
| 570 |
+
pin_memory: 是否使用pinned memory
|
| 571 |
+
cache_data: 是否缓存数据到内存
|
| 572 |
+
image_size: 图像尺寸 (D, H, W)
|
| 573 |
+
base_dir: 图像文件的基础目录,用于拼接相对路径
|
| 574 |
+
modalities: 要加载的模态列表,如 ['T1', 'T2']。如果为None,则加载所有模态
|
| 575 |
+
intersection: 模态过滤模式
|
| 576 |
+
- True: 只加载所有指定模态都存在的样本(交集模式)
|
| 577 |
+
- False: 只要包含其中一种指定模态就可以(并集模式)
|
| 578 |
+
exclusive_modalities: 是否只加载仅包含指定模态的样本(排除有其他模态的样本)
|
| 579 |
+
- True: 只加载样本中存在的模态完全等于指定模态的样本
|
| 580 |
+
- False: 只要包含指定模态即可(默认行为)
|
| 581 |
+
例如:如果指定modalities=['T1'],exclusive_modalities=True时,只加载只有T1的样本,排除同时有T1和T2的样本
|
| 582 |
+
phase: 数据集阶段,'train', 'val', 或 'test'
|
| 583 |
+
modality_dropout: 是否在训练阶段启用模态dropout增强(仅phase='train'时有效)
|
| 584 |
+
expand_val_combinations: 是否在验证阶段扩展所有模态组合(仅phase='val'时有效)
|
| 585 |
+
|
| 586 |
+
Returns:
|
| 587 |
+
DataLoader实例
|
| 588 |
+
"""
|
| 589 |
+
# Label-efficiency compatibility: if excel_path is relative and not found from cwd,
|
| 590 |
+
# try resolving it under base_dir. This keeps old behavior for absolute paths.
|
| 591 |
+
resolved_excel_path = excel_path
|
| 592 |
+
if not os.path.isabs(resolved_excel_path) and not os.path.exists(resolved_excel_path):
|
| 593 |
+
candidate = os.path.join(base_dir, resolved_excel_path)
|
| 594 |
+
if os.path.exists(candidate):
|
| 595 |
+
resolved_excel_path = candidate
|
| 596 |
+
|
| 597 |
+
dataset = MultiModalDownstreamDataset(
|
| 598 |
+
excel_path=resolved_excel_path,
|
| 599 |
+
labels=labels,
|
| 600 |
+
image_size=image_size,
|
| 601 |
+
augmentation=augmentation,
|
| 602 |
+
cache_data=cache_data,
|
| 603 |
+
base_dir=base_dir,
|
| 604 |
+
modalities=modalities,
|
| 605 |
+
intersection=intersection,
|
| 606 |
+
exclusive_modalities=exclusive_modalities,
|
| 607 |
+
phase=phase,
|
| 608 |
+
modality_dropout=modality_dropout,
|
| 609 |
+
expand_val_combinations=expand_val_combinations,
|
| 610 |
+
regression_norm=regression_norm,
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
dataloader = DataLoader(
|
| 614 |
+
dataset,
|
| 615 |
+
batch_size=batch_size,
|
| 616 |
+
shuffle=shuffle,
|
| 617 |
+
num_workers=num_workers,
|
| 618 |
+
pin_memory=pin_memory,
|
| 619 |
+
drop_last=False, # 下游任务通常不drop last
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
return dataloader
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
# ============== 使用示例 ==============
|
| 626 |
+
if __name__ == '__main__':
|
| 627 |
+
print("=" * 60)
|
| 628 |
+
print("多模态3D医学图像下游任务数据加载器")
|
| 629 |
+
print("=" * 60)
|
| 630 |
+
|
| 631 |
+
# 示例1: 训练阶段,启用模态dropout增强
|
| 632 |
+
print("\n示例1: 训练阶段,启用模态dropout增强")
|
| 633 |
+
dataloader = create_downstream_dataloader(
|
| 634 |
+
excel_path="/home/data/Downstream/ADNI_Division/modality_data_train.xlsx",
|
| 635 |
+
labels= ["CN vs AD"],
|
| 636 |
+
batch_size=2,
|
| 637 |
+
num_workers=4,
|
| 638 |
+
augmentation=True,
|
| 639 |
+
modalities=["T1","T2","Flair","PET"],
|
| 640 |
+
intersection=False,
|
| 641 |
+
shuffle=True,
|
| 642 |
+
phase='train',
|
| 643 |
+
modality_dropout=True, # 启用模态dropout
|
| 644 |
+
expand_val_combinations=False,
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
print(f"\n数据集大小: {len(dataloader.dataset)}")
|
| 648 |
+
print(f"批量数: {len(dataloader)}")
|
| 649 |
+
print(f"请求的标签: {dataloader.dataset.labels}")
|
| 650 |
+
|
| 651 |
+
# 测试加载一个批量
|
| 652 |
+
print("\n测试加载一个批量...")
|
| 653 |
+
for batch in dataloader:
|
| 654 |
+
images = batch['images']
|
| 655 |
+
observed = batch['observed']
|
| 656 |
+
original_observed = batch['original_observed']
|
| 657 |
+
labels = batch['labels']
|
| 658 |
+
mc = batch['mc']
|
| 659 |
+
|
| 660 |
+
print(f"\n批量数据形状:")
|
| 661 |
+
print(f" images: {images.shape}") # (B, 4, 128, 128, 128)
|
| 662 |
+
print(f" observed: {observed.shape}") # (B, 4) - After dropout
|
| 663 |
+
print(f" original_observed: {original_observed.shape}") # (B, 4) - Before dropout
|
| 664 |
+
print(f" labels: {labels.shape}") # (B, num_labels)
|
| 665 |
+
print(f" mc (modality combination): {mc.shape}") # (B,)
|
| 666 |
+
print(f" subject_ids: {batch['subject_id']}")
|
| 667 |
+
print(f" 示例: original_observed[0]={original_observed[0].numpy()}, observed[0]={observed[0].numpy()}, mc[0]={mc[0].item()}")
|
| 668 |
+
break
|
| 669 |
+
|
| 670 |
+
# 示例2: 验证阶段,扩展所有模态组合
|
| 671 |
+
print("\n" + "=" * 60)
|
| 672 |
+
print("示例2: 验证阶段,扩展所有模态组合")
|
| 673 |
+
print("=" * 60)
|
| 674 |
+
|
| 675 |
+
dataloader2 = create_downstream_dataloader(
|
| 676 |
+
excel_path="/home/data/Downstream/ADNI_Division/modality_data_val.xlsx",
|
| 677 |
+
labels=["CN vs AD"],
|
| 678 |
+
batch_size=2,
|
| 679 |
+
modalities=["T1","T2","Flair","PET"],
|
| 680 |
+
intersection=False,
|
| 681 |
+
augmentation=False,
|
| 682 |
+
shuffle=False,
|
| 683 |
+
phase='val',
|
| 684 |
+
modality_dropout=False, # 验证阶段不启用dropout
|
| 685 |
+
expand_val_combinations=True, # 启用模态组合扩展
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
print(f"\n数据集大小: {len(dataloader2.dataset)}")
|
| 689 |
+
print(f"请求的标签: {dataloader2.dataset.labels}")
|
| 690 |
+
print(f"注意: 验证集已扩展为所有可能的模态组合,样本数会增加")
|
| 691 |
+
|
| 692 |
+
# 测试加载一个批量
|
| 693 |
+
print("\n测试加载一个批量...")
|
| 694 |
+
for batch in dataloader2:
|
| 695 |
+
images = batch['images']
|
| 696 |
+
observed = batch['observed']
|
| 697 |
+
original_observed = batch['original_observed']
|
| 698 |
+
labels = batch['labels']
|
| 699 |
+
mc = batch['mc']
|
| 700 |
+
|
| 701 |
+
print(f"\n批量数据形状:")
|
| 702 |
+
print(f" images: {images.shape}")
|
| 703 |
+
print(f" observed: {observed.shape}")
|
| 704 |
+
print(f" original_observed: {original_observed.shape}")
|
| 705 |
+
print(f" labels: {labels.shape}")
|
| 706 |
+
print(f" mc (modality combination): {mc.shape}")
|
| 707 |
+
print(f" 示例: original_observed[0]={original_observed[0].numpy()}, observed[0]={observed[0].numpy()}, mc[0]={mc[0].item()}")
|
| 708 |
+
break
|
| 709 |
+
|
| 710 |
+
# 示例3: 测试阶段,不使用任何增强
|
| 711 |
+
print("\n" + "=" * 60)
|
| 712 |
+
print("示例3: 测试阶段,不使用任何增强")
|
| 713 |
+
print("=" * 60)
|
| 714 |
+
|
| 715 |
+
dataloader3 = create_downstream_dataloader(
|
| 716 |
+
excel_path="/home/data/Downstream/ADNI_Division/modality_data_test.xlsx",
|
| 717 |
+
labels=["CN vs AD"],
|
| 718 |
+
batch_size=2,
|
| 719 |
+
modalities=["T1","T2","Flair","PET"],
|
| 720 |
+
intersection=False,
|
| 721 |
+
augmentation=False,
|
| 722 |
+
shuffle=False,
|
| 723 |
+
phase='test',
|
| 724 |
+
modality_dropout=False,
|
| 725 |
+
expand_val_combinations=False,
|
| 726 |
+
exclusive_modalities=False,
|
| 727 |
+
)
|
| 728 |
+
|
| 729 |
+
print(f"\n数据集大小: {len(dataloader3.dataset)}")
|
| 730 |
+
print(f"请求的标签: {dataloader3.dataset.labels}")
|
| 731 |
+
|
| 732 |
+
print("\n" + "=" * 60)
|
| 733 |
+
print("数据加载测试完成!")
|
| 734 |
+
print("=" * 60)
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
|
BrainAnytime/finetune_main.py
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
MultiMAE3D Finetuning for Downstream Tasks
|
| 4 |
+
|
| 5 |
+
Full finetuning: train the entire model end-to-end.
|
| 6 |
+
|
| 7 |
+
Tasks: CN vs AD, CN vs MCI, MMSE, AGE
|
| 8 |
+
Each task runs with multiple seeds and reports mean +/- std metrics.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
# Finetune on all 4 tasks (3 seeds each)
|
| 12 |
+
python finetune_main.py --pretrained ./pretrain_checkpoints/multimae/best_model.pth
|
| 13 |
+
|
| 14 |
+
# Specific task only
|
| 15 |
+
python finetune_main.py --pretrained ./pretrain_checkpoints/multimae/best_model.pth --tasks "CN vs AD"
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import gc
|
| 21 |
+
import random
|
| 22 |
+
import warnings
|
| 23 |
+
from copy import deepcopy
|
| 24 |
+
from collections import defaultdict
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import pandas as pd
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
from tqdm import tqdm, trange
|
| 31 |
+
from scipy.stats import pearsonr
|
| 32 |
+
|
| 33 |
+
warnings.filterwarnings("ignore")
|
| 34 |
+
|
| 35 |
+
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 36 |
+
sys.path.insert(0, _BASE_DIR)
|
| 37 |
+
|
| 38 |
+
from models.multimae3d import create_multimae3d, MultiMAE3D
|
| 39 |
+
from downstream_dataloader import create_downstream_dataloader
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# =========================================================================
|
| 43 |
+
# Utilities
|
| 44 |
+
# =========================================================================
|
| 45 |
+
|
| 46 |
+
def seed_everything(seed: int):
|
| 47 |
+
random.seed(seed)
|
| 48 |
+
np.random.seed(seed)
|
| 49 |
+
torch.manual_seed(seed)
|
| 50 |
+
if torch.cuda.is_available():
|
| 51 |
+
torch.cuda.manual_seed(seed)
|
| 52 |
+
torch.cuda.manual_seed_all(seed)
|
| 53 |
+
torch.backends.cudnn.deterministic = True
|
| 54 |
+
torch.backends.cudnn.benchmark = False
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def str2bool(v):
|
| 58 |
+
if isinstance(v, bool):
|
| 59 |
+
return v
|
| 60 |
+
if v.lower() in ('true', '1', 'yes'):
|
| 61 |
+
return True
|
| 62 |
+
if v.lower() in ('false', '0', 'no'):
|
| 63 |
+
return False
|
| 64 |
+
raise ValueError(f'Boolean value expected, got: {v}')
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def setup_logger(log_dir, name, filename):
|
| 68 |
+
"""Simple logger setup."""
|
| 69 |
+
import logging
|
| 70 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 71 |
+
logger = logging.getLogger(name)
|
| 72 |
+
logger.setLevel(logging.INFO)
|
| 73 |
+
fh = logging.FileHandler(os.path.join(log_dir, filename))
|
| 74 |
+
fh.setLevel(logging.INFO)
|
| 75 |
+
formatter = logging.Formatter('%(asctime)s - %(message)s')
|
| 76 |
+
fh.setFormatter(formatter)
|
| 77 |
+
logger.addHandler(fh)
|
| 78 |
+
return logger
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# =========================================================================
|
| 82 |
+
# Model: MultiMAE3D encoder + downstream task head
|
| 83 |
+
# =========================================================================
|
| 84 |
+
|
| 85 |
+
class MultiMAE3DForDownstream(nn.Module):
|
| 86 |
+
"""
|
| 87 |
+
MultiMAE3D encoder + task head for downstream classification/regression.
|
| 88 |
+
|
| 89 |
+
Uses encoder.encode() to get all-patch features, pools to a single vector
|
| 90 |
+
(CLS token or mean pooling), then applies a linear head.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
encoder: MultiMAE3D,
|
| 96 |
+
embed_dim: int = 768,
|
| 97 |
+
num_outputs: int = 1,
|
| 98 |
+
pool: str = 'cls',
|
| 99 |
+
dropout: float = 0.1,
|
| 100 |
+
):
|
| 101 |
+
super().__init__()
|
| 102 |
+
self.encoder = encoder
|
| 103 |
+
self.pool = pool
|
| 104 |
+
self.num_patches_per_modality = encoder.num_patches
|
| 105 |
+
self.num_global_tokens = encoder.num_global_tokens
|
| 106 |
+
|
| 107 |
+
self.norm = nn.LayerNorm(embed_dim)
|
| 108 |
+
self.head = nn.Sequential(
|
| 109 |
+
nn.Dropout(dropout),
|
| 110 |
+
nn.Linear(embed_dim, num_outputs),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def forward(self, images: torch.Tensor, observed: torch.Tensor) -> torch.Tensor:
|
| 114 |
+
"""
|
| 115 |
+
Args:
|
| 116 |
+
images: [B, 4, D, H, W]
|
| 117 |
+
observed: [B, 4] float mask (1.0=present, 0.0=missing)
|
| 118 |
+
Returns:
|
| 119 |
+
logits: [B, num_outputs]
|
| 120 |
+
"""
|
| 121 |
+
# encode() returns [B, 1 + 4*num_patches, embed_dim]
|
| 122 |
+
encoder_out = self.encoder.encode(images, observed)
|
| 123 |
+
|
| 124 |
+
if self.pool == 'cls':
|
| 125 |
+
features = encoder_out[:, 0] # CLS token -> [B, D]
|
| 126 |
+
elif self.pool == 'mean':
|
| 127 |
+
# Mean pool over modality tokens with masking for missing modalities
|
| 128 |
+
tokens = encoder_out[:, self.num_global_tokens:] # [B, 4*N_p, D]
|
| 129 |
+
B, _, D = tokens.shape
|
| 130 |
+
N = self.num_patches_per_modality
|
| 131 |
+
# Build per-token mask: repeat each modality's observed flag N times
|
| 132 |
+
mask = observed.unsqueeze(-1).expand(-1, -1, N) # [B, 4, N]
|
| 133 |
+
mask = mask.reshape(B, 4 * N).unsqueeze(-1) # [B, 4*N, 1]
|
| 134 |
+
features = (tokens * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
|
| 135 |
+
else:
|
| 136 |
+
raise ValueError(f"Unknown pool type: {self.pool}")
|
| 137 |
+
|
| 138 |
+
features = self.norm(features)
|
| 139 |
+
logits = self.head(features)
|
| 140 |
+
return logits
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# =========================================================================
|
| 144 |
+
# Pretrained weight loading
|
| 145 |
+
# =========================================================================
|
| 146 |
+
|
| 147 |
+
def load_pretrained_weights(model: MultiMAE3D, checkpoint_path: str, device='cpu'):
|
| 148 |
+
"""
|
| 149 |
+
Load pretrained encoder weights into a MultiMAE3D model.
|
| 150 |
+
|
| 151 |
+
Supports checkpoint formats:
|
| 152 |
+
- 'encoder_state_dict': encoder-only (from periodic/best saves)
|
| 153 |
+
- 'full_model_state_dict': full model (from best_model.pth)
|
| 154 |
+
- 'model_state_dict': full model (from latest.pth)
|
| 155 |
+
- raw state_dict (no wrapper key)
|
| 156 |
+
"""
|
| 157 |
+
print(f"Loading pretrained weights from: {checkpoint_path}")
|
| 158 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 159 |
+
|
| 160 |
+
# Pick the best available state dict
|
| 161 |
+
if 'encoder_state_dict' in ckpt:
|
| 162 |
+
state_dict = ckpt['encoder_state_dict']
|
| 163 |
+
source = 'encoder_state_dict'
|
| 164 |
+
elif 'full_model_state_dict' in ckpt:
|
| 165 |
+
state_dict = ckpt['full_model_state_dict']
|
| 166 |
+
source = 'full_model_state_dict'
|
| 167 |
+
elif 'model_state_dict' in ckpt:
|
| 168 |
+
state_dict = ckpt['model_state_dict']
|
| 169 |
+
source = 'model_state_dict'
|
| 170 |
+
else:
|
| 171 |
+
state_dict = ckpt
|
| 172 |
+
source = 'raw'
|
| 173 |
+
|
| 174 |
+
# If loading from full model, filter to encoder keys only
|
| 175 |
+
encoder_prefixes = ('encoder.', 'input_adapters.', 'pos_embed', 'global_tokens')
|
| 176 |
+
if source in ('full_model_state_dict', 'model_state_dict', 'raw'):
|
| 177 |
+
state_dict = {
|
| 178 |
+
k: v for k, v in state_dict.items()
|
| 179 |
+
if any(k.startswith(p) for p in encoder_prefixes)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
missing, unexpected = model.load_state_dict(state_dict, strict=False)
|
| 183 |
+
|
| 184 |
+
# Output adapters (decoders) are expected to be missing — we don't use them
|
| 185 |
+
truly_missing = [k for k in missing if not k.startswith('output_adapters.')]
|
| 186 |
+
|
| 187 |
+
epoch_info = ckpt.get('epoch', '?')
|
| 188 |
+
loss_info = ckpt.get('loss', '?')
|
| 189 |
+
if isinstance(loss_info, float):
|
| 190 |
+
loss_info = f"{loss_info:.4f}"
|
| 191 |
+
print(f" Source: {source}, Epoch: {epoch_info}, Pretrain Loss: {loss_info}")
|
| 192 |
+
print(f" Loaded {len(state_dict)} parameter tensors")
|
| 193 |
+
if truly_missing:
|
| 194 |
+
print(f" WARNING: {len(truly_missing)} encoder keys missing: {truly_missing[:5]}...")
|
| 195 |
+
if unexpected:
|
| 196 |
+
print(f" WARNING: {len(unexpected)} unexpected keys (ignored)")
|
| 197 |
+
|
| 198 |
+
del ckpt
|
| 199 |
+
return model
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# =========================================================================
|
| 203 |
+
# Metrics
|
| 204 |
+
# =========================================================================
|
| 205 |
+
|
| 206 |
+
def calc_regression_metrics(preds, labels):
|
| 207 |
+
"""Calculate MAE, RMSE, Pearson correlation."""
|
| 208 |
+
preds, labels = np.array(preds), np.array(labels)
|
| 209 |
+
mae = np.mean(np.abs(preds - labels))
|
| 210 |
+
rmse = np.sqrt(np.mean((preds - labels) ** 2))
|
| 211 |
+
if len(preds) > 1 and np.std(preds) > 0 and np.std(labels) > 0:
|
| 212 |
+
r, _ = pearsonr(preds, labels)
|
| 213 |
+
else:
|
| 214 |
+
r = 0.0
|
| 215 |
+
return {'mae': mae, 'rmse': rmse, 'pearson': r}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def calc_classification_metrics(preds, labels, probs):
|
| 219 |
+
"""Calculate ACC, AUC, Sensitivity, Specificity, F1."""
|
| 220 |
+
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score, confusion_matrix
|
| 221 |
+
|
| 222 |
+
preds, labels = np.array(preds), np.array(labels)
|
| 223 |
+
probs = np.array(probs)
|
| 224 |
+
|
| 225 |
+
acc = accuracy_score(labels, preds)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
auc = roc_auc_score(labels, probs[:, 1])
|
| 229 |
+
except ValueError:
|
| 230 |
+
auc = 0.0
|
| 231 |
+
|
| 232 |
+
f1 = f1_score(labels, preds, average='binary')
|
| 233 |
+
|
| 234 |
+
cm = confusion_matrix(labels, preds)
|
| 235 |
+
if cm.shape == (2, 2):
|
| 236 |
+
tn, fp, fn, tp = cm.ravel()
|
| 237 |
+
sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 238 |
+
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0
|
| 239 |
+
else:
|
| 240 |
+
sensitivity, specificity = 0.0, 0.0
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
'acc': acc, 'auc': auc,
|
| 244 |
+
'sensitivity': sensitivity, 'specificity': specificity,
|
| 245 |
+
'f1': f1,
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def calc_metrics_by_combo(preds, labels, probs, combos, task_type):
|
| 250 |
+
"""Calculate metrics grouped by modality combination string."""
|
| 251 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 252 |
+
grouped = defaultdict(lambda: {'preds': [], 'labels': [], 'probs': []})
|
| 253 |
+
for i, combo in enumerate(combos):
|
| 254 |
+
grouped[combo]['preds'].append(preds[i])
|
| 255 |
+
grouped[combo]['labels'].append(labels[i])
|
| 256 |
+
grouped[combo]['probs'].append(probs[i])
|
| 257 |
+
|
| 258 |
+
results = {}
|
| 259 |
+
for combo, data in grouped.items():
|
| 260 |
+
p = np.array(data['preds'])
|
| 261 |
+
l = np.array(data['labels'])
|
| 262 |
+
pr = np.array(data['probs'])
|
| 263 |
+
if is_cls:
|
| 264 |
+
try:
|
| 265 |
+
m = calc_classification_metrics(p, l, pr)
|
| 266 |
+
except Exception:
|
| 267 |
+
m = {'acc': 0, 'auc': 0, 'sensitivity': 0, 'specificity': 0, 'f1': 0}
|
| 268 |
+
else:
|
| 269 |
+
m = calc_regression_metrics(p, l)
|
| 270 |
+
m['n_samples'] = len(p)
|
| 271 |
+
results[combo] = m
|
| 272 |
+
return results
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# =========================================================================
|
| 276 |
+
# Training & Evaluation
|
| 277 |
+
# =========================================================================
|
| 278 |
+
|
| 279 |
+
MODALITY_NAMES = ['T1', 'T2', 'Flair', 'PET']
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def run_epoch(loader, model, criterion, device, task_type,
|
| 283 |
+
is_training=False, optimizer=None):
|
| 284 |
+
"""Run one epoch of training or evaluation."""
|
| 285 |
+
all_preds, all_labels, all_probs = [], [], []
|
| 286 |
+
modality_combos = []
|
| 287 |
+
total_loss, n_batches = 0.0, 0
|
| 288 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 289 |
+
|
| 290 |
+
model.train() if is_training else model.eval()
|
| 291 |
+
|
| 292 |
+
with torch.set_grad_enabled(is_training):
|
| 293 |
+
for batch in tqdm(loader, leave=False,
|
| 294 |
+
desc='Train' if is_training else 'Eval'):
|
| 295 |
+
images = batch['images'].to(device, non_blocking=True)
|
| 296 |
+
observed = batch['observed'].to(device, non_blocking=True)
|
| 297 |
+
labels = batch['labels'][:, 0].to(device, non_blocking=True)
|
| 298 |
+
|
| 299 |
+
logits = model(images, observed) # [B, 1]
|
| 300 |
+
logits_flat = logits.squeeze(-1) # [B]
|
| 301 |
+
loss = criterion(logits_flat, labels.float())
|
| 302 |
+
|
| 303 |
+
total_loss += loss.item()
|
| 304 |
+
n_batches += 1
|
| 305 |
+
|
| 306 |
+
if is_training:
|
| 307 |
+
optimizer.zero_grad()
|
| 308 |
+
loss.backward()
|
| 309 |
+
nn.utils.clip_grad_norm_(
|
| 310 |
+
[p for p in model.parameters() if p.requires_grad],
|
| 311 |
+
max_norm=1.0,
|
| 312 |
+
)
|
| 313 |
+
optimizer.step()
|
| 314 |
+
|
| 315 |
+
# Collect predictions
|
| 316 |
+
if is_cls:
|
| 317 |
+
prob_pos = torch.sigmoid(logits_flat).detach().cpu().numpy()
|
| 318 |
+
pred = (prob_pos > 0.5).astype(int)
|
| 319 |
+
probs_2d = np.stack([1 - prob_pos, prob_pos], axis=1)
|
| 320 |
+
all_preds.extend(pred)
|
| 321 |
+
all_labels.extend(labels.cpu().numpy())
|
| 322 |
+
all_probs.extend(probs_2d)
|
| 323 |
+
else:
|
| 324 |
+
pred_vals = logits_flat.detach().cpu().numpy()
|
| 325 |
+
all_preds.extend(pred_vals)
|
| 326 |
+
all_labels.extend(labels.cpu().numpy())
|
| 327 |
+
all_probs.extend(pred_vals.reshape(-1, 1))
|
| 328 |
+
|
| 329 |
+
# Track modality combos (eval only)
|
| 330 |
+
if not is_training:
|
| 331 |
+
B = images.shape[0]
|
| 332 |
+
for i in range(B):
|
| 333 |
+
present = [
|
| 334 |
+
MODALITY_NAMES[j]
|
| 335 |
+
for j in range(4)
|
| 336 |
+
if observed[i, j] > 0.5
|
| 337 |
+
]
|
| 338 |
+
combo_str = ('+'.join(sorted(present))
|
| 339 |
+
if present else 'None')
|
| 340 |
+
modality_combos.append(combo_str)
|
| 341 |
+
|
| 342 |
+
avg_loss = total_loss / max(n_batches, 1)
|
| 343 |
+
return avg_loss, all_preds, all_labels, all_probs, modality_combos
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# =========================================================================
|
| 347 |
+
# Single task + seed pipeline
|
| 348 |
+
# =========================================================================
|
| 349 |
+
|
| 350 |
+
def train_and_evaluate(args, task_type, seed, device):
|
| 351 |
+
"""
|
| 352 |
+
Full train/val/test pipeline for one (task, seed) combination.
|
| 353 |
+
Returns a dict of test metrics.
|
| 354 |
+
"""
|
| 355 |
+
seed_everything(seed)
|
| 356 |
+
torch.cuda.empty_cache()
|
| 357 |
+
|
| 358 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 359 |
+
|
| 360 |
+
# ---- Data loaders ----
|
| 361 |
+
loader_kwargs = dict(
|
| 362 |
+
batch_size=args.batch_size,
|
| 363 |
+
num_workers=args.num_workers,
|
| 364 |
+
pin_memory=True,
|
| 365 |
+
cache_data=False,
|
| 366 |
+
image_size=tuple(args.image_size),
|
| 367 |
+
base_dir=args.base_dir,
|
| 368 |
+
modalities=args.modalities,
|
| 369 |
+
intersection=args.intersection,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
print(f"\nLoading data for task={task_type}, seed={seed}, mode=finetune")
|
| 373 |
+
train_loader = create_downstream_dataloader(
|
| 374 |
+
excel_path=args.train_excel, labels=[task_type],
|
| 375 |
+
augmentation=True, shuffle=True,
|
| 376 |
+
phase='train', modality_dropout=True, expand_val_combinations=False,
|
| 377 |
+
**loader_kwargs,
|
| 378 |
+
)
|
| 379 |
+
val_loader = create_downstream_dataloader(
|
| 380 |
+
excel_path=args.val_excel, labels=[task_type],
|
| 381 |
+
augmentation=False, shuffle=False,
|
| 382 |
+
phase='val', modality_dropout=False, expand_val_combinations=True,
|
| 383 |
+
**loader_kwargs,
|
| 384 |
+
)
|
| 385 |
+
test_loader = create_downstream_dataloader(
|
| 386 |
+
excel_path=args.test_excel, labels=[task_type],
|
| 387 |
+
augmentation=False, shuffle=False,
|
| 388 |
+
phase='test', modality_dropout=False, expand_val_combinations=False,
|
| 389 |
+
exclusive_modalities=False,
|
| 390 |
+
**loader_kwargs,
|
| 391 |
+
)
|
| 392 |
+
print(f" Train: {len(train_loader.dataset)}, "
|
| 393 |
+
f"Val: {len(val_loader.dataset)}, "
|
| 394 |
+
f"Test: {len(test_loader.dataset)}")
|
| 395 |
+
|
| 396 |
+
# ---- Model ----
|
| 397 |
+
encoder = create_multimae3d(
|
| 398 |
+
img_size=args.img_size,
|
| 399 |
+
patch_size=args.patch_size,
|
| 400 |
+
embed_dim=args.embed_dim,
|
| 401 |
+
depth=args.depth,
|
| 402 |
+
num_heads=args.num_heads,
|
| 403 |
+
decoder_embed_dim=args.decoder_embed_dim,
|
| 404 |
+
decoder_depth=args.decoder_depth,
|
| 405 |
+
decoder_num_heads=args.decoder_num_heads,
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
# Load pretrained weights
|
| 409 |
+
if args.pretrained and os.path.isfile(args.pretrained):
|
| 410 |
+
load_pretrained_weights(encoder, args.pretrained, device='cpu')
|
| 411 |
+
else:
|
| 412 |
+
print(" No pretrained weights loaded (training from scratch)")
|
| 413 |
+
|
| 414 |
+
model = MultiMAE3DForDownstream(
|
| 415 |
+
encoder=encoder,
|
| 416 |
+
embed_dim=args.embed_dim,
|
| 417 |
+
num_outputs=1,
|
| 418 |
+
pool=args.pool,
|
| 419 |
+
dropout=args.dropout,
|
| 420 |
+
).to(device)
|
| 421 |
+
|
| 422 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 423 |
+
|
| 424 |
+
# ---- Freeze encoder if requested ----
|
| 425 |
+
freeze_epochs = getattr(args, 'freeze_epochs', 0)
|
| 426 |
+
if freeze_epochs > 0:
|
| 427 |
+
# Freeze all pretrained encoder parameters
|
| 428 |
+
for param in model.encoder.parameters():
|
| 429 |
+
param.requires_grad = False
|
| 430 |
+
trainable_params = sum(p.numel() for p in model.parameters()
|
| 431 |
+
if p.requires_grad)
|
| 432 |
+
encoder_params = sum(p.numel() for p in model.encoder.parameters())
|
| 433 |
+
print(f" Model: {total_params:,} total, {trainable_params:,} trainable "
|
| 434 |
+
f"(encoder frozen: {encoder_params:,} params for first {freeze_epochs} epochs)")
|
| 435 |
+
else:
|
| 436 |
+
trainable_params = sum(p.numel() for p in model.parameters()
|
| 437 |
+
if p.requires_grad)
|
| 438 |
+
print(f" Model: {total_params:,} total, {trainable_params:,} trainable")
|
| 439 |
+
|
| 440 |
+
# ---- Helper: build optimizer + scheduler ----
|
| 441 |
+
warmup_ep = args.warmup_epochs
|
| 442 |
+
total_ep = args.epochs
|
| 443 |
+
|
| 444 |
+
def build_optimizer_and_scheduler(model, lr, remaining_epochs, warmup):
|
| 445 |
+
trainable = [p for p in model.parameters() if p.requires_grad]
|
| 446 |
+
optimizer = torch.optim.AdamW(trainable, lr=lr,
|
| 447 |
+
weight_decay=args.weight_decay)
|
| 448 |
+
|
| 449 |
+
def lr_lambda(epoch):
|
| 450 |
+
if epoch < warmup:
|
| 451 |
+
return (epoch + 1) / max(warmup, 1)
|
| 452 |
+
progress = (epoch - warmup) / max(remaining_epochs - warmup, 1)
|
| 453 |
+
return 0.5 * (1.0 + np.cos(np.pi * progress))
|
| 454 |
+
|
| 455 |
+
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
|
| 456 |
+
return optimizer, scheduler
|
| 457 |
+
|
| 458 |
+
optimizer, scheduler = build_optimizer_and_scheduler(
|
| 459 |
+
model, args.lr, total_ep, warmup_ep)
|
| 460 |
+
|
| 461 |
+
# Criterion
|
| 462 |
+
criterion = (nn.BCEWithLogitsLoss() if is_cls
|
| 463 |
+
else nn.MSELoss()).to(device)
|
| 464 |
+
|
| 465 |
+
# ---- Training loop ----
|
| 466 |
+
best_metric = 0.0 if is_cls else float('inf')
|
| 467 |
+
best_model_state = None
|
| 468 |
+
patience_counter = 0
|
| 469 |
+
|
| 470 |
+
for epoch in range(total_ep):
|
| 471 |
+
# Unfreeze encoder after freeze_epochs
|
| 472 |
+
if freeze_epochs > 0 and epoch == freeze_epochs:
|
| 473 |
+
print(f"\n >>> Epoch {epoch}: Unfreezing pretrained encoder <<<")
|
| 474 |
+
for param in model.encoder.parameters():
|
| 475 |
+
param.requires_grad = True
|
| 476 |
+
unfrozen_trainable = sum(p.numel() for p in model.parameters()
|
| 477 |
+
if p.requires_grad)
|
| 478 |
+
print(f" Trainable params: {unfrozen_trainable:,} (all parameters)")
|
| 479 |
+
# Rebuild optimizer & scheduler for joint training phase
|
| 480 |
+
remaining = total_ep - epoch
|
| 481 |
+
optimizer, scheduler = build_optimizer_and_scheduler(
|
| 482 |
+
model, args.lr, remaining, warmup_ep)
|
| 483 |
+
print(f" New optimizer created: lr={args.lr}, "
|
| 484 |
+
f"remaining_epochs={remaining}, warmup={warmup_ep}")
|
| 485 |
+
|
| 486 |
+
# Train
|
| 487 |
+
train_loss, tr_preds, tr_labels, tr_probs, _ = run_epoch(
|
| 488 |
+
train_loader, model, criterion, device, task_type,
|
| 489 |
+
is_training=True, optimizer=optimizer,
|
| 490 |
+
)
|
| 491 |
+
# Validate
|
| 492 |
+
val_loss, val_preds, val_labels, val_probs, _ = run_epoch(
|
| 493 |
+
val_loader, model, criterion, device, task_type,
|
| 494 |
+
is_training=False,
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
scheduler.step()
|
| 498 |
+
torch.cuda.empty_cache()
|
| 499 |
+
|
| 500 |
+
# Compute metrics
|
| 501 |
+
if is_cls:
|
| 502 |
+
tr_m = calc_classification_metrics(tr_preds, tr_labels, tr_probs)
|
| 503 |
+
val_m = calc_classification_metrics(val_preds, val_labels, val_probs)
|
| 504 |
+
current = val_m['acc']
|
| 505 |
+
improved = current > best_metric
|
| 506 |
+
else:
|
| 507 |
+
tr_m = calc_regression_metrics(tr_preds, tr_labels)
|
| 508 |
+
val_m = calc_regression_metrics(val_preds, val_labels)
|
| 509 |
+
current = val_m['mae']
|
| 510 |
+
improved = current < best_metric
|
| 511 |
+
|
| 512 |
+
if improved:
|
| 513 |
+
best_metric = current
|
| 514 |
+
best_model_state = deepcopy(model.state_dict())
|
| 515 |
+
patience_counter = 0
|
| 516 |
+
|
| 517 |
+
# Save best checkpoint
|
| 518 |
+
mode_suffix = 'freeze_then_finetune' if freeze_epochs > 0 else 'finetune'
|
| 519 |
+
save_dir = os.path.join(_BASE_DIR, 'saves', f'multimae_{mode_suffix}')
|
| 520 |
+
os.makedirs(save_dir, exist_ok=True)
|
| 521 |
+
task_str = task_type.replace(' ', '_')
|
| 522 |
+
torch.save({
|
| 523 |
+
'epoch': epoch + 1,
|
| 524 |
+
'model_state_dict': best_model_state,
|
| 525 |
+
'task': task_type,
|
| 526 |
+
'seed': seed,
|
| 527 |
+
'best_metric': best_metric,
|
| 528 |
+
'freeze_epochs': freeze_epochs,
|
| 529 |
+
}, os.path.join(save_dir, f'{task_str}_seed_{seed}_best.pth'))
|
| 530 |
+
else:
|
| 531 |
+
patience_counter += 1
|
| 532 |
+
|
| 533 |
+
# Print progress periodically or on improvement
|
| 534 |
+
if (epoch + 1) % 5 == 0 or improved:
|
| 535 |
+
if is_cls:
|
| 536 |
+
print(
|
| 537 |
+
f" Epoch {epoch+1:3d}/{total_ep} | "
|
| 538 |
+
f"TrLoss: {train_loss:.4f}, TrAcc: {tr_m['acc']*100:.1f}% | "
|
| 539 |
+
f"ValAcc: {val_m['acc']*100:.1f}%, "
|
| 540 |
+
f"ValAUC: {val_m['auc']*100:.1f}%"
|
| 541 |
+
f"{' ***' if improved else ''}"
|
| 542 |
+
)
|
| 543 |
+
else:
|
| 544 |
+
print(
|
| 545 |
+
f" Epoch {epoch+1:3d}/{total_ep} | "
|
| 546 |
+
f"TrLoss: {train_loss:.4f}, TrMAE: {tr_m['mae']:.4f} | "
|
| 547 |
+
f"ValMAE: {val_m['mae']:.4f}, "
|
| 548 |
+
f"ValPearson: {val_m['pearson']:.4f}"
|
| 549 |
+
f"{' ***' if improved else ''}"
|
| 550 |
+
)
|
| 551 |
+
|
| 552 |
+
# Early stopping
|
| 553 |
+
if patience_counter >= args.patience:
|
| 554 |
+
print(f" Early stopping at epoch {epoch+1}")
|
| 555 |
+
break
|
| 556 |
+
|
| 557 |
+
# ---- Test evaluation ----
|
| 558 |
+
if best_model_state is not None:
|
| 559 |
+
model.load_state_dict(best_model_state)
|
| 560 |
+
|
| 561 |
+
print("\n Evaluating on test set...")
|
| 562 |
+
test_loss, test_preds, test_labels, test_probs, test_combos = run_epoch(
|
| 563 |
+
test_loader, model, criterion, device, task_type,
|
| 564 |
+
is_training=False,
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
# Overall test metrics
|
| 568 |
+
if is_cls:
|
| 569 |
+
test_m = calc_classification_metrics(
|
| 570 |
+
test_preds, test_labels, test_probs)
|
| 571 |
+
print(
|
| 572 |
+
f" Test: Acc={test_m['acc']*100:.2f}%, "
|
| 573 |
+
f"AUC={test_m['auc']*100:.2f}%, "
|
| 574 |
+
f"Sen={test_m['sensitivity']*100:.2f}%, "
|
| 575 |
+
f"Spe={test_m['specificity']*100:.2f}%, "
|
| 576 |
+
f"F1={test_m['f1']*100:.2f}%"
|
| 577 |
+
)
|
| 578 |
+
else:
|
| 579 |
+
test_m = calc_regression_metrics(test_preds, test_labels)
|
| 580 |
+
print(
|
| 581 |
+
f" Test: MAE={test_m['mae']:.4f}, "
|
| 582 |
+
f"RMSE={test_m['rmse']:.4f}, "
|
| 583 |
+
f"Pearson={test_m['pearson']:.4f}"
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
# Per-modality-combination breakdown
|
| 587 |
+
combo_results = calc_metrics_by_combo(
|
| 588 |
+
test_preds, test_labels, test_probs, test_combos, task_type)
|
| 589 |
+
if combo_results:
|
| 590 |
+
print(f"\n Per-modality-combination results:")
|
| 591 |
+
for combo in sorted(combo_results.keys()):
|
| 592 |
+
r = combo_results[combo]
|
| 593 |
+
n = r['n_samples']
|
| 594 |
+
if is_cls:
|
| 595 |
+
print(f" {combo:25s} (n={n:3d}) | "
|
| 596 |
+
f"Acc={r['acc']*100:.1f}%, AUC={r['auc']*100:.1f}%")
|
| 597 |
+
else:
|
| 598 |
+
print(f" {combo:25s} (n={n:3d}) | "
|
| 599 |
+
f"MAE={r['mae']:.4f}, Pearson={r['pearson']:.4f}")
|
| 600 |
+
|
| 601 |
+
# Save per-combo results to Excel
|
| 602 |
+
freeze_epochs = getattr(args, 'freeze_epochs', 0)
|
| 603 |
+
mode_tag = (f"freeze{freeze_epochs}_finetune"
|
| 604 |
+
if freeze_epochs > 0 else "finetune")
|
| 605 |
+
_save_combo_results(combo_results, task_type, seed, mode_tag, is_cls)
|
| 606 |
+
|
| 607 |
+
# Cleanup
|
| 608 |
+
del model, encoder, optimizer, train_loader, val_loader, test_loader
|
| 609 |
+
del best_model_state
|
| 610 |
+
torch.cuda.empty_cache()
|
| 611 |
+
gc.collect()
|
| 612 |
+
|
| 613 |
+
return test_m
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def _save_combo_results(combo_results, task_type, seed, mode, is_cls):
|
| 617 |
+
"""Save per-modality-combination results to Excel."""
|
| 618 |
+
results_dir = os.path.join(_BASE_DIR, 'results')
|
| 619 |
+
os.makedirs(results_dir, exist_ok=True)
|
| 620 |
+
|
| 621 |
+
rows = []
|
| 622 |
+
for combo in sorted(combo_results.keys()):
|
| 623 |
+
r = combo_results[combo]
|
| 624 |
+
row = {'Modality': combo, 'N': r['n_samples']}
|
| 625 |
+
if is_cls:
|
| 626 |
+
row.update({
|
| 627 |
+
'Acc': r['acc'] * 100,
|
| 628 |
+
'AUC': r['auc'] * 100,
|
| 629 |
+
'Sensitivity': r['sensitivity'] * 100,
|
| 630 |
+
'Specificity': r['specificity'] * 100,
|
| 631 |
+
'F1': r['f1'] * 100,
|
| 632 |
+
})
|
| 633 |
+
else:
|
| 634 |
+
row.update({
|
| 635 |
+
'MAE': r['mae'],
|
| 636 |
+
'RMSE': r['rmse'],
|
| 637 |
+
'Pearson': r['pearson'],
|
| 638 |
+
})
|
| 639 |
+
rows.append(row)
|
| 640 |
+
|
| 641 |
+
task_str = task_type.replace(' ', '_')
|
| 642 |
+
path = os.path.join(
|
| 643 |
+
results_dir,
|
| 644 |
+
f'multimae_{mode}_{task_str}_seed_{seed}_by_combo.xlsx',
|
| 645 |
+
)
|
| 646 |
+
pd.DataFrame(rows).to_excel(path, index=False)
|
| 647 |
+
print(f" Saved: {path}")
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
# =========================================================================
|
| 651 |
+
# Argument parsing
|
| 652 |
+
# =========================================================================
|
| 653 |
+
|
| 654 |
+
def parse_args():
|
| 655 |
+
import argparse
|
| 656 |
+
p = argparse.ArgumentParser(
|
| 657 |
+
description='MultiMAE3D Finetuning for Downstream Tasks')
|
| 658 |
+
|
| 659 |
+
# Mode
|
| 660 |
+
p.add_argument('--mode', type=str, default='finetune',
|
| 661 |
+
choices=['finetune', 'freeze_then_finetune'],
|
| 662 |
+
help='finetune: train all parameters end-to-end; '
|
| 663 |
+
'freeze_then_finetune: freeze encoder for N epochs then unfreeze')
|
| 664 |
+
p.add_argument(
|
| 665 |
+
'--pretrained', type=str,
|
| 666 |
+
default=os.path.join(
|
| 667 |
+
_BASE_DIR, 'pretrain_checkpoints', 'multimae', 'best_model.pth'),
|
| 668 |
+
help='Path to pretrained MultiMAE checkpoint')
|
| 669 |
+
|
| 670 |
+
# Tasks & seeds
|
| 671 |
+
p.add_argument('--tasks', type=str, nargs='+',
|
| 672 |
+
default=['CN vs AD', 'CN vs MCI', 'MMSE', 'AGE'],
|
| 673 |
+
help='Tasks to evaluate')
|
| 674 |
+
p.add_argument('--n_seeds', type=int, default=3,
|
| 675 |
+
help='Number of random seeds per task')
|
| 676 |
+
|
| 677 |
+
# Data
|
| 678 |
+
p.add_argument('--train_excel', type=str,
|
| 679 |
+
default='./data/Downstream/'
|
| 680 |
+
'ADNI_Division/modality_data_train.xlsx')
|
| 681 |
+
p.add_argument('--val_excel', type=str,
|
| 682 |
+
default='./data/Downstream/'
|
| 683 |
+
'ADNI_Division/modality_data_val.xlsx')
|
| 684 |
+
p.add_argument('--test_excel', type=str,
|
| 685 |
+
default='./data/Downstream/'
|
| 686 |
+
'ADNI_Division/modality_data_test.xlsx')
|
| 687 |
+
p.add_argument('--base_dir', type=str,
|
| 688 |
+
default='./data/Downstream/ADNI/')
|
| 689 |
+
p.add_argument('--modalities', type=str, nargs='+',
|
| 690 |
+
default=['T1', 'T2', 'Flair', 'PET'])
|
| 691 |
+
p.add_argument('--intersection', type=str2bool, default=False)
|
| 692 |
+
p.add_argument('--image_size', type=int, nargs=3,
|
| 693 |
+
default=[128, 128, 128])
|
| 694 |
+
p.add_argument('--batch_size', type=int, default=4)
|
| 695 |
+
p.add_argument('--num_workers', type=int, default=8)
|
| 696 |
+
|
| 697 |
+
# MultiMAE encoder architecture (must match pretrained checkpoint)
|
| 698 |
+
p.add_argument('--img_size', type=int, default=128)
|
| 699 |
+
p.add_argument('--patch_size', type=int, default=16)
|
| 700 |
+
p.add_argument('--embed_dim', type=int, default=768)
|
| 701 |
+
p.add_argument('--depth', type=int, default=12)
|
| 702 |
+
p.add_argument('--num_heads', type=int, default=12)
|
| 703 |
+
p.add_argument('--decoder_embed_dim', type=int, default=384)
|
| 704 |
+
p.add_argument('--decoder_depth', type=int, default=2)
|
| 705 |
+
p.add_argument('--decoder_num_heads', type=int, default=12)
|
| 706 |
+
|
| 707 |
+
# Downstream head
|
| 708 |
+
p.add_argument('--pool', type=str, default='cls',
|
| 709 |
+
choices=['cls', 'mean'],
|
| 710 |
+
help='Feature pooling: cls token or mean pool')
|
| 711 |
+
p.add_argument('--dropout', type=float, default=0.1)
|
| 712 |
+
|
| 713 |
+
# Training
|
| 714 |
+
p.add_argument('--epochs', type=int, default=50)
|
| 715 |
+
p.add_argument('--lr', type=float, default=5e-5,
|
| 716 |
+
help='Learning rate')
|
| 717 |
+
p.add_argument('--weight_decay', type=float, default=0.05)
|
| 718 |
+
p.add_argument('--warmup_epochs', type=int, default=5)
|
| 719 |
+
p.add_argument('--patience', type=int, default=15,
|
| 720 |
+
help='Early stopping patience')
|
| 721 |
+
p.add_argument('--freeze_epochs', type=int, default=0,
|
| 722 |
+
help='Number of epochs to freeze pretrained encoder '
|
| 723 |
+
'(0 = no freeze, full finetune from start)')
|
| 724 |
+
|
| 725 |
+
# Device
|
| 726 |
+
p.add_argument('--device', type=int, default=0)
|
| 727 |
+
|
| 728 |
+
return p.parse_args()
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
# =========================================================================
|
| 732 |
+
# Main: loop over tasks x seeds
|
| 733 |
+
# =========================================================================
|
| 734 |
+
|
| 735 |
+
def main():
|
| 736 |
+
args = parse_args()
|
| 737 |
+
device = torch.device(
|
| 738 |
+
f'cuda:{args.device}' if torch.cuda.is_available() else 'cpu')
|
| 739 |
+
|
| 740 |
+
print("=" * 80)
|
| 741 |
+
print(f"MultiMAE3D Downstream Evaluation")
|
| 742 |
+
print(f" Mode : {args.mode}")
|
| 743 |
+
print(f" Tasks : {args.tasks}")
|
| 744 |
+
print(f" Seeds : {args.n_seeds}")
|
| 745 |
+
print(f" Pretrained : {args.pretrained}")
|
| 746 |
+
print(f" Pool : {args.pool}")
|
| 747 |
+
print(f" Device : {device}")
|
| 748 |
+
print(f" LR : {args.lr}")
|
| 749 |
+
print(f" Epochs : {args.epochs}")
|
| 750 |
+
print(f" Batch size : {args.batch_size}")
|
| 751 |
+
if args.freeze_epochs > 0:
|
| 752 |
+
print(f" Freeze epochs: {args.freeze_epochs} (encoder frozen, then joint training)")
|
| 753 |
+
print("=" * 80)
|
| 754 |
+
|
| 755 |
+
# Logger
|
| 756 |
+
log_dir = os.path.join(_BASE_DIR, 'logs')
|
| 757 |
+
logger = setup_logger(log_dir, 'multimae_ft',
|
| 758 |
+
f'multimae_{args.mode}.txt')
|
| 759 |
+
|
| 760 |
+
all_results = {}
|
| 761 |
+
|
| 762 |
+
for task_type in args.tasks:
|
| 763 |
+
print(f"\n{'='*80}")
|
| 764 |
+
print(f"TASK: {task_type}")
|
| 765 |
+
print(f"{'='*80}")
|
| 766 |
+
|
| 767 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 768 |
+
seed_results = []
|
| 769 |
+
|
| 770 |
+
for seed in range(args.n_seeds):
|
| 771 |
+
print(f"\n--- Seed {seed} ---")
|
| 772 |
+
metrics = train_and_evaluate(args, task_type, seed, device)
|
| 773 |
+
seed_results.append(metrics)
|
| 774 |
+
|
| 775 |
+
all_results[task_type] = seed_results
|
| 776 |
+
|
| 777 |
+
# Per-task summary
|
| 778 |
+
print(f"\n{task_type} Summary ({args.n_seeds} seeds):")
|
| 779 |
+
summary_str = f"[{args.mode}] {task_type}: "
|
| 780 |
+
if is_cls:
|
| 781 |
+
for key in ['acc', 'auc', 'sensitivity', 'specificity', 'f1']:
|
| 782 |
+
vals = [r[key] * 100 for r in seed_results]
|
| 783 |
+
msg = f"{np.mean(vals):.2f} +/- {np.std(vals):.2f}%"
|
| 784 |
+
print(f" {key:>12s}: {msg}")
|
| 785 |
+
summary_str += f"{key}={msg}, "
|
| 786 |
+
else:
|
| 787 |
+
for key in ['mae', 'rmse', 'pearson']:
|
| 788 |
+
vals = [r[key] for r in seed_results]
|
| 789 |
+
msg = f"{np.mean(vals):.4f} +/- {np.std(vals):.4f}"
|
| 790 |
+
print(f" {key:>12s}: {msg}")
|
| 791 |
+
summary_str += f"{key}={msg}, "
|
| 792 |
+
logger.info(summary_str)
|
| 793 |
+
|
| 794 |
+
# ---- Final summary table ----
|
| 795 |
+
print("\n" + "=" * 80)
|
| 796 |
+
print("FINAL SUMMARY")
|
| 797 |
+
print("=" * 80)
|
| 798 |
+
|
| 799 |
+
summary_rows = []
|
| 800 |
+
|
| 801 |
+
for task_type in args.tasks:
|
| 802 |
+
results = all_results[task_type]
|
| 803 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 804 |
+
row = {'Task': task_type, 'Mode': args.mode}
|
| 805 |
+
|
| 806 |
+
if is_cls:
|
| 807 |
+
for key in ['acc', 'auc', 'sensitivity', 'specificity', 'f1']:
|
| 808 |
+
vals = [r[key] * 100 for r in results]
|
| 809 |
+
row[f'{key}_mean'] = np.mean(vals)
|
| 810 |
+
row[f'{key}_std'] = np.std(vals)
|
| 811 |
+
row[key] = f"{np.mean(vals):.2f}+/-{np.std(vals):.2f}"
|
| 812 |
+
# Per-seed values
|
| 813 |
+
for i, r in enumerate(results):
|
| 814 |
+
row[f'seed_{i}_acc'] = r['acc'] * 100
|
| 815 |
+
row[f'seed_{i}_auc'] = r['auc'] * 100
|
| 816 |
+
|
| 817 |
+
vals_acc = [r['acc'] * 100 for r in results]
|
| 818 |
+
vals_auc = [r['auc'] * 100 for r in results]
|
| 819 |
+
print(f" {task_type:12s} | "
|
| 820 |
+
f"Acc: {np.mean(vals_acc):.2f}+/-{np.std(vals_acc):.2f}% | "
|
| 821 |
+
f"AUC: {np.mean(vals_auc):.2f}+/-{np.std(vals_auc):.2f}%")
|
| 822 |
+
else:
|
| 823 |
+
for key in ['mae', 'rmse', 'pearson']:
|
| 824 |
+
vals = [r[key] for r in results]
|
| 825 |
+
row[f'{key}_mean'] = np.mean(vals)
|
| 826 |
+
row[f'{key}_std'] = np.std(vals)
|
| 827 |
+
row[key] = f"{np.mean(vals):.4f}+/-{np.std(vals):.4f}"
|
| 828 |
+
for i, r in enumerate(results):
|
| 829 |
+
row[f'seed_{i}_mae'] = r['mae']
|
| 830 |
+
row[f'seed_{i}_pearson'] = r['pearson']
|
| 831 |
+
|
| 832 |
+
vals_mae = [r['mae'] for r in results]
|
| 833 |
+
vals_r = [r['pearson'] for r in results]
|
| 834 |
+
print(f" {task_type:12s} | "
|
| 835 |
+
f"MAE: {np.mean(vals_mae):.4f}+/-{np.std(vals_mae):.4f} | "
|
| 836 |
+
f"Pearson: {np.mean(vals_r):.4f}+/-{np.std(vals_r):.4f}")
|
| 837 |
+
|
| 838 |
+
summary_rows.append(row)
|
| 839 |
+
|
| 840 |
+
# Save summary Excel
|
| 841 |
+
results_dir = os.path.join(_BASE_DIR, 'results')
|
| 842 |
+
os.makedirs(results_dir, exist_ok=True)
|
| 843 |
+
freeze_epochs = getattr(args, 'freeze_epochs', 0)
|
| 844 |
+
summary_tag = (f"freeze{freeze_epochs}_finetune"
|
| 845 |
+
if freeze_epochs > 0 else "finetune")
|
| 846 |
+
summary_path = os.path.join(
|
| 847 |
+
results_dir, f'multimae_{summary_tag}_summary.xlsx')
|
| 848 |
+
pd.DataFrame(summary_rows).to_excel(summary_path, index=False)
|
| 849 |
+
print(f"\nSummary saved to: {summary_path}")
|
| 850 |
+
print("=" * 80)
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
if __name__ == '__main__':
|
| 854 |
+
main()
|
BrainAnytime/models/__init__.py
ADDED
|
File without changes
|
BrainAnytime/models/multimae3d.py
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MultiMAE3D: Multi-modal Masked Autoencoder for 3D Medical Images
|
| 3 |
+
|
| 4 |
+
Architecture:
|
| 5 |
+
- Per-modality input adapters (Conv3D patch embedding)
|
| 6 |
+
- Shared ViT encoder
|
| 7 |
+
- Per-modality output adapters (cross-attn decoder)
|
| 8 |
+
- Handles arbitrary missing modalities via observed mask
|
| 9 |
+
|
| 10 |
+
Based on MultiMAE_reference, simplified for our use case:
|
| 11 |
+
- Fixed input size 128^3, 4 modalities (T1, T2, Flair, PET)
|
| 12 |
+
- Pure reconstruction pretraining (MSE loss)
|
| 13 |
+
- No Hydra/Lightning dependencies
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import copy
|
| 17 |
+
import math
|
| 18 |
+
from typing import Union, Tuple, Dict, List, Optional
|
| 19 |
+
from collections import OrderedDict
|
| 20 |
+
from functools import partial
|
| 21 |
+
|
| 22 |
+
import torch
|
| 23 |
+
import torch.nn as nn
|
| 24 |
+
import torch.nn.functional as F
|
| 25 |
+
from timm.layers import DropPath
|
| 26 |
+
from einops import rearrange
|
| 27 |
+
|
| 28 |
+
from models.multimae3d_utils import (
|
| 29 |
+
to_3tuple,
|
| 30 |
+
calc_patchified_dim,
|
| 31 |
+
patchify,
|
| 32 |
+
unpatchify,
|
| 33 |
+
shuffle_patches,
|
| 34 |
+
unshuffle_patches,
|
| 35 |
+
build_3d_sincos_position_embedding,
|
| 36 |
+
mask_data,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# =============================================================================
|
| 41 |
+
# Input Adapter: Conv3D patch embedding (per modality)
|
| 42 |
+
# =============================================================================
|
| 43 |
+
|
| 44 |
+
class PatchedInputAdapter(nn.Module):
|
| 45 |
+
"""
|
| 46 |
+
Converts a single-channel 3D volume into patch tokens.
|
| 47 |
+
Input: [B, N_selected, 1, pd, ph, pw] (selected shuffled patches)
|
| 48 |
+
Output: [B, N_selected, embed_dim]
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
in_channels: int = 1,
|
| 54 |
+
patch_size: Union[int, Tuple[int, int, int]] = 16,
|
| 55 |
+
embed_dim: int = 768,
|
| 56 |
+
):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.in_channels = in_channels
|
| 59 |
+
self.patch_size = to_3tuple(patch_size)
|
| 60 |
+
self.embed_dim = embed_dim
|
| 61 |
+
|
| 62 |
+
# Conv3D projection: each patch -> embed_dim
|
| 63 |
+
self.proj = nn.Conv3d(
|
| 64 |
+
in_channels,
|
| 65 |
+
embed_dim,
|
| 66 |
+
kernel_size=self.patch_size,
|
| 67 |
+
stride=self.patch_size,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 71 |
+
"""
|
| 72 |
+
x: [B, N, C, pd, ph, pw] selected patches (already patchified & shuffled)
|
| 73 |
+
returns: [B, N, embed_dim]
|
| 74 |
+
"""
|
| 75 |
+
B, N = x.shape[0], x.shape[1]
|
| 76 |
+
# Merge batch and patch dims for Conv3D
|
| 77 |
+
x = rearrange(x, "b n c d h w -> (b n) c d h w")
|
| 78 |
+
x = self.proj(x) # [(B*N), embed_dim, 1, 1, 1]
|
| 79 |
+
x = x.flatten(2) # [(B*N), embed_dim, 1]
|
| 80 |
+
x = x.squeeze(-1) # [(B*N), embed_dim]
|
| 81 |
+
x = rearrange(x, "(b n) d -> b n d", b=B)
|
| 82 |
+
return x
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# =============================================================================
|
| 86 |
+
# Cross Attention (for decoder)
|
| 87 |
+
# =============================================================================
|
| 88 |
+
|
| 89 |
+
class CrossAttention(nn.Module):
|
| 90 |
+
"""Cross attention: query attends to context (encoder output)."""
|
| 91 |
+
|
| 92 |
+
def __init__(self, dim: int, num_heads: int = 8, qkv_bias: bool = True,
|
| 93 |
+
attn_drop: float = 0.0, proj_drop: float = 0.0):
|
| 94 |
+
super().__init__()
|
| 95 |
+
self.num_heads = num_heads
|
| 96 |
+
head_dim = dim // num_heads
|
| 97 |
+
self.scale = head_dim ** -0.5
|
| 98 |
+
|
| 99 |
+
self.q = nn.Linear(dim, dim, bias=qkv_bias)
|
| 100 |
+
self.kv = nn.Linear(dim, dim * 2, bias=qkv_bias)
|
| 101 |
+
self.attn_drop = nn.Dropout(attn_drop)
|
| 102 |
+
self.proj = nn.Linear(dim, dim)
|
| 103 |
+
self.proj_drop = nn.Dropout(proj_drop)
|
| 104 |
+
|
| 105 |
+
def forward(self, x: torch.Tensor, context: torch.Tensor) -> torch.Tensor:
|
| 106 |
+
B, N, C = x.shape
|
| 107 |
+
_, M, _ = context.shape
|
| 108 |
+
|
| 109 |
+
q = self.q(x).reshape(B, N, self.num_heads, C // self.num_heads).permute(0, 2, 1, 3)
|
| 110 |
+
kv = self.kv(context).reshape(B, M, 2, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
|
| 111 |
+
k, v = kv[0], kv[1]
|
| 112 |
+
|
| 113 |
+
attn = (q @ k.transpose(-2, -1)) * self.scale
|
| 114 |
+
attn = attn.softmax(dim=-1)
|
| 115 |
+
attn = self.attn_drop(attn)
|
| 116 |
+
|
| 117 |
+
x = (attn @ v).transpose(1, 2).reshape(B, N, -1)
|
| 118 |
+
x = self.proj(x)
|
| 119 |
+
x = self.proj_drop(x)
|
| 120 |
+
return x
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# =============================================================================
|
| 124 |
+
# Transformer blocks with attention mask support
|
| 125 |
+
# =============================================================================
|
| 126 |
+
|
| 127 |
+
class Mlp(nn.Module):
|
| 128 |
+
"""Simple MLP with GELU activation."""
|
| 129 |
+
|
| 130 |
+
def __init__(self, in_features, hidden_features=None, out_features=None,
|
| 131 |
+
act_layer=nn.GELU, drop=0.):
|
| 132 |
+
super().__init__()
|
| 133 |
+
out_features = out_features or in_features
|
| 134 |
+
hidden_features = hidden_features or in_features
|
| 135 |
+
self.fc1 = nn.Linear(in_features, hidden_features)
|
| 136 |
+
self.act = act_layer()
|
| 137 |
+
self.fc2 = nn.Linear(hidden_features, out_features)
|
| 138 |
+
self.drop = nn.Dropout(drop)
|
| 139 |
+
|
| 140 |
+
def forward(self, x):
|
| 141 |
+
x = self.fc1(x)
|
| 142 |
+
x = self.act(x)
|
| 143 |
+
x = self.drop(x)
|
| 144 |
+
x = self.fc2(x)
|
| 145 |
+
x = self.drop(x)
|
| 146 |
+
return x
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class MaskedAttention(nn.Module):
|
| 150 |
+
"""Multi-head self-attention with optional additive attention mask."""
|
| 151 |
+
|
| 152 |
+
def __init__(self, dim, num_heads=12, qkv_bias=True,
|
| 153 |
+
attn_drop=0., proj_drop=0.):
|
| 154 |
+
super().__init__()
|
| 155 |
+
self.num_heads = num_heads
|
| 156 |
+
head_dim = dim // num_heads
|
| 157 |
+
self.scale = head_dim ** -0.5
|
| 158 |
+
|
| 159 |
+
self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias)
|
| 160 |
+
self.attn_drop = nn.Dropout(attn_drop)
|
| 161 |
+
self.proj = nn.Linear(dim, dim)
|
| 162 |
+
self.proj_drop = nn.Dropout(proj_drop)
|
| 163 |
+
|
| 164 |
+
def forward(self, x, attn_mask=None):
|
| 165 |
+
"""
|
| 166 |
+
x: [B, N, C]
|
| 167 |
+
attn_mask: [B, 1, 1, N] additive mask, -inf for tokens to ignore (column masking)
|
| 168 |
+
"""
|
| 169 |
+
B, N, C = x.shape
|
| 170 |
+
qkv = self.qkv(x).reshape(B, N, 3, self.num_heads, C // self.num_heads).permute(2, 0, 3, 1, 4)
|
| 171 |
+
q, k, v = qkv.unbind(0) # each [B, num_heads, N, head_dim]
|
| 172 |
+
|
| 173 |
+
attn = (q @ k.transpose(-2, -1)) * self.scale # [B, num_heads, N, N]
|
| 174 |
+
if attn_mask is not None:
|
| 175 |
+
attn = attn + attn_mask
|
| 176 |
+
attn = attn.softmax(dim=-1)
|
| 177 |
+
attn = self.attn_drop(attn)
|
| 178 |
+
|
| 179 |
+
x = (attn @ v).transpose(1, 2).reshape(B, N, C)
|
| 180 |
+
x = self.proj(x)
|
| 181 |
+
x = self.proj_drop(x)
|
| 182 |
+
return x
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class MaskedBlock(nn.Module):
|
| 186 |
+
"""Pre-LN Transformer block with optional attention mask support.
|
| 187 |
+
Used for both encoder (with mask) and decoder (without mask).
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
def __init__(self, dim, num_heads, mlp_ratio=4., qkv_bias=True,
|
| 191 |
+
drop_path=0., act_layer=nn.GELU,
|
| 192 |
+
norm_layer=partial(nn.LayerNorm, eps=1e-6)):
|
| 193 |
+
super().__init__()
|
| 194 |
+
self.norm1 = norm_layer(dim)
|
| 195 |
+
self.attn = MaskedAttention(dim, num_heads=num_heads, qkv_bias=qkv_bias)
|
| 196 |
+
self.drop_path = DropPath(drop_path) if drop_path > 0. else nn.Identity()
|
| 197 |
+
self.norm2 = norm_layer(dim)
|
| 198 |
+
mlp_hidden = int(dim * mlp_ratio)
|
| 199 |
+
self.mlp = Mlp(in_features=dim, hidden_features=mlp_hidden, act_layer=act_layer)
|
| 200 |
+
|
| 201 |
+
def forward(self, x, attn_mask=None):
|
| 202 |
+
x = x + self.drop_path(self.attn(self.norm1(x), attn_mask=attn_mask))
|
| 203 |
+
x = x + self.drop_path(self.mlp(self.norm2(x)))
|
| 204 |
+
return x
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# =============================================================================
|
| 208 |
+
# Cross-Modal Predictor (for cross-level mutual prediction)
|
| 209 |
+
# =============================================================================
|
| 210 |
+
|
| 211 |
+
class CrossModalPredictor(nn.Module):
|
| 212 |
+
"""3-layer MLP predictor for cross-modal feature prediction.
|
| 213 |
+
|
| 214 |
+
Maps features from one modality space to another.
|
| 215 |
+
Structure: Linear(D, 2D) → GELU → Linear(2D, 2D) → GELU → Linear(2D, D)
|
| 216 |
+
"""
|
| 217 |
+
|
| 218 |
+
def __init__(self, dim: int):
|
| 219 |
+
super().__init__()
|
| 220 |
+
self.net = nn.Sequential(
|
| 221 |
+
nn.Linear(dim, dim * 2),
|
| 222 |
+
nn.GELU(),
|
| 223 |
+
nn.Linear(dim * 2, dim * 2),
|
| 224 |
+
nn.GELU(),
|
| 225 |
+
nn.Linear(dim * 2, dim),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 229 |
+
return self.net(x)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# =============================================================================
|
| 233 |
+
# Output Adapter: Decoder (per modality)
|
| 234 |
+
# =============================================================================
|
| 235 |
+
|
| 236 |
+
class SpatialOutputAdapter(nn.Module):
|
| 237 |
+
"""
|
| 238 |
+
Per-modality decoder.
|
| 239 |
+
Takes encoder tokens, adds mask tokens, applies cross-attention + self-attention,
|
| 240 |
+
then projects back to patch pixel space.
|
| 241 |
+
|
| 242 |
+
Architecture:
|
| 243 |
+
1. Project encoder tokens from encoder_dim -> decoder_dim
|
| 244 |
+
2. Create mask tokens for masked positions
|
| 245 |
+
3. Add positional embedding to query (mask + selected tokens)
|
| 246 |
+
4. Cross-attention: query attends to encoder context
|
| 247 |
+
5. Self-attention transformer blocks
|
| 248 |
+
6. Linear projection to patch pixel dimension
|
| 249 |
+
"""
|
| 250 |
+
|
| 251 |
+
def __init__(
|
| 252 |
+
self,
|
| 253 |
+
out_channels: int = 1,
|
| 254 |
+
img_size: Union[int, Tuple[int, int, int]] = 128,
|
| 255 |
+
patch_size: Union[int, Tuple[int, int, int]] = 16,
|
| 256 |
+
encoder_embed_dim: int = 768,
|
| 257 |
+
embed_dim: int = 384,
|
| 258 |
+
num_heads: int = 12,
|
| 259 |
+
depth: int = 2,
|
| 260 |
+
mlp_ratio: float = 4.0,
|
| 261 |
+
qkv_bias: bool = True,
|
| 262 |
+
):
|
| 263 |
+
super().__init__()
|
| 264 |
+
self.out_channels = out_channels
|
| 265 |
+
self.img_size = to_3tuple(img_size)
|
| 266 |
+
self.patch_size = to_3tuple(patch_size)
|
| 267 |
+
self.embed_dim = embed_dim
|
| 268 |
+
self.num_heads = num_heads
|
| 269 |
+
self.depth = depth
|
| 270 |
+
|
| 271 |
+
self.patchified_dim = calc_patchified_dim(self.img_size, self.patch_size)
|
| 272 |
+
self.num_patches = self.patchified_dim[0] * self.patchified_dim[1] * self.patchified_dim[2]
|
| 273 |
+
|
| 274 |
+
# Project encoder tokens to decoder dimension
|
| 275 |
+
self.proj_context = nn.Linear(encoder_embed_dim, embed_dim)
|
| 276 |
+
|
| 277 |
+
# Learnable mask token
|
| 278 |
+
self.mask_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
|
| 279 |
+
nn.init.normal_(self.mask_token, std=0.02)
|
| 280 |
+
|
| 281 |
+
# Decoder positional embedding (sincos, frozen)
|
| 282 |
+
self.pos_embed = build_3d_sincos_position_embedding(
|
| 283 |
+
grid_size=self.patchified_dim,
|
| 284 |
+
embed_dim=embed_dim,
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Cross-attention + MLP (MultiMAE style)
|
| 288 |
+
self.xattn = CrossAttention(
|
| 289 |
+
dim=embed_dim, num_heads=num_heads, qkv_bias=qkv_bias,
|
| 290 |
+
)
|
| 291 |
+
norm_layer = partial(nn.LayerNorm, eps=1e-6)
|
| 292 |
+
self.context_norm = norm_layer(embed_dim)
|
| 293 |
+
self.query_norm = norm_layer(embed_dim)
|
| 294 |
+
self.out_norm = norm_layer(embed_dim)
|
| 295 |
+
mlp_hidden = int(embed_dim * mlp_ratio)
|
| 296 |
+
self.mlp = nn.Sequential(
|
| 297 |
+
nn.Linear(embed_dim, mlp_hidden),
|
| 298 |
+
nn.GELU(),
|
| 299 |
+
nn.Linear(mlp_hidden, embed_dim),
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# Self-attention transformer blocks (decoder: no attention mask needed)
|
| 303 |
+
self.blocks = nn.Sequential(*[
|
| 304 |
+
MaskedBlock(
|
| 305 |
+
dim=embed_dim,
|
| 306 |
+
num_heads=num_heads,
|
| 307 |
+
mlp_ratio=mlp_ratio,
|
| 308 |
+
qkv_bias=qkv_bias,
|
| 309 |
+
act_layer=nn.GELU,
|
| 310 |
+
norm_layer=norm_layer,
|
| 311 |
+
)
|
| 312 |
+
for _ in range(depth)
|
| 313 |
+
]) if depth > 0 else nn.Identity()
|
| 314 |
+
|
| 315 |
+
# Output projection: decoder_dim -> patch_pixels
|
| 316 |
+
dim_patch = self.patch_size[0] * self.patch_size[1] * self.patch_size[2] * out_channels
|
| 317 |
+
self.out_proj = nn.Linear(embed_dim, dim_patch)
|
| 318 |
+
|
| 319 |
+
def forward(
|
| 320 |
+
self,
|
| 321 |
+
encoder_tokens: torch.Tensor,
|
| 322 |
+
task_range: Tuple[int, int],
|
| 323 |
+
perm_idx: torch.Tensor,
|
| 324 |
+
num_patches: int,
|
| 325 |
+
) -> torch.Tensor:
|
| 326 |
+
"""
|
| 327 |
+
Args:
|
| 328 |
+
encoder_tokens: [B, total_visible_tokens, encoder_dim] (last layer output)
|
| 329 |
+
task_range: (start, end) indices of this modality's tokens in the concat
|
| 330 |
+
perm_idx: [B, num_patches] permutation indices for this modality
|
| 331 |
+
num_patches: total number of patches for this modality
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
output: [B, num_patches, out_channels, pd, ph, pw] (all patches, unshuffled order)
|
| 335 |
+
"""
|
| 336 |
+
B = encoder_tokens.shape[0]
|
| 337 |
+
|
| 338 |
+
# 1. Project encoder tokens to decoder dim
|
| 339 |
+
context = self.proj_context(encoder_tokens)
|
| 340 |
+
|
| 341 |
+
# 2. Extract this modality's selected tokens from the context
|
| 342 |
+
num_selected = task_range[1] - task_range[0]
|
| 343 |
+
selected_tokens = context[:, task_range[0]:task_range[1]]
|
| 344 |
+
|
| 345 |
+
# 3. Create mask tokens for masked positions
|
| 346 |
+
num_masked = num_patches - num_selected
|
| 347 |
+
mask_tokens = self.mask_token.repeat(B, num_masked, 1)
|
| 348 |
+
|
| 349 |
+
# 4. Concatenate: [selected, masked] in shuffled order
|
| 350 |
+
query = torch.cat([selected_tokens, mask_tokens], dim=1) # [B, num_patches, dim]
|
| 351 |
+
|
| 352 |
+
# 5. Add positional embedding (following the permutation order)
|
| 353 |
+
pos_emb = self.pos_embed.expand(B, -1, -1) # [B, num_patches, dim]
|
| 354 |
+
pos_emb_shuffled = pos_emb[torch.arange(B, device=pos_emb.device)[:, None], perm_idx]
|
| 355 |
+
query = query + pos_emb_shuffled
|
| 356 |
+
|
| 357 |
+
# 6. Cross-attention + MLP
|
| 358 |
+
x = self.xattn(self.query_norm(query), self.context_norm(context))
|
| 359 |
+
x = x + self.mlp(self.out_norm(x))
|
| 360 |
+
|
| 361 |
+
# 7. Self-attention blocks
|
| 362 |
+
if self.depth > 0:
|
| 363 |
+
x = self.blocks(x)
|
| 364 |
+
|
| 365 |
+
# 8. Project to patch pixel space
|
| 366 |
+
x = self.out_proj(x) # [B, num_patches, patch_pixels]
|
| 367 |
+
|
| 368 |
+
# 9. Reshape to patch format
|
| 369 |
+
x = rearrange(
|
| 370 |
+
x,
|
| 371 |
+
"b n (c pd ph pw) -> b n c pd ph pw",
|
| 372 |
+
c=self.out_channels,
|
| 373 |
+
pd=self.patch_size[0],
|
| 374 |
+
ph=self.patch_size[1],
|
| 375 |
+
pw=self.patch_size[2],
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# 10. Unshuffle back to spatial order
|
| 379 |
+
x = unshuffle_patches(x, perm_idx)
|
| 380 |
+
|
| 381 |
+
return x
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
# =============================================================================
|
| 385 |
+
# MultiMAE3D: Main Model
|
| 386 |
+
# =============================================================================
|
| 387 |
+
|
| 388 |
+
class MultiMAE3D(nn.Module):
|
| 389 |
+
"""
|
| 390 |
+
Multi-modal Masked Autoencoder for 3D Medical Images.
|
| 391 |
+
|
| 392 |
+
Handles 4 modalities (T1, T2, Flair, PET) with arbitrary missing modalities.
|
| 393 |
+
|
| 394 |
+
Forward pass:
|
| 395 |
+
1. Split stacked input into per-modality volumes
|
| 396 |
+
2. Patchify and mask each modality (missing → 100% masked)
|
| 397 |
+
3. Tokenize visible patches via per-modality input adapters
|
| 398 |
+
4. Add positional embeddings + CLS token
|
| 399 |
+
5. Concatenate all visible tokens → shared ViT encoder
|
| 400 |
+
6. Per-modality decoder → reconstruct masked patches
|
| 401 |
+
7. Compute MSE loss only on present modalities' masked patches
|
| 402 |
+
"""
|
| 403 |
+
|
| 404 |
+
MODALITY_NAMES = ["T1", "T2", "Flair", "PET"]
|
| 405 |
+
|
| 406 |
+
def __init__(
|
| 407 |
+
self,
|
| 408 |
+
img_size: Union[int, Tuple[int, int, int]] = 128,
|
| 409 |
+
patch_size: Union[int, Tuple[int, int, int]] = 16,
|
| 410 |
+
embed_dim: int = 768,
|
| 411 |
+
depth: int = 12,
|
| 412 |
+
num_heads: int = 12,
|
| 413 |
+
mlp_ratio: float = 4.0,
|
| 414 |
+
decoder_embed_dim: int = 384,
|
| 415 |
+
decoder_depth: int = 2,
|
| 416 |
+
decoder_num_heads: int = 12,
|
| 417 |
+
mask_ratio: float = 0.75,
|
| 418 |
+
use_dirichlet: bool = True,
|
| 419 |
+
dirichlet_alpha: float = 1.0,
|
| 420 |
+
num_global_tokens: int = 1,
|
| 421 |
+
qkv_bias: bool = True,
|
| 422 |
+
drop_path_rate: float = 0.0,
|
| 423 |
+
enable_cross_modal: bool = False,
|
| 424 |
+
):
|
| 425 |
+
super().__init__()
|
| 426 |
+
|
| 427 |
+
self.img_size = to_3tuple(img_size)
|
| 428 |
+
self.patch_size = to_3tuple(patch_size)
|
| 429 |
+
self.embed_dim = embed_dim
|
| 430 |
+
self.depth = depth
|
| 431 |
+
self.mask_ratio = mask_ratio
|
| 432 |
+
self.use_dirichlet = use_dirichlet
|
| 433 |
+
self.dirichlet_alpha = dirichlet_alpha
|
| 434 |
+
self.num_global_tokens = num_global_tokens
|
| 435 |
+
self.enable_cross_modal = enable_cross_modal
|
| 436 |
+
|
| 437 |
+
self.patchified_dim = calc_patchified_dim(self.img_size, self.patch_size)
|
| 438 |
+
self.num_patches = self.patchified_dim[0] * self.patchified_dim[1] * self.patchified_dim[2]
|
| 439 |
+
|
| 440 |
+
# ----- Input adapters (per modality) -----
|
| 441 |
+
self.input_adapters = nn.ModuleDict({
|
| 442 |
+
name: PatchedInputAdapter(
|
| 443 |
+
in_channels=1,
|
| 444 |
+
patch_size=patch_size,
|
| 445 |
+
embed_dim=embed_dim,
|
| 446 |
+
)
|
| 447 |
+
for name in self.MODALITY_NAMES
|
| 448 |
+
})
|
| 449 |
+
|
| 450 |
+
# ----- Encoder positional embedding (sincos, frozen) -----
|
| 451 |
+
self.pos_embed = build_3d_sincos_position_embedding(
|
| 452 |
+
grid_size=self.patchified_dim,
|
| 453 |
+
embed_dim=embed_dim,
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
# ----- CLS token -----
|
| 457 |
+
if num_global_tokens > 0:
|
| 458 |
+
self.global_tokens = nn.Parameter(torch.zeros(num_global_tokens, embed_dim))
|
| 459 |
+
nn.init.normal_(self.global_tokens, std=0.02)
|
| 460 |
+
|
| 461 |
+
# ----- Shared Transformer encoder (ModuleList for attn_mask support) -----
|
| 462 |
+
dpr = [x.item() for x in torch.linspace(0, drop_path_rate, depth)]
|
| 463 |
+
norm_layer = partial(nn.LayerNorm, eps=1e-6)
|
| 464 |
+
self.encoder = nn.ModuleList([
|
| 465 |
+
MaskedBlock(
|
| 466 |
+
dim=embed_dim,
|
| 467 |
+
num_heads=num_heads,
|
| 468 |
+
mlp_ratio=mlp_ratio,
|
| 469 |
+
qkv_bias=qkv_bias,
|
| 470 |
+
drop_path=dpr[i],
|
| 471 |
+
act_layer=nn.GELU,
|
| 472 |
+
norm_layer=norm_layer,
|
| 473 |
+
)
|
| 474 |
+
for i in range(depth)
|
| 475 |
+
])
|
| 476 |
+
|
| 477 |
+
# ----- Output adapters / decoders (per modality) -----
|
| 478 |
+
self.output_adapters = nn.ModuleDict({
|
| 479 |
+
name: SpatialOutputAdapter(
|
| 480 |
+
out_channels=1,
|
| 481 |
+
img_size=img_size,
|
| 482 |
+
patch_size=patch_size,
|
| 483 |
+
encoder_embed_dim=embed_dim,
|
| 484 |
+
embed_dim=decoder_embed_dim,
|
| 485 |
+
num_heads=decoder_num_heads,
|
| 486 |
+
depth=decoder_depth,
|
| 487 |
+
mlp_ratio=mlp_ratio,
|
| 488 |
+
qkv_bias=qkv_bias,
|
| 489 |
+
)
|
| 490 |
+
for name in self.MODALITY_NAMES
|
| 491 |
+
})
|
| 492 |
+
|
| 493 |
+
# Initialize weights
|
| 494 |
+
self._initialize_weights()
|
| 495 |
+
|
| 496 |
+
# ----- Cross-modal mutual prediction components -----
|
| 497 |
+
if self.enable_cross_modal:
|
| 498 |
+
# Teacher encoder (EMA copy of student) — no gradients
|
| 499 |
+
self.teacher_input_adapters = copy.deepcopy(self.input_adapters)
|
| 500 |
+
for p in self.teacher_input_adapters.parameters():
|
| 501 |
+
p.requires_grad = False
|
| 502 |
+
|
| 503 |
+
self.teacher_encoder = copy.deepcopy(self.encoder)
|
| 504 |
+
for p in self.teacher_encoder.parameters():
|
| 505 |
+
p.requires_grad = False
|
| 506 |
+
|
| 507 |
+
# Teacher global tokens stored as buffer (auto-moves with .to(device))
|
| 508 |
+
if self.num_global_tokens > 0:
|
| 509 |
+
self.register_buffer(
|
| 510 |
+
"teacher_global_tokens",
|
| 511 |
+
self.global_tokens.data.clone(),
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
# Cross-modal predictors (student-only, learnable)
|
| 515 |
+
self.predictor_mri_to_pet = CrossModalPredictor(embed_dim)
|
| 516 |
+
self.predictor_pet_to_mri = CrossModalPredictor(embed_dim)
|
| 517 |
+
# Initialize predictor weights
|
| 518 |
+
self.predictor_mri_to_pet.apply(self._init_weights)
|
| 519 |
+
self.predictor_pet_to_mri.apply(self._init_weights)
|
| 520 |
+
|
| 521 |
+
def _initialize_weights(self):
|
| 522 |
+
self.apply(self._init_weights)
|
| 523 |
+
# Special init for Conv3D projection (following MAE)
|
| 524 |
+
for name, m in self.named_modules():
|
| 525 |
+
if isinstance(m, nn.Linear):
|
| 526 |
+
if "qkv" in name:
|
| 527 |
+
val = math.sqrt(6.0 / float(m.weight.shape[0] // 3 + m.weight.shape[1]))
|
| 528 |
+
nn.init.uniform_(m.weight, -val, val)
|
| 529 |
+
elif "kv" in name:
|
| 530 |
+
val = math.sqrt(6.0 / float(m.weight.shape[0] // 2 + m.weight.shape[1]))
|
| 531 |
+
nn.init.uniform_(m.weight, -val, val)
|
| 532 |
+
if isinstance(m, nn.Conv3d):
|
| 533 |
+
if ".proj" in name:
|
| 534 |
+
w = m.weight.data
|
| 535 |
+
nn.init.xavier_uniform_(w.view([w.shape[0], -1]))
|
| 536 |
+
|
| 537 |
+
@staticmethod
|
| 538 |
+
def _init_weights(m):
|
| 539 |
+
if isinstance(m, nn.Linear):
|
| 540 |
+
nn.init.xavier_uniform_(m.weight)
|
| 541 |
+
if m.bias is not None:
|
| 542 |
+
nn.init.constant_(m.bias, 0)
|
| 543 |
+
elif isinstance(m, nn.LayerNorm):
|
| 544 |
+
nn.init.constant_(m.bias, 0)
|
| 545 |
+
nn.init.constant_(m.weight, 1.0)
|
| 546 |
+
|
| 547 |
+
def _split_modalities(self, images: torch.Tensor) -> Dict[str, torch.Tensor]:
|
| 548 |
+
"""Split stacked [B, 4, D, H, W] into per-modality dict {name: [B, 1, D, H, W]}."""
|
| 549 |
+
return {
|
| 550 |
+
name: images[:, i:i+1]
|
| 551 |
+
for i, name in enumerate(self.MODALITY_NAMES)
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
# -----------------------------------------------------------------
|
| 555 |
+
# Cross-modal mutual prediction helpers
|
| 556 |
+
# -----------------------------------------------------------------
|
| 557 |
+
|
| 558 |
+
def _encode_with(
|
| 559 |
+
self,
|
| 560 |
+
selected_patches: Dict[str, torch.Tensor],
|
| 561 |
+
perm_indices: Dict[str, torch.Tensor],
|
| 562 |
+
observed: torch.Tensor,
|
| 563 |
+
input_adapters: nn.ModuleDict,
|
| 564 |
+
global_tokens,
|
| 565 |
+
encoder_blocks: nn.ModuleList,
|
| 566 |
+
):
|
| 567 |
+
"""
|
| 568 |
+
Shared encoding logic used by both student and teacher.
|
| 569 |
+
|
| 570 |
+
Returns:
|
| 571 |
+
encoder_output: [B, total_tokens, D] or None
|
| 572 |
+
task_ranges: OrderedDict {modality_name: (start, end)}
|
| 573 |
+
"""
|
| 574 |
+
B = observed.shape[0]
|
| 575 |
+
device = observed.device
|
| 576 |
+
|
| 577 |
+
tokens = {}
|
| 578 |
+
for name in self.MODALITY_NAMES:
|
| 579 |
+
sel = selected_patches[name]
|
| 580 |
+
if sel.shape[1] == 0:
|
| 581 |
+
continue
|
| 582 |
+
tok = input_adapters[name](sel)
|
| 583 |
+
perm = perm_indices[name]
|
| 584 |
+
pos_emb = self.pos_embed.expand(B, -1, -1)
|
| 585 |
+
pos_emb_selected = pos_emb[
|
| 586 |
+
torch.arange(B, device=device)[:, None], perm[:, :sel.shape[1]]
|
| 587 |
+
]
|
| 588 |
+
tok = tok + pos_emb_selected
|
| 589 |
+
tokens[name] = tok
|
| 590 |
+
|
| 591 |
+
token_list = []
|
| 592 |
+
task_ranges = OrderedDict()
|
| 593 |
+
offset = self.num_global_tokens
|
| 594 |
+
|
| 595 |
+
for name in self.MODALITY_NAMES:
|
| 596 |
+
if name in tokens:
|
| 597 |
+
n_tok = tokens[name].shape[1]
|
| 598 |
+
task_ranges[name] = (offset, offset + n_tok)
|
| 599 |
+
token_list.append(tokens[name])
|
| 600 |
+
offset += n_tok
|
| 601 |
+
else:
|
| 602 |
+
task_ranges[name] = (offset, offset)
|
| 603 |
+
|
| 604 |
+
if len(token_list) == 0:
|
| 605 |
+
return None, task_ranges
|
| 606 |
+
|
| 607 |
+
input_tokens = torch.cat(token_list, dim=1)
|
| 608 |
+
|
| 609 |
+
if self.num_global_tokens > 0 and global_tokens is not None:
|
| 610 |
+
if global_tokens.dim() == 2:
|
| 611 |
+
cls = global_tokens.unsqueeze(0).expand(B, -1, -1)
|
| 612 |
+
else:
|
| 613 |
+
cls = global_tokens.expand(B, -1, -1)
|
| 614 |
+
input_tokens = torch.cat([cls, input_tokens], dim=1)
|
| 615 |
+
|
| 616 |
+
# Column masking for missing modalities
|
| 617 |
+
total_tokens = input_tokens.shape[1]
|
| 618 |
+
attn_mask = torch.zeros(B, 1, 1, total_tokens, device=device)
|
| 619 |
+
for i, name in enumerate(self.MODALITY_NAMES):
|
| 620 |
+
start, end = task_ranges[name]
|
| 621 |
+
if start == end:
|
| 622 |
+
continue
|
| 623 |
+
missing = (observed[:, i] < 0.5)
|
| 624 |
+
if missing.any():
|
| 625 |
+
attn_mask[missing, :, :, start:end] = float("-inf")
|
| 626 |
+
if (attn_mask == 0).all():
|
| 627 |
+
attn_mask = None
|
| 628 |
+
|
| 629 |
+
encoder_output = input_tokens
|
| 630 |
+
for block in encoder_blocks:
|
| 631 |
+
encoder_output = block(encoder_output, attn_mask=attn_mask)
|
| 632 |
+
|
| 633 |
+
return encoder_output, task_ranges
|
| 634 |
+
|
| 635 |
+
def _compute_cross_modal_loss(
|
| 636 |
+
self,
|
| 637 |
+
selected_patches: Dict[str, torch.Tensor],
|
| 638 |
+
perm_indices: Dict[str, torch.Tensor],
|
| 639 |
+
observed: torch.Tensor,
|
| 640 |
+
student_encoder_output: torch.Tensor,
|
| 641 |
+
task_ranges: OrderedDict,
|
| 642 |
+
) -> torch.Tensor:
|
| 643 |
+
"""
|
| 644 |
+
Cross-level mutual prediction loss (simplified global-average-pooling version).
|
| 645 |
+
|
| 646 |
+
Two groups:
|
| 647 |
+
- MRI group: all T1 + T2 + Flair tokens → z_MRI (D-dim vector)
|
| 648 |
+
- PET group: all PET tokens → z_PET (D-dim vector)
|
| 649 |
+
|
| 650 |
+
Predictions (student → teacher):
|
| 651 |
+
- predictor_mri_to_pet(z_MRI_student) → predict z_PET_teacher
|
| 652 |
+
- predictor_pet_to_mri(z_PET_student) → predict z_MRI_teacher
|
| 653 |
+
|
| 654 |
+
Loss: negative cosine similarity, averaged over paired samples only.
|
| 655 |
+
"""
|
| 656 |
+
B = observed.shape[0]
|
| 657 |
+
device = observed.device
|
| 658 |
+
|
| 659 |
+
# Paired = has at least one MRI modality AND PET
|
| 660 |
+
has_mri = (observed[:, :3].sum(dim=1) > 0.5) # [B]
|
| 661 |
+
has_pet = (observed[:, 3] > 0.5) # [B]
|
| 662 |
+
is_paired = has_mri & has_pet # [B]
|
| 663 |
+
|
| 664 |
+
if not is_paired.any():
|
| 665 |
+
return torch.tensor(0.0, device=device, requires_grad=True)
|
| 666 |
+
|
| 667 |
+
# --- Teacher forward (no gradients) ---
|
| 668 |
+
with torch.no_grad():
|
| 669 |
+
teacher_gt = (
|
| 670 |
+
self.teacher_global_tokens
|
| 671 |
+
if self.num_global_tokens > 0 else None
|
| 672 |
+
)
|
| 673 |
+
teacher_output, _ = self._encode_with(
|
| 674 |
+
selected_patches, perm_indices, observed,
|
| 675 |
+
self.teacher_input_adapters, teacher_gt,
|
| 676 |
+
self.teacher_encoder,
|
| 677 |
+
)
|
| 678 |
+
if teacher_output is None:
|
| 679 |
+
return torch.tensor(0.0, device=device, requires_grad=True)
|
| 680 |
+
|
| 681 |
+
# --- Build group masks [B, L] ---
|
| 682 |
+
total_tokens = student_encoder_output.shape[1]
|
| 683 |
+
mri_mask = torch.zeros(B, total_tokens, device=device)
|
| 684 |
+
pet_mask = torch.zeros(B, total_tokens, device=device)
|
| 685 |
+
|
| 686 |
+
# MRI group: T1 (idx 0), T2 (idx 1), Flair (idx 2)
|
| 687 |
+
for idx, name in enumerate(["T1", "T2", "Flair"]):
|
| 688 |
+
start, end = task_ranges[name]
|
| 689 |
+
if start < end:
|
| 690 |
+
mri_mask[:, start:end] = observed[:, idx:idx+1].expand(-1, end - start)
|
| 691 |
+
|
| 692 |
+
# PET group: idx 3
|
| 693 |
+
start, end = task_ranges["PET"]
|
| 694 |
+
if start < end:
|
| 695 |
+
pet_mask[:, start:end] = observed[:, 3:4].expand(-1, end - start)
|
| 696 |
+
|
| 697 |
+
# --- Global average pooling per group ---
|
| 698 |
+
mri_count = mri_mask.sum(dim=1, keepdim=True).clamp(min=1)
|
| 699 |
+
pet_count = pet_mask.sum(dim=1, keepdim=True).clamp(min=1)
|
| 700 |
+
|
| 701 |
+
z_mri_s = (student_encoder_output * mri_mask.unsqueeze(-1)).sum(dim=1) / mri_count # [B, D]
|
| 702 |
+
z_pet_s = (student_encoder_output * pet_mask.unsqueeze(-1)).sum(dim=1) / pet_count # [B, D]
|
| 703 |
+
|
| 704 |
+
z_mri_t = (teacher_output * mri_mask.unsqueeze(-1)).sum(dim=1) / mri_count # [B, D]
|
| 705 |
+
z_pet_t = (teacher_output * pet_mask.unsqueeze(-1)).sum(dim=1) / pet_count # [B, D]
|
| 706 |
+
|
| 707 |
+
# --- L2 normalize onto unit hypersphere ---
|
| 708 |
+
z_mri_s = F.normalize(z_mri_s, dim=-1)
|
| 709 |
+
z_pet_s = F.normalize(z_pet_s, dim=-1)
|
| 710 |
+
z_mri_t = F.normalize(z_mri_t, dim=-1)
|
| 711 |
+
z_pet_t = F.normalize(z_pet_t, dim=-1)
|
| 712 |
+
|
| 713 |
+
# --- Cross-modal predictions + normalize ---
|
| 714 |
+
pred_pet = F.normalize(self.predictor_mri_to_pet(z_mri_s), dim=-1) # [B, D]
|
| 715 |
+
pred_mri = F.normalize(self.predictor_pet_to_mri(z_pet_s), dim=-1) # [B, D]
|
| 716 |
+
|
| 717 |
+
# --- Negative cosine similarity: L = 2 - 2·cos(pred, target) ---
|
| 718 |
+
loss_m2p = 2 - 2 * (pred_pet * z_pet_t.detach()).sum(dim=-1) # [B]
|
| 719 |
+
loss_p2m = 2 - 2 * (pred_mri * z_mri_t.detach()).sum(dim=-1) # [B]
|
| 720 |
+
|
| 721 |
+
# Average only over paired samples
|
| 722 |
+
paired_f = is_paired.float()
|
| 723 |
+
n_paired = paired_f.sum().clamp(min=1)
|
| 724 |
+
|
| 725 |
+
loss_m2p = (loss_m2p * paired_f).sum() / n_paired
|
| 726 |
+
loss_p2m = (loss_p2m * paired_f).sum() / n_paired
|
| 727 |
+
|
| 728 |
+
return 0.5 * (loss_m2p + loss_p2m)
|
| 729 |
+
|
| 730 |
+
@torch.no_grad()
|
| 731 |
+
def update_teacher(self, momentum: float):
|
| 732 |
+
"""EMA update: θ_teacher ← m·θ_teacher + (1-m)·θ_student."""
|
| 733 |
+
if not self.enable_cross_modal:
|
| 734 |
+
return
|
| 735 |
+
|
| 736 |
+
for p_s, p_t in zip(
|
| 737 |
+
self.input_adapters.parameters(),
|
| 738 |
+
self.teacher_input_adapters.parameters(),
|
| 739 |
+
):
|
| 740 |
+
p_t.data.mul_(momentum).add_(p_s.data, alpha=1 - momentum)
|
| 741 |
+
|
| 742 |
+
if self.num_global_tokens > 0:
|
| 743 |
+
self.teacher_global_tokens.mul_(momentum).add_(
|
| 744 |
+
self.global_tokens.data, alpha=1 - momentum
|
| 745 |
+
)
|
| 746 |
+
|
| 747 |
+
for p_s, p_t in zip(
|
| 748 |
+
self.encoder.parameters(),
|
| 749 |
+
self.teacher_encoder.parameters(),
|
| 750 |
+
):
|
| 751 |
+
p_t.data.mul_(momentum).add_(p_s.data, alpha=1 - momentum)
|
| 752 |
+
|
| 753 |
+
@torch.no_grad()
|
| 754 |
+
def init_teacher_from_student(self):
|
| 755 |
+
"""Copy current student weights to teacher (call after loading checkpoint)."""
|
| 756 |
+
if not self.enable_cross_modal:
|
| 757 |
+
return
|
| 758 |
+
|
| 759 |
+
for p_s, p_t in zip(
|
| 760 |
+
self.input_adapters.parameters(),
|
| 761 |
+
self.teacher_input_adapters.parameters(),
|
| 762 |
+
):
|
| 763 |
+
p_t.data.copy_(p_s.data)
|
| 764 |
+
|
| 765 |
+
if self.num_global_tokens > 0:
|
| 766 |
+
self.teacher_global_tokens.copy_(self.global_tokens.data)
|
| 767 |
+
|
| 768 |
+
for p_s, p_t in zip(
|
| 769 |
+
self.encoder.parameters(),
|
| 770 |
+
self.teacher_encoder.parameters(),
|
| 771 |
+
):
|
| 772 |
+
p_t.data.copy_(p_s.data)
|
| 773 |
+
|
| 774 |
+
def forward(
|
| 775 |
+
self,
|
| 776 |
+
images: torch.Tensor,
|
| 777 |
+
observed: torch.Tensor,
|
| 778 |
+
return_loss: bool = True,
|
| 779 |
+
patch_mask_probs: torch.Tensor = None,
|
| 780 |
+
) -> Dict[str, torch.Tensor]:
|
| 781 |
+
"""
|
| 782 |
+
Args:
|
| 783 |
+
images: [B, 4, D, H, W] stacked multi-modal 3D volumes
|
| 784 |
+
observed: [B, 4] float tensor, 1.0=present, 0.0=missing
|
| 785 |
+
return_loss: if True, compute and return reconstruction loss
|
| 786 |
+
patch_mask_probs: optional [N_patches] per-patch masking probability
|
| 787 |
+
from anatomy-aware masking (higher = more likely to be masked)
|
| 788 |
+
|
| 789 |
+
Returns:
|
| 790 |
+
dict with:
|
| 791 |
+
'loss': scalar MSE loss (if return_loss=True)
|
| 792 |
+
'per_modality_loss': {name: loss} for each present modality
|
| 793 |
+
'mask_ratios': {name: float} actual mask ratios used
|
| 794 |
+
"""
|
| 795 |
+
B = images.shape[0]
|
| 796 |
+
device = images.device
|
| 797 |
+
|
| 798 |
+
# 1. Split into per-modality dict
|
| 799 |
+
batch = self._split_modalities(images)
|
| 800 |
+
|
| 801 |
+
# 2. Mask data (patchify + shuffle + split)
|
| 802 |
+
# When patch_mask_probs is provided, uses anatomy-aware weighted sampling
|
| 803 |
+
selected_patches, masked_patches, perm_indices, mask_ratios = mask_data(
|
| 804 |
+
batch=batch,
|
| 805 |
+
modality_names=self.MODALITY_NAMES,
|
| 806 |
+
observed=observed,
|
| 807 |
+
mask_ratio=self.mask_ratio,
|
| 808 |
+
patch_size=self.patch_size,
|
| 809 |
+
use_dirichlet=self.use_dirichlet if self.training else False,
|
| 810 |
+
dirichlet_alpha=self.dirichlet_alpha,
|
| 811 |
+
patch_mask_probs=patch_mask_probs if self.training else None,
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
# 3-6. Student encoding (tokenize → concat → attn mask → encoder)
|
| 815 |
+
encoder_output, task_ranges = self._encode_with(
|
| 816 |
+
selected_patches, perm_indices, observed,
|
| 817 |
+
self.input_adapters, self.global_tokens, self.encoder,
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
if encoder_output is None:
|
| 821 |
+
return {
|
| 822 |
+
"loss": torch.tensor(0.0, device=device),
|
| 823 |
+
"cross_modal_loss": torch.tensor(0.0, device=device),
|
| 824 |
+
"per_modality_loss": {},
|
| 825 |
+
"mask_ratios": mask_ratios,
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
# 7. Per-modality decoder
|
| 829 |
+
reconstructed = {}
|
| 830 |
+
for name in self.MODALITY_NAMES:
|
| 831 |
+
reconstructed[name] = self.output_adapters[name](
|
| 832 |
+
encoder_tokens=encoder_output,
|
| 833 |
+
task_range=task_ranges[name],
|
| 834 |
+
perm_idx=perm_indices[name],
|
| 835 |
+
num_patches=self.num_patches,
|
| 836 |
+
)
|
| 837 |
+
# reconstructed[name]: [B, num_patches, 1, pd, ph, pw] in spatial order
|
| 838 |
+
|
| 839 |
+
# 8. Compute reconstruction loss (MSE, only on present modalities' masked patches)
|
| 840 |
+
if return_loss:
|
| 841 |
+
total_loss = torch.tensor(0.0, device=device)
|
| 842 |
+
per_mod_loss = {}
|
| 843 |
+
num_present = 0
|
| 844 |
+
|
| 845 |
+
for i, name in enumerate(self.MODALITY_NAMES):
|
| 846 |
+
# Only compute loss on present modalities
|
| 847 |
+
mod_observed = observed[:, i] # [B]
|
| 848 |
+
if mod_observed.sum() < 0.5:
|
| 849 |
+
continue
|
| 850 |
+
|
| 851 |
+
# Ground truth: all patches in spatial order
|
| 852 |
+
gt_patches = patchify(batch[name], self.patch_size) # [B, num_patches, 1, pd, ph, pw]
|
| 853 |
+
pred_patches = reconstructed[name] # [B, num_patches, 1, pd, ph, pw]
|
| 854 |
+
|
| 855 |
+
# Create per-patch mask: 1 = masked (should reconstruct), 0 = visible
|
| 856 |
+
perm = perm_indices[name]
|
| 857 |
+
num_selected = selected_patches[name].shape[1]
|
| 858 |
+
# In shuffled order: first num_selected are visible, rest masked
|
| 859 |
+
# Convert to spatial order mask (vectorized, no Python loop)
|
| 860 |
+
mask = torch.ones(B, self.num_patches, device=device)
|
| 861 |
+
if num_selected > 0:
|
| 862 |
+
selected_perm = perm[:, :num_selected] # [B, num_selected]
|
| 863 |
+
mask.scatter_(1, selected_perm, 0.0)
|
| 864 |
+
|
| 865 |
+
# Per-sample observed mask: zero out loss for missing samples
|
| 866 |
+
sample_mask = mod_observed.float() # [B]
|
| 867 |
+
|
| 868 |
+
# Patch normalization (per-patch zero-mean unit-variance, like original MAE)
|
| 869 |
+
gt_mean = gt_patches.mean(dim=(2, 3, 4, 5), keepdim=True)
|
| 870 |
+
gt_var = gt_patches.var(dim=(2, 3, 4, 5), keepdim=True)
|
| 871 |
+
gt_patches_norm = (gt_patches - gt_mean) / (gt_var + 1e-6).sqrt()
|
| 872 |
+
|
| 873 |
+
# Compute MSE on masked patches only (against normalized targets)
|
| 874 |
+
per_patch_mse = ((pred_patches - gt_patches_norm) ** 2).mean(dim=(2, 3, 4, 5)) # [B, num_patches]
|
| 875 |
+
masked_mse = (per_patch_mse * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1) # [B]
|
| 876 |
+
mod_loss = (masked_mse * sample_mask).sum() / sample_mask.sum().clamp(min=1)
|
| 877 |
+
|
| 878 |
+
per_mod_loss[name] = mod_loss
|
| 879 |
+
total_loss = total_loss + mod_loss
|
| 880 |
+
num_present += 1
|
| 881 |
+
|
| 882 |
+
if num_present > 0:
|
| 883 |
+
total_loss = total_loss / num_present
|
| 884 |
+
|
| 885 |
+
# 9. Cross-modal mutual prediction loss
|
| 886 |
+
cross_modal_loss = torch.tensor(0.0, device=device)
|
| 887 |
+
if self.enable_cross_modal:
|
| 888 |
+
cross_modal_loss = self._compute_cross_modal_loss(
|
| 889 |
+
selected_patches, perm_indices, observed,
|
| 890 |
+
encoder_output, task_ranges,
|
| 891 |
+
)
|
| 892 |
+
|
| 893 |
+
return {
|
| 894 |
+
"loss": total_loss,
|
| 895 |
+
"cross_modal_loss": cross_modal_loss,
|
| 896 |
+
"per_modality_loss": per_mod_loss,
|
| 897 |
+
"mask_ratios": mask_ratios,
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
return {
|
| 901 |
+
"reconstructed": reconstructed,
|
| 902 |
+
"cross_modal_loss": torch.tensor(0.0, device=device),
|
| 903 |
+
"mask_ratios": mask_ratios,
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
def encode(
|
| 907 |
+
self,
|
| 908 |
+
images: torch.Tensor,
|
| 909 |
+
observed: torch.Tensor,
|
| 910 |
+
) -> torch.Tensor:
|
| 911 |
+
"""
|
| 912 |
+
Encode without masking (for downstream use).
|
| 913 |
+
Returns encoder output tokens [B, num_global + 4*num_patches, embed_dim].
|
| 914 |
+
"""
|
| 915 |
+
B = images.shape[0]
|
| 916 |
+
device = images.device
|
| 917 |
+
batch = self._split_modalities(images)
|
| 918 |
+
|
| 919 |
+
tokens_list = []
|
| 920 |
+
offset = self.num_global_tokens
|
| 921 |
+
|
| 922 |
+
for i, name in enumerate(self.MODALITY_NAMES):
|
| 923 |
+
img = batch[name] # [B, 1, D, H, W]
|
| 924 |
+
patches = patchify(img, self.patch_size) # [B, num_patches, 1, pd, ph, pw]
|
| 925 |
+
|
| 926 |
+
# Tokenize all patches (no masking)
|
| 927 |
+
tok = self.input_adapters[name](patches) # [B, num_patches, embed_dim]
|
| 928 |
+
|
| 929 |
+
# Add positional embedding
|
| 930 |
+
pos_emb = self.pos_embed.expand(B, -1, -1)
|
| 931 |
+
tok = tok + pos_emb
|
| 932 |
+
|
| 933 |
+
# Zero out tokens for missing modalities
|
| 934 |
+
mod_mask = observed[:, i:i+1].unsqueeze(-1) # [B, 1, 1]
|
| 935 |
+
tok = tok * mod_mask
|
| 936 |
+
|
| 937 |
+
tokens_list.append(tok)
|
| 938 |
+
offset += self.num_patches
|
| 939 |
+
|
| 940 |
+
input_tokens = torch.cat(tokens_list, dim=1)
|
| 941 |
+
|
| 942 |
+
# Add CLS token
|
| 943 |
+
if self.num_global_tokens > 0:
|
| 944 |
+
cls = self.global_tokens.unsqueeze(0).expand(B, -1, -1)
|
| 945 |
+
input_tokens = torch.cat([cls, input_tokens], dim=1)
|
| 946 |
+
|
| 947 |
+
# Build attention mask: prevent attending to tokens from missing modalities
|
| 948 |
+
total_tokens = input_tokens.shape[1]
|
| 949 |
+
attn_mask = torch.zeros(B, 1, 1, total_tokens, device=device)
|
| 950 |
+
mod_offset = self.num_global_tokens
|
| 951 |
+
for i, name in enumerate(self.MODALITY_NAMES):
|
| 952 |
+
start = mod_offset
|
| 953 |
+
end = mod_offset + self.num_patches
|
| 954 |
+
missing = (observed[:, i] < 0.5) # [B]
|
| 955 |
+
if missing.any():
|
| 956 |
+
attn_mask[missing, :, :, start:end] = float("-inf")
|
| 957 |
+
mod_offset = end
|
| 958 |
+
if (attn_mask == 0).all():
|
| 959 |
+
attn_mask = None
|
| 960 |
+
|
| 961 |
+
# Encode with attention mask
|
| 962 |
+
encoder_output = input_tokens
|
| 963 |
+
for block in self.encoder:
|
| 964 |
+
encoder_output = block(encoder_output, attn_mask=attn_mask)
|
| 965 |
+
|
| 966 |
+
return encoder_output
|
| 967 |
+
|
| 968 |
+
|
| 969 |
+
def create_multimae3d(
|
| 970 |
+
img_size: int = 128,
|
| 971 |
+
patch_size: int = 16,
|
| 972 |
+
embed_dim: int = 768,
|
| 973 |
+
depth: int = 12,
|
| 974 |
+
num_heads: int = 12,
|
| 975 |
+
decoder_embed_dim: int = 384,
|
| 976 |
+
decoder_depth: int = 2,
|
| 977 |
+
decoder_num_heads: int = 12,
|
| 978 |
+
mask_ratio: float = 0.75,
|
| 979 |
+
use_dirichlet: bool = True,
|
| 980 |
+
enable_cross_modal: bool = False,
|
| 981 |
+
**kwargs,
|
| 982 |
+
) -> MultiMAE3D:
|
| 983 |
+
"""Factory function to create MultiMAE3D with default ViT-B config."""
|
| 984 |
+
return MultiMAE3D(
|
| 985 |
+
img_size=img_size,
|
| 986 |
+
patch_size=patch_size,
|
| 987 |
+
embed_dim=embed_dim,
|
| 988 |
+
depth=depth,
|
| 989 |
+
num_heads=num_heads,
|
| 990 |
+
decoder_embed_dim=decoder_embed_dim,
|
| 991 |
+
decoder_depth=decoder_depth,
|
| 992 |
+
decoder_num_heads=decoder_num_heads,
|
| 993 |
+
mask_ratio=mask_ratio,
|
| 994 |
+
use_dirichlet=use_dirichlet,
|
| 995 |
+
enable_cross_modal=enable_cross_modal,
|
| 996 |
+
**kwargs,
|
| 997 |
+
)
|
BrainAnytime/models/multimae3d_utils.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MultiMAE 3D Utility Functions
|
| 3 |
+
- Patchify / Unpatchify
|
| 4 |
+
- Patch shuffling for masking
|
| 5 |
+
- 3D sinusoidal positional embeddings
|
| 6 |
+
- Dirichlet masking with missing modality support
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Union, Tuple, Dict, List
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
from torch.distributions import Dirichlet
|
| 14 |
+
from einops import rearrange
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def to_3tuple(x):
|
| 18 |
+
if isinstance(x, (list, tuple)):
|
| 19 |
+
assert len(x) == 3
|
| 20 |
+
return tuple(x)
|
| 21 |
+
return (x, x, x)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def calc_patchified_dim(
|
| 25 |
+
img_size: Union[int, Tuple[int, int, int]],
|
| 26 |
+
patch_size: Union[int, Tuple[int, int, int]],
|
| 27 |
+
) -> Tuple[int, int, int]:
|
| 28 |
+
img_size = to_3tuple(img_size)
|
| 29 |
+
patch_size = to_3tuple(patch_size)
|
| 30 |
+
return tuple(img_size[i] // patch_size[i] for i in range(3))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def patchify(
|
| 34 |
+
image: torch.Tensor,
|
| 35 |
+
patch_size: Union[int, Tuple[int, int, int]],
|
| 36 |
+
) -> torch.Tensor:
|
| 37 |
+
"""
|
| 38 |
+
Convert image to patches.
|
| 39 |
+
image: [B, C, D, H, W]
|
| 40 |
+
returns: [B, num_patches, C, pd, ph, pw]
|
| 41 |
+
"""
|
| 42 |
+
patch_size = to_3tuple(patch_size)
|
| 43 |
+
img_size = image.shape[-3:]
|
| 44 |
+
patchified_dim = calc_patchified_dim(img_size, patch_size)
|
| 45 |
+
patches = rearrange(
|
| 46 |
+
image,
|
| 47 |
+
"b c (nd pd) (nh ph) (nw pw) -> b (nd nh nw) c pd ph pw",
|
| 48 |
+
pd=patch_size[0],
|
| 49 |
+
ph=patch_size[1],
|
| 50 |
+
pw=patch_size[2],
|
| 51 |
+
nd=patchified_dim[0],
|
| 52 |
+
nh=patchified_dim[1],
|
| 53 |
+
nw=patchified_dim[2],
|
| 54 |
+
)
|
| 55 |
+
return patches
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def unpatchify(
|
| 59 |
+
patches: torch.Tensor,
|
| 60 |
+
img_size: Union[int, Tuple[int, int, int]],
|
| 61 |
+
patch_size: Union[int, Tuple[int, int, int]],
|
| 62 |
+
) -> torch.Tensor:
|
| 63 |
+
"""
|
| 64 |
+
Convert patches back to image.
|
| 65 |
+
patches: [B, num_patches, C, pd, ph, pw]
|
| 66 |
+
returns: [B, C, D, H, W]
|
| 67 |
+
"""
|
| 68 |
+
patch_size = to_3tuple(patch_size)
|
| 69 |
+
img_size = to_3tuple(img_size)
|
| 70 |
+
patchified_dim = calc_patchified_dim(img_size, patch_size)
|
| 71 |
+
image = rearrange(
|
| 72 |
+
patches,
|
| 73 |
+
"b (nd nh nw) c pd ph pw -> b c (nd pd) (nh ph) (nw pw)",
|
| 74 |
+
pd=patch_size[0],
|
| 75 |
+
ph=patch_size[1],
|
| 76 |
+
pw=patch_size[2],
|
| 77 |
+
nd=patchified_dim[0],
|
| 78 |
+
nh=patchified_dim[1],
|
| 79 |
+
nw=patchified_dim[2],
|
| 80 |
+
)
|
| 81 |
+
return image
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def shuffle_patches(
|
| 85 |
+
patches: torch.Tensor,
|
| 86 |
+
permutations: torch.Tensor = None,
|
| 87 |
+
mask_probs: torch.Tensor = None,
|
| 88 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 89 |
+
"""
|
| 90 |
+
Shuffle patches along the patch dimension.
|
| 91 |
+
|
| 92 |
+
When mask_probs is None: uniform random shuffle.
|
| 93 |
+
When mask_probs is provided: Gumbel-top-k weighted shuffle.
|
| 94 |
+
Patches with higher mask_probs end up at higher indices (masked),
|
| 95 |
+
patches with lower mask_probs end up at lower indices (visible).
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
patches: [B, N, ...]
|
| 99 |
+
permutations: optional pre-computed permutation indices [B, N]
|
| 100 |
+
mask_probs: optional [N] per-patch masking probability (sums to 1)
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
(shuffled_patches, perm_indices)
|
| 104 |
+
"""
|
| 105 |
+
batch_size, num_patches = patches.shape[0], patches.shape[1]
|
| 106 |
+
if permutations is not None:
|
| 107 |
+
perm_idx = permutations
|
| 108 |
+
else:
|
| 109 |
+
rand = torch.rand(batch_size, num_patches, device=patches.device)
|
| 110 |
+
|
| 111 |
+
if mask_probs is not None:
|
| 112 |
+
# Gumbel-top-k trick for weighted sampling without replacement.
|
| 113 |
+
# key_i = log(p_i) + Gumbel(0,1)_i
|
| 114 |
+
# Top-k of keys = sample from Multinomial(p, k)
|
| 115 |
+
# After ascending argsort: low keys → visible, high keys → masked.
|
| 116 |
+
mask_probs = mask_probs.to(patches.device)
|
| 117 |
+
gumbel = -torch.log(-torch.log(rand.clamp(1e-20, 1.0 - 1e-20)))
|
| 118 |
+
log_probs = torch.log(mask_probs.clamp(min=1e-20)) # [N]
|
| 119 |
+
keys = gumbel + log_probs.unsqueeze(0) # [B, N]
|
| 120 |
+
perm_idx = torch.argsort(keys, dim=1)
|
| 121 |
+
else:
|
| 122 |
+
perm_idx = torch.argsort(rand, dim=1)
|
| 123 |
+
|
| 124 |
+
shuffled = patches[torch.arange(batch_size, device=patches.device)[:, None], perm_idx]
|
| 125 |
+
return shuffled, perm_idx
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def unshuffle_patches(
|
| 129 |
+
patches: torch.Tensor,
|
| 130 |
+
perm_idx: torch.Tensor,
|
| 131 |
+
) -> torch.Tensor:
|
| 132 |
+
"""
|
| 133 |
+
Inverse of shuffle_patches.
|
| 134 |
+
"""
|
| 135 |
+
batch_size = patches.shape[0]
|
| 136 |
+
inv_idx = torch.argsort(perm_idx, dim=1)
|
| 137 |
+
return patches[torch.arange(batch_size, device=patches.device)[:, None], inv_idx]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def build_3d_sincos_position_embedding(
|
| 141 |
+
grid_size: Tuple[int, int, int],
|
| 142 |
+
embed_dim: int,
|
| 143 |
+
temperature: float = 10000.0,
|
| 144 |
+
) -> nn.Parameter:
|
| 145 |
+
"""
|
| 146 |
+
Build 3D sinusoidal positional embedding.
|
| 147 |
+
returns: [1, num_patches, embed_dim] (frozen parameter)
|
| 148 |
+
"""
|
| 149 |
+
grid_size = to_3tuple(grid_size)
|
| 150 |
+
h, w, d = grid_size
|
| 151 |
+
|
| 152 |
+
assert embed_dim % 6 == 0, \
|
| 153 |
+
f"embed_dim ({embed_dim}) must be divisible by 6 for 3D sincos pos embed"
|
| 154 |
+
|
| 155 |
+
pos_dim = embed_dim // 6
|
| 156 |
+
omega = torch.arange(pos_dim, dtype=torch.float32) / pos_dim
|
| 157 |
+
omega = 1.0 / (temperature ** omega)
|
| 158 |
+
|
| 159 |
+
grid_h = torch.arange(h, dtype=torch.float32)
|
| 160 |
+
grid_w = torch.arange(w, dtype=torch.float32)
|
| 161 |
+
grid_d = torch.arange(d, dtype=torch.float32)
|
| 162 |
+
|
| 163 |
+
out_h = torch.einsum("m,d->md", grid_h.flatten(), omega)
|
| 164 |
+
out_w = torch.einsum("m,d->md", grid_w.flatten(), omega)
|
| 165 |
+
out_d = torch.einsum("m,d->md", grid_d.flatten(), omega)
|
| 166 |
+
|
| 167 |
+
# Expand to full grid: [H*W*D, pos_dim] for each axis
|
| 168 |
+
# Use meshgrid ordering to get correct spatial layout
|
| 169 |
+
grid_h_idx, grid_w_idx, grid_d_idx = torch.meshgrid(
|
| 170 |
+
torch.arange(h), torch.arange(w), torch.arange(d), indexing="ij"
|
| 171 |
+
)
|
| 172 |
+
grid_h_flat = grid_h_idx.flatten() # [H*W*D]
|
| 173 |
+
grid_w_flat = grid_w_idx.flatten()
|
| 174 |
+
grid_d_flat = grid_d_idx.flatten()
|
| 175 |
+
|
| 176 |
+
pos_emb = torch.cat([
|
| 177 |
+
torch.sin(out_h[grid_h_flat]),
|
| 178 |
+
torch.cos(out_h[grid_h_flat]),
|
| 179 |
+
torch.sin(out_w[grid_w_flat]),
|
| 180 |
+
torch.cos(out_w[grid_w_flat]),
|
| 181 |
+
torch.sin(out_d[grid_d_flat]),
|
| 182 |
+
torch.cos(out_d[grid_d_flat]),
|
| 183 |
+
], dim=1)[None, :, :] # [1, num_patches, embed_dim]
|
| 184 |
+
|
| 185 |
+
pos_emb = nn.Parameter(pos_emb)
|
| 186 |
+
pos_emb.requires_grad = False
|
| 187 |
+
return pos_emb
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def generate_dirichlet_mask_ratios(
|
| 191 |
+
num_modalities: int,
|
| 192 |
+
alpha: float,
|
| 193 |
+
overall_mask_ratio: float,
|
| 194 |
+
) -> torch.Tensor:
|
| 195 |
+
"""
|
| 196 |
+
Sample per-modality mask ratios from a Dirichlet distribution.
|
| 197 |
+
The total visible budget is distributed among modalities.
|
| 198 |
+
|
| 199 |
+
Returns: [num_modalities] tensor of per-modality mask ratios
|
| 200 |
+
"""
|
| 201 |
+
dirichlet = Dirichlet(torch.tensor([float(alpha)] * num_modalities))
|
| 202 |
+
visible_ratio = 1.0 - overall_mask_ratio
|
| 203 |
+
total_visible = visible_ratio * num_modalities
|
| 204 |
+
visible_per_mod = total_visible * dirichlet.sample()
|
| 205 |
+
# Clamp to [0, 1]
|
| 206 |
+
mask_ratios = (1.0 - visible_per_mod).clamp(0.0, 1.0)
|
| 207 |
+
return mask_ratios
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def compute_mask_ratios(
|
| 211 |
+
modality_names: List[str],
|
| 212 |
+
observed: torch.Tensor,
|
| 213 |
+
mask_ratio: float = 0.75,
|
| 214 |
+
use_dirichlet: bool = True,
|
| 215 |
+
dirichlet_alpha: float = 1.0,
|
| 216 |
+
) -> Dict[str, float]:
|
| 217 |
+
"""
|
| 218 |
+
Compute per-modality mask ratios, respecting observed mask.
|
| 219 |
+
Missing modalities (observed=0) get mask_ratio=1.0.
|
| 220 |
+
Present modalities get Dirichlet or uniform masking.
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
modality_names: list of modality names, e.g. ['T1', 'T2', 'Flair', 'PET']
|
| 224 |
+
observed: [M] bool/float tensor, 1.0=present, 0.0=missing
|
| 225 |
+
NOTE: This is per-sample, called once per sample in the batch.
|
| 226 |
+
For simplicity, we use the same mask ratio for the whole batch
|
| 227 |
+
(based on which modalities are present in the majority of the batch).
|
| 228 |
+
mask_ratio: overall target mask ratio for present modalities
|
| 229 |
+
use_dirichlet: whether to use Dirichlet distribution
|
| 230 |
+
dirichlet_alpha: Dirichlet concentration parameter
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
dict mapping modality_name -> mask_ratio (float)
|
| 234 |
+
"""
|
| 235 |
+
ratios = {}
|
| 236 |
+
present_mods = [name for i, name in enumerate(modality_names) if observed[i] > 0.5]
|
| 237 |
+
missing_mods = [name for i, name in enumerate(modality_names) if observed[i] <= 0.5]
|
| 238 |
+
|
| 239 |
+
# Missing modalities: fully masked
|
| 240 |
+
for name in missing_mods:
|
| 241 |
+
ratios[name] = 1.0
|
| 242 |
+
|
| 243 |
+
# Present modalities: Dirichlet or uniform
|
| 244 |
+
if len(present_mods) > 0:
|
| 245 |
+
if use_dirichlet and len(present_mods) > 1:
|
| 246 |
+
# Dirichlet masking among present modalities
|
| 247 |
+
dir_ratios = generate_dirichlet_mask_ratios(
|
| 248 |
+
num_modalities=len(present_mods),
|
| 249 |
+
alpha=dirichlet_alpha,
|
| 250 |
+
overall_mask_ratio=mask_ratio,
|
| 251 |
+
)
|
| 252 |
+
for i, name in enumerate(present_mods):
|
| 253 |
+
ratios[name] = dir_ratios[i].item()
|
| 254 |
+
else:
|
| 255 |
+
# Uniform masking
|
| 256 |
+
for name in present_mods:
|
| 257 |
+
ratios[name] = mask_ratio
|
| 258 |
+
|
| 259 |
+
return ratios
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def mask_data(
|
| 263 |
+
batch: Dict[str, torch.Tensor],
|
| 264 |
+
modality_names: List[str],
|
| 265 |
+
observed: torch.Tensor,
|
| 266 |
+
mask_ratio: float = 0.75,
|
| 267 |
+
patch_size: Union[int, Tuple[int, int, int]] = 16,
|
| 268 |
+
use_dirichlet: bool = True,
|
| 269 |
+
dirichlet_alpha: float = 1.0,
|
| 270 |
+
patch_mask_probs: torch.Tensor = None,
|
| 271 |
+
) -> Tuple[
|
| 272 |
+
Dict[str, torch.Tensor],
|
| 273 |
+
Dict[str, torch.Tensor],
|
| 274 |
+
Dict[str, torch.Tensor],
|
| 275 |
+
Dict[str, float],
|
| 276 |
+
]:
|
| 277 |
+
"""
|
| 278 |
+
Core masking function for MultiMAE pretraining.
|
| 279 |
+
|
| 280 |
+
For each modality:
|
| 281 |
+
- Patchify the image
|
| 282 |
+
- Shuffle patches (optionally weighted by anatomy importance)
|
| 283 |
+
- Split into selected (visible) and masked based on mask_ratio
|
| 284 |
+
- Missing modalities (observed=0) get 100% masking
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
batch: dict mapping modality name -> [B, 1, D, H, W] tensor
|
| 288 |
+
modality_names: ordered list of modality names
|
| 289 |
+
observed: [B, M] tensor indicating which modalities are present
|
| 290 |
+
mask_ratio: target mask ratio for present modalities
|
| 291 |
+
patch_size: patch size for patchification
|
| 292 |
+
use_dirichlet: whether to use Dirichlet distribution
|
| 293 |
+
dirichlet_alpha: Dirichlet concentration parameter
|
| 294 |
+
patch_mask_probs: optional [N_patches] per-patch masking probability
|
| 295 |
+
from anatomy-aware masking. When provided, uses Gumbel-top-k
|
| 296 |
+
weighted sampling instead of uniform random shuffling.
|
| 297 |
+
Higher probability = more likely to be masked.
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
selected_patches: {modality: [B, num_selected, C, pd, ph, pw]}
|
| 301 |
+
masked_patches: {modality: [B, num_masked, C, pd, ph, pw]}
|
| 302 |
+
perm_indices: {modality: [B, num_patches]}
|
| 303 |
+
mask_ratios: {modality: float}
|
| 304 |
+
"""
|
| 305 |
+
patch_size = to_3tuple(patch_size)
|
| 306 |
+
batch_size = observed.shape[0]
|
| 307 |
+
|
| 308 |
+
# Union strategy: if ANY sample in the batch has a modality, it gets
|
| 309 |
+
# partial masking. Samples where this modality is missing contribute
|
| 310 |
+
# zero-valued patches (harmless in encoder, excluded from loss).
|
| 311 |
+
# This ensures no information is wasted when modalities are present
|
| 312 |
+
# in a minority of samples.
|
| 313 |
+
batch_observed = (observed.max(dim=0).values > 0.5).float() # [M]
|
| 314 |
+
mask_ratios = compute_mask_ratios(
|
| 315 |
+
modality_names=modality_names,
|
| 316 |
+
observed=batch_observed,
|
| 317 |
+
mask_ratio=mask_ratio,
|
| 318 |
+
use_dirichlet=use_dirichlet,
|
| 319 |
+
dirichlet_alpha=dirichlet_alpha,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
selected_patches = {}
|
| 323 |
+
masked_patches = {}
|
| 324 |
+
perm_indices = {}
|
| 325 |
+
|
| 326 |
+
for mod_name in modality_names:
|
| 327 |
+
# Patchify: [B, 1, D, H, W] -> [B, num_patches, 1, pd, ph, pw]
|
| 328 |
+
patches = patchify(batch[mod_name], patch_size)
|
| 329 |
+
num_patches = patches.shape[1]
|
| 330 |
+
|
| 331 |
+
# Shuffle patches (weighted by anatomy importance if provided)
|
| 332 |
+
shuffled, perm_idx = shuffle_patches(patches, mask_probs=patch_mask_probs)
|
| 333 |
+
perm_indices[mod_name] = perm_idx
|
| 334 |
+
|
| 335 |
+
# Split into selected and masked
|
| 336 |
+
mod_mask_ratio = mask_ratios[mod_name]
|
| 337 |
+
num_selected = int((1.0 - mod_mask_ratio) * num_patches)
|
| 338 |
+
# Ensure at least 0 selected (for fully masked modalities)
|
| 339 |
+
num_selected = max(0, num_selected)
|
| 340 |
+
|
| 341 |
+
selected_patches[mod_name] = shuffled[:, :num_selected]
|
| 342 |
+
masked_patches[mod_name] = shuffled[:, num_selected:]
|
| 343 |
+
|
| 344 |
+
return selected_patches, masked_patches, perm_indices, mask_ratios
|
BrainAnytime/pretrain_dataloader_v2.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import nibabel as nib
|
| 5 |
+
import torch
|
| 6 |
+
from torch.utils.data import Dataset, DataLoader
|
| 7 |
+
import torchio as tio
|
| 8 |
+
from typing import List, Dict, Tuple, Optional
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MultiModalPretrainDataset(Dataset):
|
| 13 |
+
"""
|
| 14 |
+
多模态3D医学图像预训练数据集
|
| 15 |
+
|
| 16 |
+
特点:
|
| 17 |
+
- 支持多个数据集(A4, ADNIDOD, AIBL, BraTS, NACC)
|
| 18 |
+
- 缺失模态填充为0,并提供observed_indicator
|
| 19 |
+
- 支持数据增强(Spatial transforms)
|
| 20 |
+
- 支持Modality Dropout增加组合多样性
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
# 统一的模态顺序
|
| 24 |
+
MODALITY_ORDER = ['T1', 'T2', 'Flair', 'PET'] # 统一为4个模态
|
| 25 |
+
|
| 26 |
+
# 每个数据集的模态列名映射到统一名称
|
| 27 |
+
MODALITY_MAPPING = {
|
| 28 |
+
'modality_data_A4.xlsx': {'T1': 'T1', 'T2': 'T2', 'Flair': 'Flair', 'Amy_PET': 'PET'},
|
| 29 |
+
'modality_data_ADNIDOD.xlsx': {'T1': 'T1', 'T2': 'T2', 'Flair': 'Flair', 'PET': 'PET'},
|
| 30 |
+
'modality_data_AIBL.xlsx': {'T1': 'T1', 'T2': 'T2', 'Flair': 'Flair', 'PET': 'PET'},
|
| 31 |
+
'modality_data_BraTS.xlsx': {'T1w': 'T1', 'T2w': 'T2', 'Flair': 'Flair', 'PET': 'PET'},
|
| 32 |
+
'modality_data_NACC.xlsx': {'T1': 'T1', 'T2': 'T2', 'Flair': 'Flair', 'Amyloid': 'PET'},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
# Path prefix replacement: Excel paths use the old server prefix,
|
| 36 |
+
# remap to the local data directory.
|
| 37 |
+
OLD_PATH_PREFIX = "/home/data/Pretrain"
|
| 38 |
+
NEW_PATH_PREFIX = "./data/Pretrain"
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
excel_dir: str = "./data/Match_data_path/pretraining_processed",
|
| 43 |
+
image_size: Tuple[int, int, int] = (128, 128, 128),
|
| 44 |
+
augmentation: bool = True,
|
| 45 |
+
modality_dropout_prob: float = 0.3,
|
| 46 |
+
min_modalities: int = 1,
|
| 47 |
+
cache_data: bool = False,
|
| 48 |
+
):
|
| 49 |
+
"""
|
| 50 |
+
Args:
|
| 51 |
+
excel_dir: Excel文件目录路径
|
| 52 |
+
image_size: 图像尺寸 (D, H, W)
|
| 53 |
+
augmentation: 是否进行数据增强
|
| 54 |
+
modality_dropout_prob: 每个模态被dropout的概率
|
| 55 |
+
min_modalities: 至少保留的模态数量
|
| 56 |
+
cache_data: 是否缓存加载的数据到内存
|
| 57 |
+
"""
|
| 58 |
+
self.excel_dir = excel_dir
|
| 59 |
+
self.image_size = image_size
|
| 60 |
+
self.augmentation = augmentation
|
| 61 |
+
self.modality_dropout_prob = modality_dropout_prob
|
| 62 |
+
self.min_modalities = min_modalities
|
| 63 |
+
self.cache_data = cache_data
|
| 64 |
+
self.cache = {}
|
| 65 |
+
|
| 66 |
+
# 加载所有样本
|
| 67 |
+
self.samples = self._load_all_samples()
|
| 68 |
+
print(f"Loaded {len(self.samples)} samples from {len(self.MODALITY_MAPPING)} datasets")
|
| 69 |
+
|
| 70 |
+
# 初始化数据增强
|
| 71 |
+
if self.augmentation:
|
| 72 |
+
self.spatial_transform = tio.OneOf({
|
| 73 |
+
tio.RandomFlip(axes=0, flip_probability=0.5): 0.33,
|
| 74 |
+
tio.RandomAffine(scales=(0.9, 1.2), degrees=10, p=0.5): 0.33,
|
| 75 |
+
tio.RandomElasticDeformation(
|
| 76 |
+
num_control_points=(10, 10, 10),
|
| 77 |
+
max_displacement=8,
|
| 78 |
+
locked_borders=2,
|
| 79 |
+
p=0.5
|
| 80 |
+
): 0.34,
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
def _load_all_samples(self) -> List[Dict]:
|
| 84 |
+
"""加载所有Excel文件中的样本"""
|
| 85 |
+
samples = []
|
| 86 |
+
|
| 87 |
+
for excel_file, modality_map in self.MODALITY_MAPPING.items():
|
| 88 |
+
excel_path = os.path.join(self.excel_dir, excel_file)
|
| 89 |
+
if not os.path.exists(excel_path):
|
| 90 |
+
print(f"Warning: Excel file not found: {excel_path}")
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
df = pd.read_excel(excel_path)
|
| 94 |
+
dataset_name = excel_file.replace('modality_data_', '').replace('.xlsx', '')
|
| 95 |
+
|
| 96 |
+
for idx, row in df.iterrows():
|
| 97 |
+
sample = {
|
| 98 |
+
'dataset': dataset_name,
|
| 99 |
+
'subject_id': row.get('SubjectID', f'{dataset_name}_{idx}'),
|
| 100 |
+
'modalities': {}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# 映射模态路径
|
| 104 |
+
for orig_col, unified_name in modality_map.items():
|
| 105 |
+
if orig_col in df.columns:
|
| 106 |
+
path = row[orig_col]
|
| 107 |
+
if pd.notna(path) and isinstance(path, str):
|
| 108 |
+
# Remap old server path prefix to local path
|
| 109 |
+
if path.startswith(self.OLD_PATH_PREFIX):
|
| 110 |
+
path = self.NEW_PATH_PREFIX + path[len(self.OLD_PATH_PREFIX):]
|
| 111 |
+
if os.path.exists(path):
|
| 112 |
+
sample['modalities'][unified_name] = path
|
| 113 |
+
|
| 114 |
+
# 只添加至少有一个模态的样本
|
| 115 |
+
if len(sample['modalities']) >= 1:
|
| 116 |
+
samples.append(sample)
|
| 117 |
+
|
| 118 |
+
return samples
|
| 119 |
+
|
| 120 |
+
def _load_nifti(self, path: str) -> np.ndarray:
|
| 121 |
+
"""加载NIfTI文件"""
|
| 122 |
+
try:
|
| 123 |
+
nii = nib.load(path)
|
| 124 |
+
data = nii.get_fdata().astype(np.float32)
|
| 125 |
+
return data
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"Error loading {path}: {e}")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
def _apply_modality_dropout(self, available_modalities: List[str]) -> List[str]:
|
| 131 |
+
"""
|
| 132 |
+
应用Modality Dropout
|
| 133 |
+
随机丢弃一些模态以增加组合多样性
|
| 134 |
+
"""
|
| 135 |
+
if len(available_modalities) <= self.min_modalities:
|
| 136 |
+
return available_modalities
|
| 137 |
+
|
| 138 |
+
kept_modalities = []
|
| 139 |
+
for mod in available_modalities:
|
| 140 |
+
if random.random() > self.modality_dropout_prob:
|
| 141 |
+
kept_modalities.append(mod)
|
| 142 |
+
|
| 143 |
+
# 确保至少保留min_modalities个模态
|
| 144 |
+
if len(kept_modalities) < self.min_modalities:
|
| 145 |
+
# 随机选择需要保留的模态
|
| 146 |
+
kept_modalities = random.sample(available_modalities, self.min_modalities)
|
| 147 |
+
|
| 148 |
+
return kept_modalities
|
| 149 |
+
|
| 150 |
+
def __len__(self) -> int:
|
| 151 |
+
return len(self.samples)
|
| 152 |
+
|
| 153 |
+
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
|
| 154 |
+
sample = self.samples[idx]
|
| 155 |
+
|
| 156 |
+
# 检查缓存
|
| 157 |
+
if self.cache_data and idx in self.cache:
|
| 158 |
+
cached_data = self.cache[idx]
|
| 159 |
+
images = cached_data['images'].clone()
|
| 160 |
+
original_observed = cached_data['observed'].clone()
|
| 161 |
+
else:
|
| 162 |
+
# 初始化输出张量
|
| 163 |
+
num_modalities = len(self.MODALITY_ORDER)
|
| 164 |
+
images = torch.zeros(num_modalities, *self.image_size, dtype=torch.float32)
|
| 165 |
+
original_observed = torch.zeros(num_modalities, dtype=torch.float32)
|
| 166 |
+
|
| 167 |
+
# 加载每个模态
|
| 168 |
+
for i, modality in enumerate(self.MODALITY_ORDER):
|
| 169 |
+
if modality in sample['modalities']:
|
| 170 |
+
path = sample['modalities'][modality]
|
| 171 |
+
data = self._load_nifti(path)
|
| 172 |
+
|
| 173 |
+
if data is not None:
|
| 174 |
+
# 确保数据尺寸正确
|
| 175 |
+
if data.shape == self.image_size:
|
| 176 |
+
images[i] = torch.from_numpy(data)
|
| 177 |
+
original_observed[i] = 1.0
|
| 178 |
+
else:
|
| 179 |
+
print(f"Warning: Size mismatch for {path}, expected {self.image_size}, got {data.shape}")
|
| 180 |
+
|
| 181 |
+
# 缓存数据
|
| 182 |
+
if self.cache_data:
|
| 183 |
+
self.cache[idx] = {
|
| 184 |
+
'images': images.clone(),
|
| 185 |
+
'observed': original_observed.clone()
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
# 不再应用Modality Dropout,直接使用原始observed
|
| 189 |
+
observed = original_observed.clone()
|
| 190 |
+
|
| 191 |
+
# 应用空间数据增强
|
| 192 |
+
if self.augmentation:
|
| 193 |
+
# 只对observed的模态应用增强
|
| 194 |
+
# 创建TorchIO Subject
|
| 195 |
+
subject_dict = {}
|
| 196 |
+
for i, modality in enumerate(self.MODALITY_ORDER):
|
| 197 |
+
if observed[i] == 1.0:
|
| 198 |
+
# TorchIO需要4D张量 (C, D, H, W)
|
| 199 |
+
subject_dict[modality] = tio.ScalarImage(tensor=images[i:i+1])
|
| 200 |
+
|
| 201 |
+
if subject_dict:
|
| 202 |
+
subject = tio.Subject(**subject_dict)
|
| 203 |
+
transformed = self.spatial_transform(subject)
|
| 204 |
+
|
| 205 |
+
# 将增强后的数据放回images张量
|
| 206 |
+
for i, modality in enumerate(self.MODALITY_ORDER):
|
| 207 |
+
if modality in subject_dict:
|
| 208 |
+
images[i] = transformed[modality].data[0]
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
'images': images, # (num_modalities, D, H, W)
|
| 212 |
+
'observed': observed, # (num_modalities,)
|
| 213 |
+
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def create_pretrain_dataloader(
|
| 218 |
+
excel_dir: str = "/home/data/Match_data_path/pretraining_processed",
|
| 219 |
+
batch_size: int = 4,
|
| 220 |
+
num_workers: int = 8,
|
| 221 |
+
augmentation: bool = True,
|
| 222 |
+
modality_dropout_prob: float = 0.3,
|
| 223 |
+
min_modalities: int = 1,
|
| 224 |
+
shuffle: bool = True,
|
| 225 |
+
pin_memory: bool = True,
|
| 226 |
+
cache_data: bool = False,
|
| 227 |
+
) -> DataLoader:
|
| 228 |
+
"""
|
| 229 |
+
创建预训练数据加载器
|
| 230 |
+
|
| 231 |
+
Args:
|
| 232 |
+
excel_dir: Excel文件目录
|
| 233 |
+
batch_size: 批量大小
|
| 234 |
+
num_workers: 数据加载进程数
|
| 235 |
+
augmentation: 是否数据增强
|
| 236 |
+
modality_dropout_prob: 模态dropout概率
|
| 237 |
+
min_modalities: 至少保留的模态数
|
| 238 |
+
shuffle: 是否打乱数据
|
| 239 |
+
pin_memory: 是否使用pinned memory
|
| 240 |
+
cache_data: 是否缓存数据到内存
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
DataLoader实例
|
| 244 |
+
"""
|
| 245 |
+
dataset = MultiModalPretrainDataset(
|
| 246 |
+
excel_dir=excel_dir,
|
| 247 |
+
augmentation=augmentation,
|
| 248 |
+
modality_dropout_prob=modality_dropout_prob,
|
| 249 |
+
min_modalities=min_modalities,
|
| 250 |
+
cache_data=cache_data,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
dataloader = DataLoader(
|
| 254 |
+
dataset,
|
| 255 |
+
batch_size=batch_size,
|
| 256 |
+
shuffle=shuffle,
|
| 257 |
+
num_workers=num_workers,
|
| 258 |
+
pin_memory=pin_memory,
|
| 259 |
+
drop_last=True,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return dataloader
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def collate_fn_with_info(batch: List[Dict]) -> Dict[str, torch.Tensor]:
|
| 266 |
+
"""
|
| 267 |
+
自定义collate函数,处理批量数据
|
| 268 |
+
"""
|
| 269 |
+
images = torch.stack([item['images'] for item in batch])
|
| 270 |
+
observed = torch.stack([item['observed'] for item in batch])
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
'images': images, # (B, num_modalities, D, H, W)
|
| 274 |
+
'observed': observed, # (B, num_modalities)
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
# ============== 使用示例 ==============
|
| 279 |
+
if __name__ == '__main__':
|
| 280 |
+
print("=" * 60)
|
| 281 |
+
print("多模态3D医学图像预训练数据加载器")
|
| 282 |
+
print("=" * 60)
|
| 283 |
+
|
| 284 |
+
# 创建数据加载器
|
| 285 |
+
dataloader = create_pretrain_dataloader(
|
| 286 |
+
excel_dir="/home/data/Match_data_path/pretraining_processed",
|
| 287 |
+
batch_size=2,
|
| 288 |
+
num_workers=4,
|
| 289 |
+
augmentation=True,
|
| 290 |
+
modality_dropout_prob=0.3,
|
| 291 |
+
min_modalities=1,
|
| 292 |
+
shuffle=True,
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
print(f"\n数据集大小: {len(dataloader.dataset)}")
|
| 296 |
+
print(f"批量数: {len(dataloader)}")
|
| 297 |
+
print(f"模态顺序: {MultiModalPretrainDataset.MODALITY_ORDER}")
|
| 298 |
+
|
| 299 |
+
# 测试加载一个批量
|
| 300 |
+
print("\n测试加载一个批量...")
|
| 301 |
+
for batch in dataloader:
|
| 302 |
+
images = batch['images']
|
| 303 |
+
observed = batch['observed']
|
| 304 |
+
|
| 305 |
+
print(f"\n批量数据形状:")
|
| 306 |
+
print(f" images: {images.shape}") # (B, 4, 128, 128, 128)
|
| 307 |
+
print(f" observed: {observed.shape}") # (B, 4)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
print("\n" + "=" * 60)
|
| 311 |
+
print("数据加载测试完成!")
|
| 312 |
+
print("=" * 60)
|
| 313 |
+
|
BrainAnytime/test_main.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
MultiMAE3D Test-Only Evaluation
|
| 4 |
+
|
| 5 |
+
Load saved finetuned checkpoints and evaluate on the test set.
|
| 6 |
+
Reuses model/data/metric utilities from finetune_main.py.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
# Test all tasks for finetune mode
|
| 10 |
+
python test_main.py --mode finetune
|
| 11 |
+
|
| 12 |
+
# Test a specific task
|
| 13 |
+
python test_main.py --mode finetune --tasks "CN vs AD"
|
| 14 |
+
|
| 15 |
+
# Test with custom checkpoint directory
|
| 16 |
+
python test_main.py --mode finetune --checkpoint_dir ./saves/multimae_finetune/
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import gc
|
| 22 |
+
import random
|
| 23 |
+
import warnings
|
| 24 |
+
from collections import defaultdict
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import pandas as pd
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
from tqdm import tqdm
|
| 31 |
+
from scipy.stats import pearsonr
|
| 32 |
+
|
| 33 |
+
warnings.filterwarnings("ignore")
|
| 34 |
+
|
| 35 |
+
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 36 |
+
sys.path.insert(0, _BASE_DIR)
|
| 37 |
+
|
| 38 |
+
from models.multimae3d import create_multimae3d, MultiMAE3D
|
| 39 |
+
from downstream_dataloader import create_downstream_dataloader
|
| 40 |
+
from finetune_main import (
|
| 41 |
+
seed_everything,
|
| 42 |
+
str2bool,
|
| 43 |
+
MultiMAE3DForDownstream,
|
| 44 |
+
run_epoch,
|
| 45 |
+
calc_classification_metrics,
|
| 46 |
+
calc_regression_metrics,
|
| 47 |
+
calc_metrics_by_combo,
|
| 48 |
+
_save_combo_results,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# =========================================================================
|
| 53 |
+
# Test-only evaluation for a single (task, seed)
|
| 54 |
+
# =========================================================================
|
| 55 |
+
|
| 56 |
+
def test_evaluate(args, task_type, seed, device, checkpoint_path):
|
| 57 |
+
"""Load a saved checkpoint and evaluate on test set."""
|
| 58 |
+
seed_everything(seed)
|
| 59 |
+
torch.cuda.empty_cache()
|
| 60 |
+
|
| 61 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 62 |
+
|
| 63 |
+
# ---- Test data loader ----
|
| 64 |
+
loader_kwargs = dict(
|
| 65 |
+
batch_size=args.batch_size,
|
| 66 |
+
num_workers=args.num_workers,
|
| 67 |
+
pin_memory=True,
|
| 68 |
+
cache_data=False,
|
| 69 |
+
image_size=tuple(args.image_size),
|
| 70 |
+
base_dir=args.base_dir,
|
| 71 |
+
modalities=args.modalities,
|
| 72 |
+
intersection=args.intersection,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
print(f"\nLoading test data for task={task_type}, seed={seed}")
|
| 76 |
+
test_loader = create_downstream_dataloader(
|
| 77 |
+
excel_path=args.test_excel, labels=[task_type],
|
| 78 |
+
augmentation=False, shuffle=False,
|
| 79 |
+
phase='test', modality_dropout=False, expand_val_combinations=False,
|
| 80 |
+
exclusive_modalities=False,
|
| 81 |
+
**loader_kwargs,
|
| 82 |
+
)
|
| 83 |
+
print(f" Test: {len(test_loader.dataset)} samples")
|
| 84 |
+
|
| 85 |
+
# ---- Model ----
|
| 86 |
+
encoder = create_multimae3d(
|
| 87 |
+
img_size=args.img_size,
|
| 88 |
+
patch_size=args.patch_size,
|
| 89 |
+
embed_dim=args.embed_dim,
|
| 90 |
+
depth=args.depth,
|
| 91 |
+
num_heads=args.num_heads,
|
| 92 |
+
decoder_embed_dim=args.decoder_embed_dim,
|
| 93 |
+
decoder_depth=args.decoder_depth,
|
| 94 |
+
decoder_num_heads=args.decoder_num_heads,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
model = MultiMAE3DForDownstream(
|
| 98 |
+
encoder=encoder,
|
| 99 |
+
embed_dim=args.embed_dim,
|
| 100 |
+
num_outputs=1,
|
| 101 |
+
pool=args.pool,
|
| 102 |
+
dropout=args.dropout,
|
| 103 |
+
).to(device)
|
| 104 |
+
|
| 105 |
+
# Load checkpoint
|
| 106 |
+
print(f" Loading checkpoint: {checkpoint_path}")
|
| 107 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 108 |
+
model.load_state_dict(ckpt['model_state_dict'])
|
| 109 |
+
print(f" Loaded (epoch={ckpt.get('epoch', '?')}, "
|
| 110 |
+
f"best_metric={ckpt.get('best_metric', '?')})")
|
| 111 |
+
|
| 112 |
+
# Criterion
|
| 113 |
+
criterion = (nn.BCEWithLogitsLoss() if is_cls
|
| 114 |
+
else nn.MSELoss()).to(device)
|
| 115 |
+
|
| 116 |
+
# ---- Test evaluation ----
|
| 117 |
+
print(" Evaluating on test set...")
|
| 118 |
+
test_loss, test_preds, test_labels, test_probs, test_combos = run_epoch(
|
| 119 |
+
test_loader, model, criterion, device, task_type,
|
| 120 |
+
is_training=False,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
# Overall test metrics
|
| 124 |
+
if is_cls:
|
| 125 |
+
test_m = calc_classification_metrics(
|
| 126 |
+
test_preds, test_labels, test_probs)
|
| 127 |
+
print(
|
| 128 |
+
f" Test: Acc={test_m['acc']*100:.2f}%, "
|
| 129 |
+
f"AUC={test_m['auc']*100:.2f}%, "
|
| 130 |
+
f"Sen={test_m['sensitivity']*100:.2f}%, "
|
| 131 |
+
f"Spe={test_m['specificity']*100:.2f}%, "
|
| 132 |
+
f"F1={test_m['f1']*100:.2f}%"
|
| 133 |
+
)
|
| 134 |
+
else:
|
| 135 |
+
test_m = calc_regression_metrics(test_preds, test_labels)
|
| 136 |
+
print(
|
| 137 |
+
f" Test: MAE={test_m['mae']:.4f}, "
|
| 138 |
+
f"RMSE={test_m['rmse']:.4f}, "
|
| 139 |
+
f"Pearson={test_m['pearson']:.4f}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Per-modality-combination breakdown
|
| 143 |
+
combo_results = calc_metrics_by_combo(
|
| 144 |
+
test_preds, test_labels, test_probs, test_combos, task_type)
|
| 145 |
+
if combo_results:
|
| 146 |
+
print(f"\n Per-modality-combination results:")
|
| 147 |
+
for combo in sorted(combo_results.keys()):
|
| 148 |
+
r = combo_results[combo]
|
| 149 |
+
n = r['n_samples']
|
| 150 |
+
if is_cls:
|
| 151 |
+
print(f" {combo:25s} (n={n:3d}) | "
|
| 152 |
+
f"Acc={r['acc']*100:.1f}%, AUC={r['auc']*100:.1f}%")
|
| 153 |
+
else:
|
| 154 |
+
print(f" {combo:25s} (n={n:3d}) | "
|
| 155 |
+
f"MAE={r['mae']:.4f}, Pearson={r['pearson']:.4f}")
|
| 156 |
+
|
| 157 |
+
# Save per-combo results
|
| 158 |
+
mode_tag = args.mode
|
| 159 |
+
_save_combo_results(combo_results, task_type, seed,
|
| 160 |
+
f"test_{mode_tag}", is_cls)
|
| 161 |
+
|
| 162 |
+
# Cleanup
|
| 163 |
+
del model, encoder, test_loader
|
| 164 |
+
torch.cuda.empty_cache()
|
| 165 |
+
gc.collect()
|
| 166 |
+
|
| 167 |
+
return test_m
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# =========================================================================
|
| 171 |
+
# Argument parsing
|
| 172 |
+
# =========================================================================
|
| 173 |
+
|
| 174 |
+
def parse_args():
|
| 175 |
+
import argparse
|
| 176 |
+
p = argparse.ArgumentParser(
|
| 177 |
+
description='MultiMAE3D Test-Only Evaluation')
|
| 178 |
+
|
| 179 |
+
# Mode & checkpoints
|
| 180 |
+
p.add_argument('--mode', type=str, default='finetune',
|
| 181 |
+
choices=['finetune', 'freeze_then_finetune'],
|
| 182 |
+
help='Which training mode checkpoints to load')
|
| 183 |
+
p.add_argument('--checkpoint_dir', type=str, default=None,
|
| 184 |
+
help='Directory containing saved checkpoints. '
|
| 185 |
+
'Defaults to saves/multimae_{mode}/')
|
| 186 |
+
|
| 187 |
+
# Tasks & seeds
|
| 188 |
+
p.add_argument('--tasks', type=str, nargs='+',
|
| 189 |
+
default=['CN vs AD', 'CN vs MCI', 'MMSE', 'AGE'],
|
| 190 |
+
help='Tasks to evaluate')
|
| 191 |
+
p.add_argument('--n_seeds', type=int, default=3,
|
| 192 |
+
help='Number of random seeds per task')
|
| 193 |
+
|
| 194 |
+
# Data
|
| 195 |
+
p.add_argument('--test_excel', type=str,
|
| 196 |
+
default='./data/Downstream/'
|
| 197 |
+
'ADNI_Division/modality_data_test.xlsx')
|
| 198 |
+
p.add_argument('--base_dir', type=str,
|
| 199 |
+
default='./data/Downstream/ADNI/')
|
| 200 |
+
p.add_argument('--modalities', type=str, nargs='+',
|
| 201 |
+
default=['T1', 'T2', 'Flair', 'PET'])
|
| 202 |
+
p.add_argument('--intersection', type=str2bool, default=False)
|
| 203 |
+
p.add_argument('--image_size', type=int, nargs=3,
|
| 204 |
+
default=[128, 128, 128])
|
| 205 |
+
p.add_argument('--batch_size', type=int, default=4)
|
| 206 |
+
p.add_argument('--num_workers', type=int, default=8)
|
| 207 |
+
|
| 208 |
+
# MultiMAE encoder architecture (must match checkpoint)
|
| 209 |
+
p.add_argument('--img_size', type=int, default=128)
|
| 210 |
+
p.add_argument('--patch_size', type=int, default=16)
|
| 211 |
+
p.add_argument('--embed_dim', type=int, default=768)
|
| 212 |
+
p.add_argument('--depth', type=int, default=12)
|
| 213 |
+
p.add_argument('--num_heads', type=int, default=12)
|
| 214 |
+
p.add_argument('--decoder_embed_dim', type=int, default=384)
|
| 215 |
+
p.add_argument('--decoder_depth', type=int, default=2)
|
| 216 |
+
p.add_argument('--decoder_num_heads', type=int, default=12)
|
| 217 |
+
|
| 218 |
+
# Downstream head
|
| 219 |
+
p.add_argument('--pool', type=str, default='cls',
|
| 220 |
+
choices=['cls', 'mean'])
|
| 221 |
+
p.add_argument('--dropout', type=float, default=0.1)
|
| 222 |
+
|
| 223 |
+
# Device
|
| 224 |
+
p.add_argument('--device', type=int, default=0)
|
| 225 |
+
|
| 226 |
+
return p.parse_args()
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# =========================================================================
|
| 230 |
+
# Main
|
| 231 |
+
# =========================================================================
|
| 232 |
+
|
| 233 |
+
def main():
|
| 234 |
+
args = parse_args()
|
| 235 |
+
device = torch.device(
|
| 236 |
+
f'cuda:{args.device}' if torch.cuda.is_available() else 'cpu')
|
| 237 |
+
|
| 238 |
+
# Resolve checkpoint directory
|
| 239 |
+
if args.checkpoint_dir is None:
|
| 240 |
+
args.checkpoint_dir = os.path.join(
|
| 241 |
+
_BASE_DIR, 'saves', f'multimae_{args.mode}')
|
| 242 |
+
|
| 243 |
+
print("=" * 80)
|
| 244 |
+
print(f"MultiMAE3D Test-Only Evaluation")
|
| 245 |
+
print(f" Mode : {args.mode}")
|
| 246 |
+
print(f" Tasks : {args.tasks}")
|
| 247 |
+
print(f" Seeds : {args.n_seeds}")
|
| 248 |
+
print(f" Checkpoint dir : {args.checkpoint_dir}")
|
| 249 |
+
print(f" Test data : {args.test_excel}")
|
| 250 |
+
print(f" Pool : {args.pool}")
|
| 251 |
+
print(f" Device : {device}")
|
| 252 |
+
print("=" * 80)
|
| 253 |
+
|
| 254 |
+
all_results = {}
|
| 255 |
+
|
| 256 |
+
for task_type in args.tasks:
|
| 257 |
+
print(f"\n{'='*80}")
|
| 258 |
+
print(f"TASK: {task_type}")
|
| 259 |
+
print(f"{'='*80}")
|
| 260 |
+
|
| 261 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 262 |
+
seed_results = []
|
| 263 |
+
task_str = task_type.replace(' ', '_')
|
| 264 |
+
|
| 265 |
+
for seed in range(args.n_seeds):
|
| 266 |
+
ckpt_name = f'{task_str}_seed_{seed}_best.pth'
|
| 267 |
+
ckpt_path = os.path.join(args.checkpoint_dir, ckpt_name)
|
| 268 |
+
|
| 269 |
+
if not os.path.isfile(ckpt_path):
|
| 270 |
+
print(f"\n--- Seed {seed} --- SKIPPED (checkpoint not found: {ckpt_name})")
|
| 271 |
+
continue
|
| 272 |
+
|
| 273 |
+
print(f"\n--- Seed {seed} ---")
|
| 274 |
+
metrics = test_evaluate(args, task_type, seed, device, ckpt_path)
|
| 275 |
+
seed_results.append(metrics)
|
| 276 |
+
|
| 277 |
+
if not seed_results:
|
| 278 |
+
print(f" No checkpoints found for {task_type}, skipping.")
|
| 279 |
+
continue
|
| 280 |
+
|
| 281 |
+
all_results[task_type] = seed_results
|
| 282 |
+
|
| 283 |
+
# Per-task summary
|
| 284 |
+
n = len(seed_results)
|
| 285 |
+
print(f"\n{task_type} Summary ({n} seeds):")
|
| 286 |
+
if is_cls:
|
| 287 |
+
for key in ['acc', 'auc', 'sensitivity', 'specificity', 'f1']:
|
| 288 |
+
vals = [r[key] * 100 for r in seed_results]
|
| 289 |
+
print(f" {key:>12s}: {np.mean(vals):.2f} +/- {np.std(vals):.2f}%")
|
| 290 |
+
else:
|
| 291 |
+
for key in ['mae', 'rmse', 'pearson']:
|
| 292 |
+
vals = [r[key] for r in seed_results]
|
| 293 |
+
print(f" {key:>12s}: {np.mean(vals):.4f} +/- {np.std(vals):.4f}")
|
| 294 |
+
|
| 295 |
+
# ---- Final summary table ----
|
| 296 |
+
print("\n" + "=" * 80)
|
| 297 |
+
print("FINAL SUMMARY")
|
| 298 |
+
print("=" * 80)
|
| 299 |
+
|
| 300 |
+
summary_rows = []
|
| 301 |
+
|
| 302 |
+
for task_type in args.tasks:
|
| 303 |
+
if task_type not in all_results:
|
| 304 |
+
continue
|
| 305 |
+
results = all_results[task_type]
|
| 306 |
+
is_cls = task_type in ('CN vs AD', 'CN vs MCI')
|
| 307 |
+
row = {'Task': task_type, 'Mode': args.mode, 'N_seeds': len(results)}
|
| 308 |
+
|
| 309 |
+
if is_cls:
|
| 310 |
+
for key in ['acc', 'auc', 'sensitivity', 'specificity', 'f1']:
|
| 311 |
+
vals = [r[key] * 100 for r in results]
|
| 312 |
+
row[f'{key}_mean'] = np.mean(vals)
|
| 313 |
+
row[f'{key}_std'] = np.std(vals)
|
| 314 |
+
row[key] = f"{np.mean(vals):.2f}+/-{np.std(vals):.2f}"
|
| 315 |
+
for i, r in enumerate(results):
|
| 316 |
+
row[f'seed_{i}_acc'] = r['acc'] * 100
|
| 317 |
+
row[f'seed_{i}_auc'] = r['auc'] * 100
|
| 318 |
+
|
| 319 |
+
vals_acc = [r['acc'] * 100 for r in results]
|
| 320 |
+
vals_auc = [r['auc'] * 100 for r in results]
|
| 321 |
+
print(f" {task_type:12s} | "
|
| 322 |
+
f"Acc: {np.mean(vals_acc):.2f}+/-{np.std(vals_acc):.2f}% | "
|
| 323 |
+
f"AUC: {np.mean(vals_auc):.2f}+/-{np.std(vals_auc):.2f}%")
|
| 324 |
+
else:
|
| 325 |
+
for key in ['mae', 'rmse', 'pearson']:
|
| 326 |
+
vals = [r[key] for r in results]
|
| 327 |
+
row[f'{key}_mean'] = np.mean(vals)
|
| 328 |
+
row[f'{key}_std'] = np.std(vals)
|
| 329 |
+
row[key] = f"{np.mean(vals):.4f}+/-{np.std(vals):.4f}"
|
| 330 |
+
for i, r in enumerate(results):
|
| 331 |
+
row[f'seed_{i}_mae'] = r['mae']
|
| 332 |
+
row[f'seed_{i}_pearson'] = r['pearson']
|
| 333 |
+
|
| 334 |
+
vals_mae = [r['mae'] for r in results]
|
| 335 |
+
vals_r = [r['pearson'] for r in results]
|
| 336 |
+
print(f" {task_type:12s} | "
|
| 337 |
+
f"MAE: {np.mean(vals_mae):.4f}+/-{np.std(vals_mae):.4f} | "
|
| 338 |
+
f"Pearson: {np.mean(vals_r):.4f}+/-{np.std(vals_r):.4f}")
|
| 339 |
+
|
| 340 |
+
summary_rows.append(row)
|
| 341 |
+
|
| 342 |
+
# Save summary Excel
|
| 343 |
+
if summary_rows:
|
| 344 |
+
results_dir = os.path.join(_BASE_DIR, 'results')
|
| 345 |
+
os.makedirs(results_dir, exist_ok=True)
|
| 346 |
+
summary_path = os.path.join(
|
| 347 |
+
results_dir, f'multimae_test_{args.mode}_summary.xlsx')
|
| 348 |
+
pd.DataFrame(summary_rows).to_excel(summary_path, index=False)
|
| 349 |
+
print(f"\nSummary saved to: {summary_path}")
|
| 350 |
+
|
| 351 |
+
print("=" * 80)
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
if __name__ == '__main__':
|
| 355 |
+
main()
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ emoji: 🧠
|
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
python_version: '3.10'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
@@ -14,160 +14,47 @@ models:
|
|
| 14 |
- Simmonstt/BrainAnytime
|
| 15 |
---
|
| 16 |
|
| 17 |
-
# BrainAnytime
|
| 18 |
-
|
| 19 |
-
Official implementation of **BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability**.
|
| 20 |
-
|
| 21 |
-
## Paper
|
| 22 |
|
| 23 |
**BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability**
|
| 24 |
|
| 25 |
-
|
| 26 |
-
- PDF: [https://arxiv.org/pdf/2605.13059](https://arxiv.org/pdf/2605.13059)
|
| 27 |
-
|
| 28 |
-
## Congrats: This paper has been early accepted (top 9%) by MICCAI 2026.
|
| 29 |
-
|
| 30 |
-
## Pretrained Weights
|
| 31 |
-
|
| 32 |
-
**Finetuned checkpoints are available on Hugging Face: [Simmonstt/BrainAnytime](https://huggingface.co/Simmonstt/BrainAnytime).**
|
| 33 |
-
|
| 34 |
-
Pretrained weights are also available at [Google Drive](https://drive.google.com/file/d/1L49zJ_Apj2jJe88_iy6jLcmd6KUlnc5h/view?usp=sharing).
|
| 35 |
-
|
| 36 |
-
## Overview
|
| 37 |
-
|
| 38 |
-
BrainAnytime is a self-supervised pretraining framework for multi-modal 3D brain imaging (T1, T2, Flair, PET) that handles **arbitrary missing modality combinations** at both training and inference time.
|
| 39 |
-
|
| 40 |
-
### Key Features
|
| 41 |
-
|
| 42 |
-
- **Multi-modal Masked Autoencoder (MultiMAE3D)**: Shared ViT encoder with per-modality input/output adapters, supporting 4 modalities (T1, T2, Flair, PET)
|
| 43 |
-
- **Cross-Modal Mutual Prediction**: EMA teacher-student framework for MRI-PET cross-level feature alignment
|
| 44 |
-
- **Anatomy-Aware Adaptive Masking**: Three-phase curriculum masking guided by AAL116 brain atlas and AD-relevant region priors
|
| 45 |
-
- **Missing Modality Robustness**: Handles arbitrary missing modality combinations via attention masking and observed indicators
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
BrainAnytime/
|
| 51 |
-
├── models/
|
| 52 |
-
│ ├── multimae3d.py # MultiMAE3D model architecture
|
| 53 |
-
│ └── multimae3d_utils.py # Patchify, masking, positional embeddings
|
| 54 |
-
├── anatomy_masking.py # Anatomy-aware adaptive masking module
|
| 55 |
-
├── pretrain_dataloader_v2.py # Multi-modal pretraining data loader
|
| 56 |
-
├── train_multimae.py # Pretraining script (single/multi-GPU DDP)
|
| 57 |
-
├── finetune_main.py # Downstream finetuning (CN vs AD, CN vs MCI, MMSE, AGE)
|
| 58 |
-
├── test_main.py # Test-only evaluation
|
| 59 |
-
└── altas/
|
| 60 |
-
└── AAL116_standard.nii.gz # AAL116 brain atlas (128x128x128)
|
| 61 |
-
```
|
| 62 |
-
|
| 63 |
-
## Requirements
|
| 64 |
-
|
| 65 |
-
- Python >= 3.8
|
| 66 |
-
- PyTorch >= 1.12
|
| 67 |
-
- torchio
|
| 68 |
-
- nibabel
|
| 69 |
-
- timm
|
| 70 |
-
- einops
|
| 71 |
-
- tensorboardX
|
| 72 |
-
- scikit-learn
|
| 73 |
-
- pandas
|
| 74 |
-
- scipy
|
| 75 |
-
- tqdm
|
| 76 |
-
|
| 77 |
-
## Data Preparation
|
| 78 |
-
|
| 79 |
-
Organize your data as follows:
|
| 80 |
-
|
| 81 |
-
```
|
| 82 |
-
./data/
|
| 83 |
-
├── Match_data_path/
|
| 84 |
-
│ └── pretraining_processed/ # Pretraining Excel files
|
| 85 |
-
│ ├── modality_data_A4.xlsx
|
| 86 |
-
│ ├── modality_data_ADNIDOD.xlsx
|
| 87 |
-
│ ├── modality_data_AIBL.xlsx
|
| 88 |
-
│ ├── modality_data_BraTS.xlsx
|
| 89 |
-
│ └── modality_data_NACC.xlsx
|
| 90 |
-
├── Pretrain/ # Preprocessed NIfTI files for pretraining
|
| 91 |
-
└── Downstream/
|
| 92 |
-
└── ADNI/ # Downstream task data
|
| 93 |
-
└── ADNI_Division/
|
| 94 |
-
├── modality_data_train.xlsx
|
| 95 |
-
├── modality_data_val.xlsx
|
| 96 |
-
└── modality_data_test.xlsx
|
| 97 |
-
```
|
| 98 |
|
| 99 |
-
|
|
|
|
| 100 |
|
| 101 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
|
| 103 |
-
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
```bash
|
| 110 |
-
# Single GPU
|
| 111 |
-
python train_multimae.py --batch_size 4
|
| 112 |
-
|
| 113 |
-
# Multi-GPU DDP (8 GPUs)
|
| 114 |
-
torchrun --nproc_per_node=8 train_multimae.py \
|
| 115 |
-
--batch_size 16 \
|
| 116 |
-
--enable_cross_modal \
|
| 117 |
-
--use_anatomy_masking \
|
| 118 |
-
--atlas_path altas/AAL116_standard.nii.gz
|
| 119 |
-
```
|
| 120 |
-
|
| 121 |
-
### Downstream Finetuning
|
| 122 |
-
|
| 123 |
-
```bash
|
| 124 |
-
# Finetune on all tasks (3 seeds each)
|
| 125 |
-
python finetune_main.py \
|
| 126 |
-
--pretrained ./pretrain_checkpoints/multimae/best_model.pth
|
| 127 |
-
|
| 128 |
-
# Specific task only
|
| 129 |
-
python finetune_main.py \
|
| 130 |
-
--pretrained ./pretrain_checkpoints/multimae/best_model.pth \
|
| 131 |
-
--tasks "CN vs AD"
|
| 132 |
-
```
|
| 133 |
-
|
| 134 |
-
### Testing
|
| 135 |
-
|
| 136 |
-
```bash
|
| 137 |
-
# Test all tasks for finetune mode
|
| 138 |
-
python test_main.py --mode finetune
|
| 139 |
-
|
| 140 |
-
# Test a specific task
|
| 141 |
-
python test_main.py --mode finetune --tasks "CN vs AD"
|
| 142 |
-
```
|
| 143 |
-
|
| 144 |
-
## Downstream Tasks
|
| 145 |
-
|
| 146 |
-
| Task | Type | Metric |
|
| 147 |
-
|------|------|--------|
|
| 148 |
-
| CN vs AD | Classification | ACC, AUC, Sensitivity, Specificity, F1 |
|
| 149 |
-
| CN vs MCI | Classification | ACC, AUC, Sensitivity, Specificity, F1 |
|
| 150 |
-
| MMSE | Regression | MAE, RMSE, Pearson |
|
| 151 |
-
| AGE | Regression | MAE, RMSE, Pearson |
|
| 152 |
-
|
| 153 |
-
## License
|
| 154 |
-
|
| 155 |
-
This project is released for academic research purposes only.
|
| 156 |
|
| 157 |
## Citation
|
| 158 |
|
| 159 |
-
If you use BrainAnytime in your research, please cite:
|
| 160 |
-
|
| 161 |
```bibtex
|
| 162 |
-
@misc{
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
url={https://arxiv.org/abs/2605.13059},
|
| 170 |
}
|
| 171 |
```
|
| 172 |
-
|
| 173 |
-
Paper page: https://arxiv.org/abs/2605.13059
|
|
|
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
python_version: '3.10'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 14 |
- Simmonstt/BrainAnytime
|
| 15 |
---
|
| 16 |
|
| 17 |
+
# BrainAnytime Demo
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
**BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability**
|
| 20 |
|
| 21 |
+
This Hugging Face Space provides an interactive demo for the BrainAnytime model, which supports:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
- **Multi-modal brain imaging**: T1, T2, Flair, PET
|
| 24 |
+
- **Missing modality robustness**: Handles arbitrary missing modality combinations
|
| 25 |
+
- **Multiple downstream tasks**:
|
| 26 |
+
- CN vs AD classification
|
| 27 |
+
- CN vs MCI classification
|
| 28 |
+
- MMSE score regression
|
| 29 |
+
- Age prediction
|
| 30 |
|
| 31 |
+
## Features
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
+
### Online Inference Demo
|
| 34 |
+
Select a task and modality combination, then run inference on pre-selected ADNI samples.
|
| 35 |
|
| 36 |
+
### Supported Modality Combinations
|
| 37 |
+
- **T**: T1 only
|
| 38 |
+
- **TF**: T1 + Flair
|
| 39 |
+
- **TMF**: T1 + T2 + Flair
|
| 40 |
+
- **TFP**: T1 + Flair + PET
|
| 41 |
+
- **TMFP**: All modalities (Full)
|
| 42 |
|
| 43 |
+
## Links
|
| 44 |
|
| 45 |
+
- **GitHub**: https://github.com/guangqianyang/BrainAnytime
|
| 46 |
+
- **Model Weights**: https://huggingface.co/Simmonstt/BrainAnytime
|
| 47 |
+
- **Paper**: [arXiv:2605.13059](https://arxiv.org/abs/2605.13059)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
## Citation
|
| 50 |
|
|
|
|
|
|
|
| 51 |
```bibtex
|
| 52 |
+
@misc{yang2026brainanytime,
|
| 53 |
+
title={BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability},
|
| 54 |
+
author={Yang, Guangqian and Ding, Tong and Hou, Wenlong and Xun, Yue and Du, Ye and Niu, Qian and Wang, Shujun},
|
| 55 |
+
year={2026},
|
| 56 |
+
eprint={2605.13059},
|
| 57 |
+
archivePrefix={arXiv},
|
| 58 |
+
primaryClass={cs.CV}
|
|
|
|
| 59 |
}
|
| 60 |
```
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,55 +1,532 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
"CN_vs_AD_seed_0_best.pth",
|
| 7 |
-
"CN_vs_MCI_seed_0_best.pth",
|
| 8 |
-
"MMSE_seed_0_best.pth",
|
| 9 |
-
"AGE_seed_0_best.pth",
|
| 10 |
-
]
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
**BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability**
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
"""
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
-
## Available finetuned checkpoints
|
| 32 |
-
{checkpoint_lines}
|
| 33 |
|
| 34 |
-
#
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
- MMSE (regression)
|
| 38 |
-
- AGE (regression)
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
|
| 54 |
if __name__ == "__main__":
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
BrainAnytime Hugging Face Space Demo
|
| 4 |
+
|
| 5 |
+
Interactive demo for brain image analysis with multi-modal support.
|
| 6 |
+
Supports 4 tasks and 5 modality combinations.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import json
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Dict, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
import gradio as gr
|
| 16 |
+
import numpy as np
|
| 17 |
+
from PIL import Image
|
| 18 |
|
| 19 |
+
# 添加当前目录到路径(用于导入 inference_engine)
|
| 20 |
+
BASE_DIR = Path(__file__).parent
|
| 21 |
+
sys.path.insert(0, str(BASE_DIR))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
+
# 导入推理引擎
|
| 24 |
+
try:
|
| 25 |
+
from inference_engine import (
|
| 26 |
+
BrainAnytimeInference,
|
| 27 |
+
TASKS,
|
| 28 |
+
MODALITY_ORDER,
|
| 29 |
+
SHORT_TO_FULL,
|
| 30 |
+
format_result,
|
| 31 |
+
)
|
| 32 |
+
INFERENCE_AVAILABLE = True
|
| 33 |
+
except ImportException as e:
|
| 34 |
+
print(f"Warning: Inference engine not available: {e}")
|
| 35 |
+
INFERENCE_AVAILABLE = False
|
| 36 |
|
|
|
|
| 37 |
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# 配置
|
| 40 |
+
# =============================================================================
|
|
|
|
| 41 |
|
| 42 |
+
# 模态组合(5种实验配置)
|
| 43 |
+
MODALITY_COMBOS = {
|
| 44 |
+
"T": {"name": "T (仅T1)", "modalities": ["T1"]},
|
| 45 |
+
"TF": {"name": "TF (T1+Flair)", "modalities": ["T1", "Flair"]},
|
| 46 |
+
"TMF": {"name": "TMF (T1+T2+Flair)", "modalities": ["T1", "T2", "Flair"]},
|
| 47 |
+
"TFP": {"name": "TFP (T1+Flair+PET)", "modalities": ["T1", "Flair", "PET"]},
|
| 48 |
+
"TMFP": {"name": "TMFP (全模态)", "modalities": ["T1", "T2", "Flair", "PET"]},
|
| 49 |
+
}
|
| 50 |
|
| 51 |
+
# 任务配置
|
| 52 |
+
TASK_CONFIG = {
|
| 53 |
+
"CN_vs_AD": {
|
| 54 |
+
"name": "CN vs AD",
|
| 55 |
+
"type": "classification",
|
| 56 |
+
"description": "区分认知正常 (CN) 与阿尔茨海默病 (AD)",
|
| 57 |
+
"classes": ["CN", "AD"],
|
| 58 |
+
},
|
| 59 |
+
"CN_vs_MCI": {
|
| 60 |
+
"name": "CN vs MCI",
|
| 61 |
+
"type": "classification",
|
| 62 |
+
"description": "区分认知正常 (CN) 与轻度认知障碍 (MCI)",
|
| 63 |
+
"classes": ["CN", "MCI"],
|
| 64 |
+
},
|
| 65 |
+
"MMSE": {
|
| 66 |
+
"name": "MMSE Score",
|
| 67 |
+
"type": "regression",
|
| 68 |
+
"description": "预测 MMSE 认知评分 (10-30分)",
|
| 69 |
+
"unit": "points",
|
| 70 |
+
"range": [10, 30],
|
| 71 |
+
},
|
| 72 |
+
"AGE": {
|
| 73 |
+
"name": "Age Prediction",
|
| 74 |
+
"type": "regression",
|
| 75 |
+
"description": "预测年龄",
|
| 76 |
+
"unit": "years",
|
| 77 |
+
"range": [50, 100],
|
| 78 |
+
},
|
| 79 |
+
}
|
| 80 |
|
| 81 |
+
# 样本数据路径
|
| 82 |
+
SAMPLES_DIR = BASE_DIR / "demo_samples"
|
| 83 |
+
SAMPLES_INDEX = SAMPLES_DIR / "samples.json"
|
| 84 |
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
# =============================================================================
|
| 87 |
+
# 全局状态
|
| 88 |
+
# =============================================================================
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
_inference_engine: Optional[BrainAnytimeInference] = None
|
| 91 |
+
_samples_cache: Optional[Dict] = None
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_inference_engine() -> Optional[BrainAnytimeInference]:
|
| 95 |
+
"""获取或创建推理引擎(单例)"""
|
| 96 |
+
global _inference_engine
|
| 97 |
+
|
| 98 |
+
if _inference_engine is None and INFERENCE_AVAILABLE:
|
| 99 |
+
# 尝试从本地加载,否则从 HF Hub 下载
|
| 100 |
+
checkpoints_dir = os.environ.get(
|
| 101 |
+
"CHECKPOINTS_DIR",
|
| 102 |
+
"/home/23037125r/code/random/multimae_freeze_then_finetune/"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
if not os.path.exists(checkpoints_dir):
|
| 106 |
+
checkpoints_dir = None # 将从 HF Hub 下载
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
_inference_engine = BrainAnytimeInference(
|
| 110 |
+
checkpoints_dir=checkpoints_dir
|
| 111 |
+
)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"Failed to initialize inference engine: {e}")
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
return _inference_engine
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def load_samples_index() -> Optional[Dict]:
|
| 120 |
+
"""加载样本索引"""
|
| 121 |
+
global _samples_cache
|
| 122 |
+
|
| 123 |
+
if _samples_cache is not None:
|
| 124 |
+
return _samples_cache
|
| 125 |
+
|
| 126 |
+
if not SAMPLES_INDEX.exists():
|
| 127 |
+
print(f"Warning: Samples index not found at {SAMPLES_INDEX}")
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
with open(SAMPLES_INDEX, 'r') as f:
|
| 132 |
+
_samples_cache = json.load(f)
|
| 133 |
+
return _samples_cache
|
| 134 |
+
except Exception as e:
|
| 135 |
+
print(f"Error loading samples index: {e}")
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_sample_info(task: str, combo: str) -> Optional[Dict]:
|
| 140 |
+
"""获取指定任务和组合的样本信息"""
|
| 141 |
+
index = load_samples_index()
|
| 142 |
+
if not index:
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
for sample in index.get("samples", []):
|
| 146 |
+
if sample["task"] == task and sample["modality_combination"] == combo:
|
| 147 |
+
return sample
|
| 148 |
+
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def get_preview_images(sample_info: Dict) -> List[Tuple[str, str]]:
|
| 153 |
+
"""获取样本的预览图路径和标题"""
|
| 154 |
+
if not sample_info:
|
| 155 |
+
return []
|
| 156 |
+
|
| 157 |
+
images = []
|
| 158 |
+
sample_dir = SAMPLES_DIR / sample_info["task"] / sample_info["modality_combination"] / sample_info["sample_name"]
|
| 159 |
+
|
| 160 |
+
for mod in MODALITY_ORDER:
|
| 161 |
+
preview_file = sample_info.get("files", {}).get("previews", {}).get(mod)
|
| 162 |
+
if preview_file:
|
| 163 |
+
img_path = sample_dir / preview_file
|
| 164 |
+
if img_path.exists():
|
| 165 |
+
images.append((str(img_path), f"{mod} - Axial Slice"))
|
| 166 |
+
|
| 167 |
+
return images
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# =============================================================================
|
| 171 |
+
# Gradio 回调函数
|
| 172 |
+
# =============================================================================
|
| 173 |
+
|
| 174 |
+
def update_sample_gallery(task: str, combo: str):
|
| 175 |
+
"""更新样本画廊"""
|
| 176 |
+
sample_info = get_sample_info(task, combo)
|
| 177 |
+
|
| 178 |
+
if not sample_info:
|
| 179 |
+
return None, "Sample not found"
|
| 180 |
+
|
| 181 |
+
# 获取预览图
|
| 182 |
+
images = get_preview_images(sample_info)
|
| 183 |
+
|
| 184 |
+
if not images:
|
| 185 |
+
return None, "No preview images available"
|
| 186 |
+
|
| 187 |
+
# 返回第一张图作为代表
|
| 188 |
+
return images[0][0], f"Sample: {sample_info['subject_id']} | Label: {sample_info.get('diag_group', 'N/A')}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def run_inference(task: str, combo: str):
|
| 192 |
+
"""执行推理"""
|
| 193 |
+
engine = get_inference_engine()
|
| 194 |
+
|
| 195 |
+
if not engine:
|
| 196 |
+
return {
|
| 197 |
+
"error": "Inference engine not available. Please check model checkpoints."
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
# 获取样本信息
|
| 201 |
+
sample_info = get_sample_info(task, combo)
|
| 202 |
+
if not sample_info:
|
| 203 |
+
return {"error": f"No sample found for {task}/{combo}"}
|
| 204 |
+
|
| 205 |
+
# 构建样本目录路径
|
| 206 |
+
sample_dir = SAMPLES_DIR / task / combo / sample_info["sample_name"]
|
| 207 |
+
|
| 208 |
+
# 执行推理
|
| 209 |
+
try:
|
| 210 |
+
result = engine.predict_from_sample(str(sample_dir), task, combo)
|
| 211 |
+
|
| 212 |
+
if result is None:
|
| 213 |
+
return {"error": "Inference failed"}
|
| 214 |
+
|
| 215 |
+
return result
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
return {"error": f"Inference error: {str(e)}"}
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def format_prediction(result: Dict) -> str:
|
| 222 |
+
"""格式化预测结果为 Markdown"""
|
| 223 |
+
if "error" in result:
|
| 224 |
+
return f"❌ **Error**: {result['error']}"
|
| 225 |
+
|
| 226 |
+
task = result.get("task", "Unknown")
|
| 227 |
+
task_type = result.get("task_type", "unknown")
|
| 228 |
+
|
| 229 |
+
lines = [
|
| 230 |
+
f"## Prediction Result: {TASK_CONFIG.get(task, {}).get('name', task)}",
|
| 231 |
+
"",
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
if task_type == "classification":
|
| 235 |
+
pred = result.get("prediction", "Unknown")
|
| 236 |
+
prob = result.get("probability", 0)
|
| 237 |
+
conf = result.get("confidence", 0)
|
| 238 |
+
classes = result.get("classes", [])
|
| 239 |
+
|
| 240 |
+
lines.extend([
|
| 241 |
+
f"**Predicted Class**: {pred}",
|
| 242 |
+
"",
|
| 243 |
+
f"**Probability**:",
|
| 244 |
+
f"- {classes[0]}: {1-prob:.3f}",
|
| 245 |
+
f"- {classes[1]}: {prob:.3f}",
|
| 246 |
+
"",
|
| 247 |
+
f"**Confidence**: {conf:.1%}",
|
| 248 |
+
])
|
| 249 |
+
else:
|
| 250 |
+
pred = result.get("prediction", 0)
|
| 251 |
+
unit = result.get("unit", "")
|
| 252 |
+
ref_range = result.get("reference_range", [0, 100])
|
| 253 |
+
|
| 254 |
+
lines.extend([
|
| 255 |
+
f"**Predicted Value**: {pred:.2f} {unit}",
|
| 256 |
+
"",
|
| 257 |
+
f"**Reference Range**: [{ref_range[0]}, {ref_range[1]}]",
|
| 258 |
+
])
|
| 259 |
+
|
| 260 |
+
lines.extend([
|
| 261 |
+
"",
|
| 262 |
+
f"**Input Modalities**: {', '.join(result.get('input', {}).get('modalities', []))}",
|
| 263 |
+
])
|
| 264 |
+
|
| 265 |
+
return "\n".join(lines)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# =============================================================================
|
| 269 |
+
# 创建 Gradio 界面
|
| 270 |
+
# =============================================================================
|
| 271 |
+
|
| 272 |
+
def create_demo() -> gr.Blocks:
|
| 273 |
+
"""创建 Gradio Demo 界面"""
|
| 274 |
+
|
| 275 |
+
with gr.Blocks(
|
| 276 |
+
title="BrainAnytime Demo",
|
| 277 |
+
css="""
|
| 278 |
+
.preview-image { max-height: 300px; }
|
| 279 |
+
.result-box { font-size: 16px; }
|
| 280 |
+
"""
|
| 281 |
+
) as demo:
|
| 282 |
+
|
| 283 |
+
gr.Markdown("""
|
| 284 |
+
# 🧠 BrainAnytime Demo
|
| 285 |
+
|
| 286 |
+
**BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis**
|
| 287 |
+
|
| 288 |
+
This demo showcases the BrainAnytime model for multi-modal 3D brain image analysis.
|
| 289 |
+
The model supports arbitrary missing modality combinations at inference time.
|
| 290 |
+
|
| 291 |
+
---
|
| 292 |
+
""")
|
| 293 |
+
|
| 294 |
+
# ==================== Tab 1: 在线推理 ====================
|
| 295 |
+
with gr.Tab("🎯 在线推理 (Online Inference)"):
|
| 296 |
+
gr.Markdown("""
|
| 297 |
+
Select a task and modality combination to run inference on pre-selected samples.
|
| 298 |
+
Each sample is from the ADNI training set and has been verified for accuracy.
|
| 299 |
+
""")
|
| 300 |
+
|
| 301 |
+
with gr.Row():
|
| 302 |
+
# 左侧:选择面板
|
| 303 |
+
with gr.Column(scale=1):
|
| 304 |
+
gr.Markdown("### Configuration")
|
| 305 |
+
|
| 306 |
+
task_selector = gr.Radio(
|
| 307 |
+
choices=list(TASK_CONFIG.keys()),
|
| 308 |
+
value="CN_vs_AD",
|
| 309 |
+
label="Task",
|
| 310 |
+
info="Select the prediction task"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
# 显示任务描述
|
| 314 |
+
task_desc = gr.Markdown(
|
| 315 |
+
TASK_CONFIG["CN_vs_AD"]["description"]
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
modality_selector = gr.Radio(
|
| 319 |
+
choices=list(MODALITY_COMBOS.keys()),
|
| 320 |
+
value="T",
|
| 321 |
+
label="Modality Combination",
|
| 322 |
+
info="Select available modalities (only experiment-supported combinations)"
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# 显示模态详情
|
| 326 |
+
modality_desc = gr.Markdown(
|
| 327 |
+
"**T**: Only T1-weighted MRI"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
run_btn = gr.Button(
|
| 331 |
+
"▶️ Run Inference",
|
| 332 |
+
variant="primary",
|
| 333 |
+
size="lg"
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# 右侧:结果展示
|
| 337 |
+
with gr.Column(scale=2):
|
| 338 |
+
gr.Markdown("### Sample Preview")
|
| 339 |
+
|
| 340 |
+
sample_preview = gr.Image(
|
| 341 |
+
label="Brain MRI Preview (Axial Slice)",
|
| 342 |
+
type="filepath",
|
| 343 |
+
height=300,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
sample_info = gr.Textbox(
|
| 347 |
+
label="Sample Info",
|
| 348 |
+
interactive=False,
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
gr.Markdown("### Prediction Result")
|
| 352 |
+
|
| 353 |
+
result_display = gr.Markdown(
|
| 354 |
+
"Click 'Run Inference' to see results",
|
| 355 |
+
elem_classes=["result-box"]
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# 事件绑定
|
| 359 |
+
def update_task_desc(task):
|
| 360 |
+
return TASK_CONFIG.get(task, {}).get("description", "")
|
| 361 |
+
|
| 362 |
+
def update_modality_desc(combo):
|
| 363 |
+
combo_info = MODALITY_COMBOS.get(combo, {})
|
| 364 |
+
mods = combo_info.get("modalities", [])
|
| 365 |
+
return f"**{combo}**: {', '.join(mods)}"
|
| 366 |
+
|
| 367 |
+
task_selector.change(
|
| 368 |
+
update_task_desc,
|
| 369 |
+
inputs=task_selector,
|
| 370 |
+
outputs=task_desc
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
modality_selector.change(
|
| 374 |
+
update_modality_desc,
|
| 375 |
+
inputs=modality_selector,
|
| 376 |
+
outputs=modality_desc
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
# 更新样本预览
|
| 380 |
+
def on_config_change(task, combo):
|
| 381 |
+
return update_sample_gallery(task, combo)
|
| 382 |
+
|
| 383 |
+
task_selector.change(
|
| 384 |
+
on_config_change,
|
| 385 |
+
inputs=[task_selector, modality_selector],
|
| 386 |
+
outputs=[sample_preview, sample_info]
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
modality_selector.change(
|
| 390 |
+
on_config_change,
|
| 391 |
+
inputs=[task_selector, modality_selector],
|
| 392 |
+
outputs=[sample_preview, sample_info]
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
# 运行推理
|
| 396 |
+
def on_run_inference(task, combo):
|
| 397 |
+
result = run_inference(task, combo)
|
| 398 |
+
return format_prediction(result)
|
| 399 |
+
|
| 400 |
+
run_btn.click(
|
| 401 |
+
on_run_inference,
|
| 402 |
+
inputs=[task_selector, modality_selector],
|
| 403 |
+
outputs=result_display
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
+
# 初始化
|
| 407 |
+
demo.load(
|
| 408 |
+
on_config_change,
|
| 409 |
+
inputs=[task_selector, modality_selector],
|
| 410 |
+
outputs=[sample_preview, sample_info]
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
# ==================== Tab 2: 项目信息 ====================
|
| 414 |
+
with gr.Tab("📖 项目信息 (Project Info)"):
|
| 415 |
+
gr.Markdown("""
|
| 416 |
+
## About BrainAnytime
|
| 417 |
+
|
| 418 |
+
**Paper**: BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis
|
| 419 |
+
with Arbitrary Modality Availability
|
| 420 |
+
|
| 421 |
+
**Conference**: MICCAI 2026 (Early Accept, Top 9%)
|
| 422 |
+
|
| 423 |
+
### Key Features
|
| 424 |
+
|
| 425 |
+
- **Multi-modal Support**: T1, T2, Flair, PET
|
| 426 |
+
- **Missing Modality Robustness**: Handles arbitrary missing combinations
|
| 427 |
+
- **Anatomy-Aware**: Uses AAL116 brain atlas for adaptive masking
|
| 428 |
+
- **Pretrained Model**: Self-supervised pretraining on large-scale datasets
|
| 429 |
+
|
| 430 |
+
### Supported Tasks
|
| 431 |
+
|
| 432 |
+
| Task | Type | Description |
|
| 433 |
+
|------|------|-------------|
|
| 434 |
+
| CN vs AD | Classification | Distinguish Normal vs Alzheimer's |
|
| 435 |
+
| CN vs MCI | Classification | Distinguish Normal vs Mild Cognitive Impairment |
|
| 436 |
+
| MMSE | Regression | Predict cognitive score (10-30) |
|
| 437 |
+
| AGE | Regression | Predict age from brain MRI |
|
| 438 |
+
|
| 439 |
+
### Links
|
| 440 |
+
|
| 441 |
+
- **GitHub**: https://github.com/guangqianyang/BrainAnytime
|
| 442 |
+
- **Model Weights**: https://huggingface.co/Simmonstt/BrainAnytime
|
| 443 |
+
- **Paper**: [arXiv:2605.13059](https://arxiv.org/abs/2605.13059)
|
| 444 |
+
|
| 445 |
+
### Citation
|
| 446 |
+
|
| 447 |
+
```bibtex
|
| 448 |
+
@misc{yang2026brainanytime,
|
| 449 |
+
title={BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis},
|
| 450 |
+
author={Yang, Guangqian and Ding, Tong and others},
|
| 451 |
+
year={2026},
|
| 452 |
+
eprint={2605.13059},
|
| 453 |
+
archivePrefix={arXiv},
|
| 454 |
+
}
|
| 455 |
+
```
|
| 456 |
+
""")
|
| 457 |
+
|
| 458 |
+
# ==================== Tab 3: 样本库 ====================
|
| 459 |
+
with gr.Tab("🗂️ 样本库 (Sample Gallery)"):
|
| 460 |
+
gr.Markdown("""
|
| 461 |
+
Browse all 20 pre-selected samples (5 modality combinations × 4 tasks).
|
| 462 |
+
Each sample includes brain MRI preview images.
|
| 463 |
+
""")
|
| 464 |
+
|
| 465 |
+
# 为每个任务创建样本展示
|
| 466 |
+
for task_key, task_info in TASK_CONFIG.items():
|
| 467 |
+
with gr.Accordion(f"{task_info['name']}", open=False):
|
| 468 |
+
for combo_key, combo_info in MODALITY_COMBOS.items():
|
| 469 |
+
sample = get_sample_info(task_key, combo_key)
|
| 470 |
+
if sample:
|
| 471 |
+
with gr.Row():
|
| 472 |
+
images = get_preview_images(sample)
|
| 473 |
+
for img_path, title in images[:2]: # 最多显示2张
|
| 474 |
+
gr.Image(
|
| 475 |
+
value=img_path,
|
| 476 |
+
label=f"{combo_key} - {title}",
|
| 477 |
+
height=200,
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
gr.Markdown("""
|
| 481 |
+
---
|
| 482 |
+
|
| 483 |
+
<center>
|
| 484 |
+
<small>
|
| 485 |
+
BrainAnytime Demo | Built with Gradio |
|
| 486 |
+
<a href="https://huggingface.co/Simmonstt/BrainAnytime">Model</a> |
|
| 487 |
+
<a href="https://github.com/guangqianyang/BrainAnytime">GitHub</a>
|
| 488 |
+
</small>
|
| 489 |
+
</center>
|
| 490 |
+
""")
|
| 491 |
+
|
| 492 |
+
return demo
|
| 493 |
|
| 494 |
|
| 495 |
+
# =============================================================================
|
| 496 |
+
# 主入口
|
| 497 |
+
# =============================================================================
|
| 498 |
|
| 499 |
if __name__ == "__main__":
|
| 500 |
+
import argparse
|
| 501 |
+
|
| 502 |
+
parser = argparse.ArgumentParser()
|
| 503 |
+
parser.add_argument("--host", type=str, default="0.0.0.0")
|
| 504 |
+
parser.add_argument("--port", type=int, default=7860)
|
| 505 |
+
parser.add_argument("--share", action="store_true")
|
| 506 |
+
|
| 507 |
+
args = parser.parse_args()
|
| 508 |
+
|
| 509 |
+
# 预加载模型(可选)
|
| 510 |
+
if INFERENCE_AVAILABLE:
|
| 511 |
+
print("Initializing inference engine...")
|
| 512 |
+
engine = get_inference_engine()
|
| 513 |
+
if engine:
|
| 514 |
+
print("✅ Inference engine ready")
|
| 515 |
+
else:
|
| 516 |
+
print("⚠️ Inference engine failed to initialize")
|
| 517 |
+
|
| 518 |
+
# 检查样本数据
|
| 519 |
+
if not SAMPLES_INDEX.exists():
|
| 520 |
+
print(f"⚠️ Warning: Samples not found at {SAMPLES_INDEX}")
|
| 521 |
+
print("Please run prepare_samples.py first")
|
| 522 |
+
else:
|
| 523 |
+
samples = load_samples_index()
|
| 524 |
+
print(f"✅ Loaded {samples.get('total_samples', 0)} samples")
|
| 525 |
+
|
| 526 |
+
# 启动 Demo
|
| 527 |
+
demo = create_demo()
|
| 528 |
+
demo.launch(
|
| 529 |
+
server_name=args.host,
|
| 530 |
+
server_port=args.port,
|
| 531 |
+
share=args.share,
|
| 532 |
+
)
|
demo_samples/AGE/T/sample_016/sample_016_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/AGE/T/sample_016/sample_016_T1_preview.png
ADDED
|
demo_samples/AGE/T/sample_016/sample_016_meta.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "AGE",
|
| 3 |
+
"modality_combination": "T",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1"
|
| 6 |
+
],
|
| 7 |
+
"modality_indices": [
|
| 8 |
+
1,
|
| 9 |
+
0,
|
| 10 |
+
0,
|
| 11 |
+
0
|
| 12 |
+
],
|
| 13 |
+
"subject_id": "007_S_4516",
|
| 14 |
+
"sample_name": "sample_016",
|
| 15 |
+
"original_labels": {
|
| 16 |
+
"AGE": 73.4839151266256,
|
| 17 |
+
"MMSE": 28.0,
|
| 18 |
+
"DX": 1,
|
| 19 |
+
"Category": "CN"
|
| 20 |
+
},
|
| 21 |
+
"task_label": {
|
| 22 |
+
"value": 73.4839151266256,
|
| 23 |
+
"type": "regression"
|
| 24 |
+
},
|
| 25 |
+
"files": {
|
| 26 |
+
"nifti": {
|
| 27 |
+
"T1": "sample_016_T1.nii.gz"
|
| 28 |
+
},
|
| 29 |
+
"previews": {
|
| 30 |
+
"T1": "sample_016_T1_preview.png"
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
demo_samples/AGE/TF/sample_017/sample_017_Flair.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85c84cf50563a38c9ef91a9d5ab2943a4ce14193cce042157cd0f9acf90fb880
|
| 3 |
+
size 1110239
|
demo_samples/AGE/TF/sample_017/sample_017_Flair_preview.png
ADDED
|
demo_samples/AGE/TF/sample_017/sample_017_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/AGE/TF/sample_017/sample_017_T1_preview.png
ADDED
|
demo_samples/AGE/TF/sample_017/sample_017_meta.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "AGE",
|
| 3 |
+
"modality_combination": "TF",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1",
|
| 6 |
+
"Flair"
|
| 7 |
+
],
|
| 8 |
+
"modality_indices": [
|
| 9 |
+
1,
|
| 10 |
+
0,
|
| 11 |
+
1,
|
| 12 |
+
0
|
| 13 |
+
],
|
| 14 |
+
"subject_id": "007_S_4516",
|
| 15 |
+
"sample_name": "sample_017",
|
| 16 |
+
"original_labels": {
|
| 17 |
+
"AGE": 73.4839151266256,
|
| 18 |
+
"MMSE": 28.0,
|
| 19 |
+
"DX": 1,
|
| 20 |
+
"Category": "CN"
|
| 21 |
+
},
|
| 22 |
+
"task_label": {
|
| 23 |
+
"value": 73.4839151266256,
|
| 24 |
+
"type": "regression"
|
| 25 |
+
},
|
| 26 |
+
"files": {
|
| 27 |
+
"nifti": {
|
| 28 |
+
"T1": "sample_017_T1.nii.gz",
|
| 29 |
+
"Flair": "sample_017_Flair.nii.gz"
|
| 30 |
+
},
|
| 31 |
+
"previews": {
|
| 32 |
+
"T1": "sample_017_T1_preview.png",
|
| 33 |
+
"Flair": "sample_017_Flair_preview.png"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|
demo_samples/AGE/TFP/sample_019/sample_019_Flair.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85c84cf50563a38c9ef91a9d5ab2943a4ce14193cce042157cd0f9acf90fb880
|
| 3 |
+
size 1110239
|
demo_samples/AGE/TFP/sample_019/sample_019_Flair_preview.png
ADDED
|
demo_samples/AGE/TFP/sample_019/sample_019_PET.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:546ec204487be18e75f950e214ffc8917aba2f7678c0db38a5a88f1547550d3c
|
| 3 |
+
size 2198379
|
demo_samples/AGE/TFP/sample_019/sample_019_PET_preview.png
ADDED
|
demo_samples/AGE/TFP/sample_019/sample_019_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/AGE/TFP/sample_019/sample_019_T1_preview.png
ADDED
|
demo_samples/AGE/TFP/sample_019/sample_019_meta.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "AGE",
|
| 3 |
+
"modality_combination": "TFP",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1",
|
| 6 |
+
"Flair",
|
| 7 |
+
"PET"
|
| 8 |
+
],
|
| 9 |
+
"modality_indices": [
|
| 10 |
+
1,
|
| 11 |
+
0,
|
| 12 |
+
1,
|
| 13 |
+
1
|
| 14 |
+
],
|
| 15 |
+
"subject_id": "007_S_4516",
|
| 16 |
+
"sample_name": "sample_019",
|
| 17 |
+
"original_labels": {
|
| 18 |
+
"AGE": 73.4839151266256,
|
| 19 |
+
"MMSE": 28.0,
|
| 20 |
+
"DX": 1,
|
| 21 |
+
"Category": "CN"
|
| 22 |
+
},
|
| 23 |
+
"task_label": {
|
| 24 |
+
"value": 73.4839151266256,
|
| 25 |
+
"type": "regression"
|
| 26 |
+
},
|
| 27 |
+
"files": {
|
| 28 |
+
"nifti": {
|
| 29 |
+
"T1": "sample_019_T1.nii.gz",
|
| 30 |
+
"Flair": "sample_019_Flair.nii.gz",
|
| 31 |
+
"PET": "sample_019_PET.nii.gz"
|
| 32 |
+
},
|
| 33 |
+
"previews": {
|
| 34 |
+
"T1": "sample_019_T1_preview.png",
|
| 35 |
+
"Flair": "sample_019_Flair_preview.png",
|
| 36 |
+
"PET": "sample_019_PET_preview.png"
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
demo_samples/AGE/TMF/sample_018/sample_018_Flair.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85c84cf50563a38c9ef91a9d5ab2943a4ce14193cce042157cd0f9acf90fb880
|
| 3 |
+
size 1110239
|
demo_samples/AGE/TMF/sample_018/sample_018_Flair_preview.png
ADDED
|
demo_samples/AGE/TMF/sample_018/sample_018_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/AGE/TMF/sample_018/sample_018_T1_preview.png
ADDED
|
demo_samples/AGE/TMF/sample_018/sample_018_T2.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9dd616c68eeb474a260b5cb98cdfe5d6fce9d6c2d28356fd8b4e35e0207f94d9
|
| 3 |
+
size 1522248
|
demo_samples/AGE/TMF/sample_018/sample_018_T2_preview.png
ADDED
|
demo_samples/AGE/TMF/sample_018/sample_018_meta.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "AGE",
|
| 3 |
+
"modality_combination": "TMF",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1",
|
| 6 |
+
"T2",
|
| 7 |
+
"Flair"
|
| 8 |
+
],
|
| 9 |
+
"modality_indices": [
|
| 10 |
+
1,
|
| 11 |
+
1,
|
| 12 |
+
1,
|
| 13 |
+
0
|
| 14 |
+
],
|
| 15 |
+
"subject_id": "007_S_4516",
|
| 16 |
+
"sample_name": "sample_018",
|
| 17 |
+
"original_labels": {
|
| 18 |
+
"AGE": 73.4839151266256,
|
| 19 |
+
"MMSE": 28.0,
|
| 20 |
+
"DX": 1,
|
| 21 |
+
"Category": "CN"
|
| 22 |
+
},
|
| 23 |
+
"task_label": {
|
| 24 |
+
"value": 73.4839151266256,
|
| 25 |
+
"type": "regression"
|
| 26 |
+
},
|
| 27 |
+
"files": {
|
| 28 |
+
"nifti": {
|
| 29 |
+
"T1": "sample_018_T1.nii.gz",
|
| 30 |
+
"T2": "sample_018_T2.nii.gz",
|
| 31 |
+
"Flair": "sample_018_Flair.nii.gz"
|
| 32 |
+
},
|
| 33 |
+
"previews": {
|
| 34 |
+
"T1": "sample_018_T1_preview.png",
|
| 35 |
+
"T2": "sample_018_T2_preview.png",
|
| 36 |
+
"Flair": "sample_018_Flair_preview.png"
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
demo_samples/AGE/TMFP/sample_020/sample_020_Flair.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85c84cf50563a38c9ef91a9d5ab2943a4ce14193cce042157cd0f9acf90fb880
|
| 3 |
+
size 1110239
|
demo_samples/AGE/TMFP/sample_020/sample_020_Flair_preview.png
ADDED
|
demo_samples/AGE/TMFP/sample_020/sample_020_PET.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:546ec204487be18e75f950e214ffc8917aba2f7678c0db38a5a88f1547550d3c
|
| 3 |
+
size 2198379
|
demo_samples/AGE/TMFP/sample_020/sample_020_PET_preview.png
ADDED
|
demo_samples/AGE/TMFP/sample_020/sample_020_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/AGE/TMFP/sample_020/sample_020_T1_preview.png
ADDED
|
demo_samples/AGE/TMFP/sample_020/sample_020_T2.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9dd616c68eeb474a260b5cb98cdfe5d6fce9d6c2d28356fd8b4e35e0207f94d9
|
| 3 |
+
size 1522248
|
demo_samples/AGE/TMFP/sample_020/sample_020_T2_preview.png
ADDED
|
demo_samples/AGE/TMFP/sample_020/sample_020_meta.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "AGE",
|
| 3 |
+
"modality_combination": "TMFP",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1",
|
| 6 |
+
"T2",
|
| 7 |
+
"Flair",
|
| 8 |
+
"PET"
|
| 9 |
+
],
|
| 10 |
+
"modality_indices": [
|
| 11 |
+
1,
|
| 12 |
+
1,
|
| 13 |
+
1,
|
| 14 |
+
1
|
| 15 |
+
],
|
| 16 |
+
"subject_id": "007_S_4516",
|
| 17 |
+
"sample_name": "sample_020",
|
| 18 |
+
"original_labels": {
|
| 19 |
+
"AGE": 73.4839151266256,
|
| 20 |
+
"MMSE": 28.0,
|
| 21 |
+
"DX": 1,
|
| 22 |
+
"Category": "CN"
|
| 23 |
+
},
|
| 24 |
+
"task_label": {
|
| 25 |
+
"value": 73.4839151266256,
|
| 26 |
+
"type": "regression"
|
| 27 |
+
},
|
| 28 |
+
"files": {
|
| 29 |
+
"nifti": {
|
| 30 |
+
"T1": "sample_020_T1.nii.gz",
|
| 31 |
+
"T2": "sample_020_T2.nii.gz",
|
| 32 |
+
"Flair": "sample_020_Flair.nii.gz",
|
| 33 |
+
"PET": "sample_020_PET.nii.gz"
|
| 34 |
+
},
|
| 35 |
+
"previews": {
|
| 36 |
+
"T1": "sample_020_T1_preview.png",
|
| 37 |
+
"T2": "sample_020_T2_preview.png",
|
| 38 |
+
"Flair": "sample_020_Flair_preview.png",
|
| 39 |
+
"PET": "sample_020_PET_preview.png"
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
}
|
demo_samples/CN_vs_AD/T/sample_001/sample_001_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/CN_vs_AD/T/sample_001/sample_001_T1_preview.png
ADDED
|
demo_samples/CN_vs_AD/T/sample_001/sample_001_meta.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "CN_vs_AD",
|
| 3 |
+
"modality_combination": "T",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1"
|
| 6 |
+
],
|
| 7 |
+
"modality_indices": [
|
| 8 |
+
1,
|
| 9 |
+
0,
|
| 10 |
+
0,
|
| 11 |
+
0
|
| 12 |
+
],
|
| 13 |
+
"subject_id": "007_S_4516",
|
| 14 |
+
"sample_name": "sample_001",
|
| 15 |
+
"original_labels": {
|
| 16 |
+
"AGE": 73.4839151266256,
|
| 17 |
+
"MMSE": 28.0,
|
| 18 |
+
"DX": 1,
|
| 19 |
+
"Category": "CN"
|
| 20 |
+
},
|
| 21 |
+
"task_label": {
|
| 22 |
+
"value": 0.0,
|
| 23 |
+
"type": "classification"
|
| 24 |
+
},
|
| 25 |
+
"files": {
|
| 26 |
+
"nifti": {
|
| 27 |
+
"T1": "sample_001_T1.nii.gz"
|
| 28 |
+
},
|
| 29 |
+
"previews": {
|
| 30 |
+
"T1": "sample_001_T1_preview.png"
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:85c84cf50563a38c9ef91a9d5ab2943a4ce14193cce042157cd0f9acf90fb880
|
| 3 |
+
size 1110239
|
demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair_preview.png
ADDED
|
demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1.nii.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a490ea2db75a7fcd46cd8caf2d5d59be722036062265ccb705f79f404e68d22e
|
| 3 |
+
size 2099660
|
demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1_preview.png
ADDED
|
demo_samples/CN_vs_AD/TF/sample_002/sample_002_meta.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "CN_vs_AD",
|
| 3 |
+
"modality_combination": "TF",
|
| 4 |
+
"modalities": [
|
| 5 |
+
"T1",
|
| 6 |
+
"Flair"
|
| 7 |
+
],
|
| 8 |
+
"modality_indices": [
|
| 9 |
+
1,
|
| 10 |
+
0,
|
| 11 |
+
1,
|
| 12 |
+
0
|
| 13 |
+
],
|
| 14 |
+
"subject_id": "007_S_4516",
|
| 15 |
+
"sample_name": "sample_002",
|
| 16 |
+
"original_labels": {
|
| 17 |
+
"AGE": 73.4839151266256,
|
| 18 |
+
"MMSE": 28.0,
|
| 19 |
+
"DX": 1,
|
| 20 |
+
"Category": "CN"
|
| 21 |
+
},
|
| 22 |
+
"task_label": {
|
| 23 |
+
"value": 0.0,
|
| 24 |
+
"type": "classification"
|
| 25 |
+
},
|
| 26 |
+
"files": {
|
| 27 |
+
"nifti": {
|
| 28 |
+
"T1": "sample_002_T1.nii.gz",
|
| 29 |
+
"Flair": "sample_002_Flair.nii.gz"
|
| 30 |
+
},
|
| 31 |
+
"previews": {
|
| 32 |
+
"T1": "sample_002_T1_preview.png",
|
| 33 |
+
"Flair": "sample_002_Flair_preview.png"
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
}
|