Simmonstt commited on
Commit
b544382
·
verified ·
1 Parent(s): 3115340

Add online inference demo with 20 samples

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. BrainAnytime/altas/AAL116_standard.nii.gz +3 -0
  2. BrainAnytime/anatomy_masking.py +595 -0
  3. BrainAnytime/downstream_dataloader.py +737 -0
  4. BrainAnytime/finetune_main.py +854 -0
  5. BrainAnytime/models/__init__.py +0 -0
  6. BrainAnytime/models/multimae3d.py +997 -0
  7. BrainAnytime/models/multimae3d_utils.py +344 -0
  8. BrainAnytime/pretrain_dataloader_v2.py +313 -0
  9. BrainAnytime/test_main.py +355 -0
  10. README.md +30 -143
  11. app.py +517 -40
  12. demo_samples/AGE/T/sample_016/sample_016_T1.nii.gz +3 -0
  13. demo_samples/AGE/T/sample_016/sample_016_T1_preview.png +0 -0
  14. demo_samples/AGE/T/sample_016/sample_016_meta.json +33 -0
  15. demo_samples/AGE/TF/sample_017/sample_017_Flair.nii.gz +3 -0
  16. demo_samples/AGE/TF/sample_017/sample_017_Flair_preview.png +0 -0
  17. demo_samples/AGE/TF/sample_017/sample_017_T1.nii.gz +3 -0
  18. demo_samples/AGE/TF/sample_017/sample_017_T1_preview.png +0 -0
  19. demo_samples/AGE/TF/sample_017/sample_017_meta.json +36 -0
  20. demo_samples/AGE/TFP/sample_019/sample_019_Flair.nii.gz +3 -0
  21. demo_samples/AGE/TFP/sample_019/sample_019_Flair_preview.png +0 -0
  22. demo_samples/AGE/TFP/sample_019/sample_019_PET.nii.gz +3 -0
  23. demo_samples/AGE/TFP/sample_019/sample_019_PET_preview.png +0 -0
  24. demo_samples/AGE/TFP/sample_019/sample_019_T1.nii.gz +3 -0
  25. demo_samples/AGE/TFP/sample_019/sample_019_T1_preview.png +0 -0
  26. demo_samples/AGE/TFP/sample_019/sample_019_meta.json +39 -0
  27. demo_samples/AGE/TMF/sample_018/sample_018_Flair.nii.gz +3 -0
  28. demo_samples/AGE/TMF/sample_018/sample_018_Flair_preview.png +0 -0
  29. demo_samples/AGE/TMF/sample_018/sample_018_T1.nii.gz +3 -0
  30. demo_samples/AGE/TMF/sample_018/sample_018_T1_preview.png +0 -0
  31. demo_samples/AGE/TMF/sample_018/sample_018_T2.nii.gz +3 -0
  32. demo_samples/AGE/TMF/sample_018/sample_018_T2_preview.png +0 -0
  33. demo_samples/AGE/TMF/sample_018/sample_018_meta.json +39 -0
  34. demo_samples/AGE/TMFP/sample_020/sample_020_Flair.nii.gz +3 -0
  35. demo_samples/AGE/TMFP/sample_020/sample_020_Flair_preview.png +0 -0
  36. demo_samples/AGE/TMFP/sample_020/sample_020_PET.nii.gz +3 -0
  37. demo_samples/AGE/TMFP/sample_020/sample_020_PET_preview.png +0 -0
  38. demo_samples/AGE/TMFP/sample_020/sample_020_T1.nii.gz +3 -0
  39. demo_samples/AGE/TMFP/sample_020/sample_020_T1_preview.png +0 -0
  40. demo_samples/AGE/TMFP/sample_020/sample_020_T2.nii.gz +3 -0
  41. demo_samples/AGE/TMFP/sample_020/sample_020_T2_preview.png +0 -0
  42. demo_samples/AGE/TMFP/sample_020/sample_020_meta.json +42 -0
  43. demo_samples/CN_vs_AD/T/sample_001/sample_001_T1.nii.gz +3 -0
  44. demo_samples/CN_vs_AD/T/sample_001/sample_001_T1_preview.png +0 -0
  45. demo_samples/CN_vs_AD/T/sample_001/sample_001_meta.json +33 -0
  46. demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair.nii.gz +3 -0
  47. demo_samples/CN_vs_AD/TF/sample_002/sample_002_Flair_preview.png +0 -0
  48. demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1.nii.gz +3 -0
  49. demo_samples/CN_vs_AD/TF/sample_002/sample_002_T1_preview.png +0 -0
  50. 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: 6.14.0
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
- - arXiv: [2605.13059](https://arxiv.org/abs/2605.13059)
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
- ## Project Structure
 
 
 
 
 
 
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
- Each Excel file should contain columns for subject IDs and file paths to the corresponding NIfTI images for each modality.
 
100
 
101
- ## Attention
 
 
 
 
 
102
 
103
- To save training time, we preprocess the 3D multimodal image by following skull stripping, MN152 template co-registration, min–max normalization, and resampling to 128 × 128 ×128 in advance. The data loader only contains data augmentation during training.
104
 
105
- ## Usage
106
-
107
- ### Pretraining
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{yang2026brainanytimeanatomyawarecrossmodalpretraining,
163
- title={BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability},
164
- author={Guangqian Yang and Tong Ding and Wenlong Hou and Yue Xun and Ye Du and Qian Niu and Shujun Wang},
165
- year={2026},
166
- eprint={2605.13059},
167
- archivePrefix={arXiv},
168
- primaryClass={cs.CV},
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
- MODEL_REPO = "Simmonstt/BrainAnytime"
4
- GITHUB_REPO = "https://github.com/guangqianyang/BrainAnytime"
5
- CHECKPOINTS = [
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
- INTRO = """
13
- # BrainAnytime Demo
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- **BrainAnytime: Anatomy-Aware Cross-Modal Pretraining for Brain Image Analysis with Arbitrary Modality Availability**
16
 
17
- This Hugging Face Space hosts the official code from GitHub. Full 3D multi-modal inference
18
- requires preprocessed NIfTI volumes and GPU resources. Use the linked model repository for
19
- finetuned checkpoints and run `finetune_main.py` / `test_main.py` locally for evaluation.
20
- """
21
 
 
 
 
 
 
 
 
 
22
 
23
- def show_project_info():
24
- checkpoint_lines = "\n".join(f"- `{name}`" for name in CHECKPOINTS)
25
- return f"""{INTRO}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- ## Links
28
- - GitHub: {GITHUB_REPO}
29
- - Model weights: https://huggingface.co/{MODEL_REPO}
30
 
31
- ## Available finetuned checkpoints
32
- {checkpoint_lines}
33
 
34
- ## Supported downstream tasks
35
- - CN vs AD (classification)
36
- - CN vs MCI (classification)
37
- - MMSE (regression)
38
- - AGE (regression)
39
 
40
- ## Quick start (local)
41
- ```bash
42
- git clone {GITHUB_REPO}.git
43
- cd BrainAnytime
44
- pip install -r requirements.txt
45
- python finetune_main.py --pretrained <path/to/pretrained.pth>
46
- ```
47
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
- with gr.Blocks(title="BrainAnytime Demo") as demo:
51
- gr.Markdown(INTRO)
52
- gr.Button("Show project details").click(show_project_info, outputs=gr.Markdown())
53
 
54
  if __name__ == "__main__":
55
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }