Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +42 -0
- checkpoints/pretrain_full_90_10_h100/config.yaml +69 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00000.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00001.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00002.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00003.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00004.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00005.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00006.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00007.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00008.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00009.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00010.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00011.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00012.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00013.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00014.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00015.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00016.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00017.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00018.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00019.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00020.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00021.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00022.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00023.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00024.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00025.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00026.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00027.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00028.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00029.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00030.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00031.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00032.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00033.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00034.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00035.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00037.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00095.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00096.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00097.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00098.png +3 -0
- checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00099.png +3 -0
- finetune/fomo_tune_baseline/output/task1/build/Apptainer.def +31 -0
- finetune/fomo_tune_baseline/output/task1/build/fomo_tune/README.md +239 -0
- finetune/fomo_tune_baseline/output/task1/build/fomo_tune/backbone.py +153 -0
- finetune/fomo_tune_baseline/output/task1/build/fomo_tune/datasets.py +205 -0
- finetune/fomo_tune_baseline/output/task1/build/fomo_tune/main_task1.py +253 -0
- finetune/fomo_tune_baseline/output/task1/build/fomo_tune/main_task3.py +241 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,45 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00096.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00095.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00099.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00098.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00097.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00000.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00001.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00002.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00004.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00003.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00005.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00007.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00006.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00008.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00011.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00009.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00010.png filter=lfs diff=lfs merge=lfs -text
|
| 53 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00012.png filter=lfs diff=lfs merge=lfs -text
|
| 54 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00013.png filter=lfs diff=lfs merge=lfs -text
|
| 55 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00014.png filter=lfs diff=lfs merge=lfs -text
|
| 56 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00015.png filter=lfs diff=lfs merge=lfs -text
|
| 57 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00016.png filter=lfs diff=lfs merge=lfs -text
|
| 58 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00017.png filter=lfs diff=lfs merge=lfs -text
|
| 59 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00018.png filter=lfs diff=lfs merge=lfs -text
|
| 60 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00019.png filter=lfs diff=lfs merge=lfs -text
|
| 61 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00020.png filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00021.png filter=lfs diff=lfs merge=lfs -text
|
| 63 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00023.png filter=lfs diff=lfs merge=lfs -text
|
| 64 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00024.png filter=lfs diff=lfs merge=lfs -text
|
| 65 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00022.png filter=lfs diff=lfs merge=lfs -text
|
| 66 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00026.png filter=lfs diff=lfs merge=lfs -text
|
| 67 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00025.png filter=lfs diff=lfs merge=lfs -text
|
| 68 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00027.png filter=lfs diff=lfs merge=lfs -text
|
| 69 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00029.png filter=lfs diff=lfs merge=lfs -text
|
| 70 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00028.png filter=lfs diff=lfs merge=lfs -text
|
| 71 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00032.png filter=lfs diff=lfs merge=lfs -text
|
| 72 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00030.png filter=lfs diff=lfs merge=lfs -text
|
| 73 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00031.png filter=lfs diff=lfs merge=lfs -text
|
| 74 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00033.png filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00034.png filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00035.png filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00037.png filter=lfs diff=lfs merge=lfs -text
|
checkpoints/pretrain_full_90_10_h100/config.yaml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: pretrain_full_90_10_h100
|
| 2 |
+
notes: null
|
| 3 |
+
output_dir: checkpoints/pretrain_full_90_10_h100
|
| 4 |
+
img_size:
|
| 5 |
+
- 208
|
| 6 |
+
- 240
|
| 7 |
+
- 208
|
| 8 |
+
patch_size: 8
|
| 9 |
+
mask_ratio: 0.8
|
| 10 |
+
pred_mask_ratio: null
|
| 11 |
+
model: mae_vit_large
|
| 12 |
+
model_kwargs:
|
| 13 |
+
decoding: attn
|
| 14 |
+
target_norm: none
|
| 15 |
+
no_decode_pos: false
|
| 16 |
+
mask_drop_scale: false
|
| 17 |
+
class_token: true
|
| 18 |
+
reg_tokens: 0
|
| 19 |
+
no_embed_class: false
|
| 20 |
+
decoder_depth: 4
|
| 21 |
+
drop_path_rate: 0.0
|
| 22 |
+
datasets:
|
| 23 |
+
fomo_train:
|
| 24 |
+
url: datasets/FOMO300/wds/shard.{000000..001020}.tar
|
| 25 |
+
samples_per_epoch: 153000
|
| 26 |
+
shuffle: true
|
| 27 |
+
buffer_size: 8000
|
| 28 |
+
drop_last: true
|
| 29 |
+
fomo_val:
|
| 30 |
+
url: datasets/FOMO300/wds/shard.{001021..001134}.tar
|
| 31 |
+
samples_per_epoch: 17000
|
| 32 |
+
shuffle: false
|
| 33 |
+
buffer_size: 1000
|
| 34 |
+
drop_last: true
|
| 35 |
+
train_dataset: fomo_train
|
| 36 |
+
eval_datasets:
|
| 37 |
+
- fomo_val
|
| 38 |
+
num_workers: 16
|
| 39 |
+
prefetch_factor: 8
|
| 40 |
+
presend_cuda: false
|
| 41 |
+
epochs: 100
|
| 42 |
+
batch_size: 64
|
| 43 |
+
accum_iter: 1
|
| 44 |
+
base_lr: 0.001
|
| 45 |
+
min_lr: 1.0e-06
|
| 46 |
+
warmup_epochs: 10
|
| 47 |
+
weight_decay: 0.05
|
| 48 |
+
betas:
|
| 49 |
+
- 0.9
|
| 50 |
+
- 0.95
|
| 51 |
+
clip_grad: 1.0
|
| 52 |
+
amp: true
|
| 53 |
+
amp_dtype: bfloat16
|
| 54 |
+
compile: false
|
| 55 |
+
ckpt: null
|
| 56 |
+
resume: false
|
| 57 |
+
auto_resume: true
|
| 58 |
+
start_epoch: 0
|
| 59 |
+
checkpoint_period: 5
|
| 60 |
+
max_checkpoints: 5
|
| 61 |
+
r2_sync: null
|
| 62 |
+
device: cuda
|
| 63 |
+
distributed: false
|
| 64 |
+
seed: 7338
|
| 65 |
+
eval_seed: 7338
|
| 66 |
+
debug: false
|
| 67 |
+
wandb: true
|
| 68 |
+
wandb_entity: null
|
| 69 |
+
wandb_project: smri-fm
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00000.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00001.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00002.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00003.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00004.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00005.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00006.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00007.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00008.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00009.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00010.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00011.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00012.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00013.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00014.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00015.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00016.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00017.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00018.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00019.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00020.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00021.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00022.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00023.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00024.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00025.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00026.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00027.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00028.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00029.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00030.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00031.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00032.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00033.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00034.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00035.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00037.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00095.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00096.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00097.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00098.png
ADDED
|
Git LFS Details
|
checkpoints/pretrain_full_90_10_h100/eval__fomo_val__mask_pred__00099.png
ADDED
|
Git LFS Details
|
finetune/fomo_tune_baseline/output/task1/build/Apptainer.def
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Bootstrap: docker
|
| 2 |
+
From: python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# NOT buildable where it sits: the %files paths below are relative to the build cwd, which is the
|
| 5 |
+
# staging dir `build.py` writes. Build it with `python -m fomo_tune.build <run_dir>`, not by
|
| 6 |
+
# pointing apptainer at this file.
|
| 7 |
+
#
|
| 8 |
+
# Versions are pinned to the training environment: numpy, scikit-learn and joblib because they
|
| 9 |
+
# unpickle `head.joblib`, torch because that is what the checkpoint was written by.
|
| 10 |
+
|
| 11 |
+
%files
|
| 12 |
+
fomo_tune /app/fomo_tune
|
| 13 |
+
smri_mae /app/smri_mae
|
| 14 |
+
model /app/model
|
| 15 |
+
predict.py /app/predict.py
|
| 16 |
+
|
| 17 |
+
%post
|
| 18 |
+
pip install --no-cache-dir \
|
| 19 |
+
torch==2.8.0 \
|
| 20 |
+
numpy==2.4.6 \
|
| 21 |
+
nibabel==5.4.2 \
|
| 22 |
+
einops==0.8.2 \
|
| 23 |
+
jaxtyping==0.3.10 \
|
| 24 |
+
timm==1.0.27 \
|
| 25 |
+
huggingface-hub==0.36.2 \
|
| 26 |
+
scikit-learn==1.8.0 \
|
| 27 |
+
joblib==1.5.3 \
|
| 28 |
+
omegaconf==2.3.0
|
| 29 |
+
|
| 30 |
+
%runscript
|
| 31 |
+
exec python /app/predict.py "$@"
|
finetune/fomo_tune_baseline/output/task1/build/fomo_tune/README.md
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# fomo_tune
|
| 2 |
+
|
| 3 |
+
The five FOMO26 challenge tasks, one script each, tuned independently.
|
| 4 |
+
|
| 5 |
+
This is a spinoff of `nanobrain.eval`, which scored every backbone on every task through one fixed
|
| 6 |
+
probe. That was the right shape for a benchmark and the wrong shape for a competition: here we care
|
| 7 |
+
about one backbone (sMRI MAE) and five scores, and each task wants a different method. **Nothing
|
| 8 |
+
here imports `nanobrain.eval`, and it should stay that way** — this package may be shared with
|
| 9 |
+
people who won't get the eval suite.
|
| 10 |
+
|
| 11 |
+
## Layout
|
| 12 |
+
|
| 13 |
+
| File | |
|
| 14 |
+
|---|---|
|
| 15 |
+
| `datasets.py` | core, **frozen**. One `load_fomo_task<k>()` per task, streaming the challenge zips into an HF dataset. Raw niftis, no resampling — the backbone transform does that. |
|
| 16 |
+
| `backbone.py` | core, **frozen**. `load_backbone(ckpt_path) -> (SmriMaeBackbone, SmriMaeTransform)`. Frozen sMRI MAE encoder; the transform canonicalizes to RAS, rescales to 1mm, fits to the pretraining shape, z-scores in a mean-threshold brain mask. |
|
| 17 |
+
| `utils.py` | core. `set_seed`, `git_sha`, `setup_logging`. |
|
| 18 |
+
| `main_task<k>.py` | shell. One task, end to end. Task 1 is the worked example; copy it. |
|
| 19 |
+
| `build.py` + `Apptainer.def` | shell. Package a run dir into the challenge `.sif`. Shared by every task. |
|
| 20 |
+
|
| 21 |
+
`datasets.py` and `backbone.py` are settled and their caches are warm. Treat them as read-only:
|
| 22 |
+
new work goes in `main_task<k>.py`. If one of them genuinely needs to change, that is a
|
| 23 |
+
conversation first, because it invalidates every score already recorded.
|
| 24 |
+
|
| 25 |
+
## The pattern
|
| 26 |
+
|
| 27 |
+
`main_task1.py` is in three sections, and the split is the point of the whole design.
|
| 28 |
+
|
| 29 |
+
**`Task1Method` — the part we tune.** Features, head, hyperparameters, anything that might move
|
| 30 |
+
the score. Its interface is:
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
method.fit(rows) # rows are dataset records: subject, label, images
|
| 34 |
+
method.predict(images) # -> the challenge's output for one subject
|
| 35 |
+
method.save(model_dir) # config.yaml + head.joblib
|
| 36 |
+
Task1Method.load(model_dir, **overrides)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**The protocol — fixed.** Pool out-of-fold predictions over all subjects, bootstrap subjects for
|
| 40 |
+
the CI. No repeats, no stratification; the bootstrap is the only variance estimate. Splitting is
|
| 41 |
+
per-task but fixed within a task — leave-one-out where n is tiny (task 1, n=21), **20-fold** where
|
| 42 |
+
it isn't (tasks 3 and 5), which is close enough to LOO without paying for 494 refits. Once a task's
|
| 43 |
+
scheme is set, hold it or scores stop being comparable across iterations. That is also why
|
| 44 |
+
`cross_validate` seeds its shuffle at 0 rather than from `cfg.seed`: the folds are part of the
|
| 45 |
+
protocol, so tuning the run's seed must not silently redraw them.
|
| 46 |
+
|
| 47 |
+
**Two entrypoints.** `train` runs the protocol then fits a head on all subjects and saves it;
|
| 48 |
+
`predict` is the challenge CLI. Both go through `Method.predict`, which is why every fold
|
| 49 |
+
exercises the code the submission will run.
|
| 50 |
+
|
| 51 |
+
That last point is the load-bearing one. `predict` is not a wrapper written at packaging time — it
|
| 52 |
+
is the same call cross-validation already made once per held-out subject. When you add a task, keep
|
| 53 |
+
that property.
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
uv run python -m fomo_tune.main_task1 train modalities=[dwi_b1000,flair] name=task1_dwi_flair
|
| 57 |
+
uv run python -m fomo_tune.main_task1 predict --model-dir output/fomo_tune/task1_dwi/model \
|
| 58 |
+
--adc adc.nii.gz --dwi dwi.nii.gz --flair flair.nii.gz --output prob.txt
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
`train` takes omegaconf dotlist overrides against the `Config` dataclass at the top of the file.
|
| 62 |
+
It writes `config.yaml`, `log.txt`, `metrics.json`, and `model/` into `{output_root}/{name}/`.
|
| 63 |
+
|
| 64 |
+
## Status
|
| 65 |
+
|
| 66 |
+
Tasks 1, 5 and 3 are drafted and verified. Task 1 is also packaged — its container passes the
|
| 67 |
+
challenge validator; 5 and 3 have not been built yet. **Tasks 2 and 4 are tabled** — both are
|
| 68 |
+
segmentation, both need `predict` to emit a nifti on the input grid, and neither is worth opening
|
| 69 |
+
until the classification and regression tasks are settled.
|
| 70 |
+
|
| 71 |
+
All three on `vitl_fomo300`, one H100, wall being the cross-validation loop:
|
| 72 |
+
|
| 73 |
+
| run | result | wall |
|
| 74 |
+
|---|---|---|
|
| 75 |
+
| `task1_dwi`, dwi_b1000, n=21, LOO | AUROC **0.990** [0.944, 1.000] | 25s |
|
| 76 |
+
| `task5_t1w`, t1w, n=48, 20-fold | AUROC **0.984** [0.953, 1.000] | 73s |
|
| 77 |
+
| `task3_t1w`, t1w, n=494, 20-fold | r **0.962** [0.956, 0.968], MAE **3.71y** [3.45, 3.97] | 260s |
|
| 78 |
+
|
| 79 |
+
**Task 3's row is one fold-seed stale.** It was measured before `cross_validate` froze its shuffle
|
| 80 |
+
at 0, so it is a 20-fold run with `random_state=4466`. Task 1 (LOO) and task 5 are current. The
|
| 81 |
+
re-run is cheap — 260s on a GPU — it just has not been done. Expect a shift of the same order task
|
| 82 |
+
5 saw when its folds moved (0.948 → 0.984, i.e. inside the CI but not negligible).
|
| 83 |
+
|
| 84 |
+
Task 1's earlier probe sweep got 0.954 [0.861, 1.000] on the same checkpoint
|
| 85 |
+
(`experiments/eval_global_0728`), so it roughly reproduces — the gap is LOO vs 5×5 stratified CV,
|
| 86 |
+
one interpolation instead of two, and a head selected on AUROC instead of balanced accuracy.
|
| 87 |
+
|
| 88 |
+
Two checks worth repeating per task — `.claude/scratch/verify_task1.py` and
|
| 89 |
+
`.claude/scratch/verify_task35.py <k>` do both:
|
| 90 |
+
- features are **bit-identical** whether the nifti comes from the HF dataset wrapper or from
|
| 91 |
+
`nib.load` off disk, so CV numbers transfer to the container
|
| 92 |
+
- the `predict` CLI agrees with the in-process method
|
| 93 |
+
|
| 94 |
+
## What changes per task
|
| 95 |
+
|
| 96 |
+
Counts and modalities, read from the local zips:
|
| 97 |
+
|
| 98 |
+
| Task | n | Inputs | Output | Split | Notes |
|
| 99 |
+
|---|---|---|---|---|---|
|
| 100 |
+
| 1 infarct | 21 | adc, dwi_b1000, flair (+t2s/swi) | probability | LOO | done |
|
| 101 |
+
| 5 polymicrogyria | 48 | t1w | probability | 20-fold | done |
|
| 102 |
+
| 3 brain age | 494 | t1w | age in years | 20-fold | done — RidgeCV head, scored by **Pearson r and MAE**, each with its own bootstrap CI |
|
| 103 |
+
| 2 meningioma | 23 | dwi_b1000, flair (+t2s/swi) | mask, input grid | — | tabled |
|
| 104 |
+
| 4 trigeminal | 40 | t2w | mask, labels 1=nerve 2=vessel | — | tabled |
|
| 105 |
+
|
| 106 |
+
Tasks 5 and 3 diverge from task 1 only where that table says. `cross_validate` over a shuffled
|
| 107 |
+
`KFold` replaces `leave_one_out`; both take one modality, so `features` loses the
|
| 108 |
+
concat-over-modalities loop and `Config` loses `modalities`; the challenge CLI flag is `--t1` for
|
| 109 |
+
both, and it is `--t1` for task 3 too even though the file in the zip is `t1w.nii.gz`.
|
| 110 |
+
|
| 111 |
+
Task 3 is the first regression, so its `score` loops over the two metrics rather than returning
|
| 112 |
+
one, and it drops task 1's guard against bootstrap resamples with fewer than two distinct labels.
|
| 113 |
+
The analogous degenerate case for regression is a resample with no spread in `y`, where Pearson r
|
| 114 |
+
is undefined rather than merely unstable — at n=494 it does not happen.
|
| 115 |
+
|
| 116 |
+
When tasks 2 and 4 come back: `predict` must write a nifti on the input's grid, and the method
|
| 117 |
+
needs localized features rather than a pooled vector — `backbone.forward` returns `patch_coords`
|
| 118 |
+
in world mm for exactly that. Task 4's label order (1=nerve, 2=vessel) is still a guess and needs
|
| 119 |
+
confirming against the challenge data before per-class numbers mean anything.
|
| 120 |
+
|
| 121 |
+
## Gotchas
|
| 122 |
+
|
| 123 |
+
**Raw niftis are on disk** at `data/fomo_eval/Task_<k>/preprocessed/<sub>/ses-01/`, which is the
|
| 124 |
+
easy way to exercise `predict` on a real file rather than one written out of the dataset:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
uv run python -m fomo_tune.main_task1 predict \
|
| 128 |
+
--model-dir output/fomo_tune/task1_dwi/model \
|
| 129 |
+
--adc data/fomo_eval/Task_1/preprocessed/sub-20/ses-01/adc.nii.gz \
|
| 130 |
+
--dwi data/fomo_eval/Task_1/preprocessed/sub-20/ses-01/dwi_b1000.nii.gz \
|
| 131 |
+
--flair data/fomo_eval/Task_1/preprocessed/sub-20/ses-01/flair.nii.gz \
|
| 132 |
+
--output /tmp/prob.txt
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
Task 5 breaks the naming: `Task_5/preprocessed/sub_01/ses_01/t1.nii.gz` — underscores throughout,
|
| 136 |
+
and `t1` not `t1w`. `datasets.py` already handles it; anything you write by hand won't.
|
| 137 |
+
|
| 138 |
+
```bash
|
| 139 |
+
uv run python -m fomo_tune.main_task5 predict --model-dir output/fomo_tune/task5_t1w/model \
|
| 140 |
+
--t1 data/fomo_eval/Task_5/preprocessed/sub_01/ses_01/t1.nii.gz --output /tmp/prob.txt
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**Volumes are wildly anisotropic.** Task 1's DWI is 0.46×0.46×**5.6**mm, so the transform
|
| 144 |
+
upsamples z by 5.6× to reach 1mm iso. Nothing is wrong, but don't read the 1mm grid as real
|
| 145 |
+
resolution.
|
| 146 |
+
|
| 147 |
+
**The backbone never saw skull or neck.** Pretraining used a SynthSeg brain mask; the transform
|
| 148 |
+
substitutes a mean-intensity threshold, which keeps both. Known fidelity gap — see
|
| 149 |
+
`.claude/memory/smri-mae-preprocessing-gap.md`.
|
| 150 |
+
|
| 151 |
+
**Probabilities are not calibrated.** `LogisticRegressionCV` on ~20 samples × 1024 features shrinks
|
| 152 |
+
hard; task 1's out-of-fold probabilities all land in 0.48–0.52 with near-perfect ranking. Fine for
|
| 153 |
+
AUROC, which is what the challenge scores, but don't read them as probabilities. Task 5's do span
|
| 154 |
+
0–1, which is n=48 rather than n=21 and not evidence of calibration.
|
| 155 |
+
|
| 156 |
+
**n is tiny, so the CI is the result.** Task 1's is ~0.06 wide at the top of the range. Most tuning
|
| 157 |
+
deltas you chase will be inside it. `.claude/NOTES.md` thread 1 has the longer argument.
|
| 158 |
+
|
| 159 |
+
**GPUs need an allocation** — the login node has no driver. See the `gpu-session` skill.
|
| 160 |
+
|
| 161 |
+
## Submission
|
| 162 |
+
|
| 163 |
+
`build.py` packages a run dir into the `.sif` the challenge wants. One command, taking the run dir
|
| 164 |
+
the shipped head was saved into:
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
uv run python -m fomo_tune.build output/fomo_tune/task1_dwi
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
It stages `/app`, then builds from there:
|
| 171 |
+
|
| 172 |
+
```
|
| 173 |
+
/app/predict.py # shim: calls fomo_tune.main_task<k> predict
|
| 174 |
+
/app/model/config.yaml # from the run dir
|
| 175 |
+
/app/model/head.joblib # from the run dir
|
| 176 |
+
/app/model/backbone.pth # stripped checkpoint, --ckpt-path points here
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
**Both `build.py` and `Apptainer.def` are shared across tasks**, which they can be because nothing
|
| 180 |
+
in staging or in the dependency list is task-specific. The one thing that does vary is the module
|
| 181 |
+
the shim imports, and that comes from `task` in the run's saved config — so a run dir knows which
|
| 182 |
+
task it belongs to, and `build.py` never needs telling.
|
| 183 |
+
|
| 184 |
+
`predict.py` is **generated at build time** rather than checked in. It is eight lines whose whole
|
| 185 |
+
meaning is the container layout staged around it, so there is nowhere outside a container to run
|
| 186 |
+
it. This does not weaken the point above about `predict` not being written at packaging time: the
|
| 187 |
+
logic still lives in `main_task<k>.py`, exercised once per fold, and the shim only picks the
|
| 188 |
+
subcommand and two paths.
|
| 189 |
+
|
| 190 |
+
**`Apptainer.def` is not buildable where it sits.** Its `%files` paths are relative to the build
|
| 191 |
+
cwd, which is the staging dir. Pointing `apptainer build` at it in the repo fails confusingly; go
|
| 192 |
+
through `build.py`.
|
| 193 |
+
|
| 194 |
+
The run dir deliberately does *not* carry backbone weights — that checkpoint is 3.9G and would be
|
| 195 |
+
copied on every run. `--ckpt-path` overrides what `config.yaml` recorded, so the saved config stays
|
| 196 |
+
a faithful record of what trained rather than being rewritten at build time.
|
| 197 |
+
|
| 198 |
+
**The staged checkpoint is stripped to `model` and `args`**, which is 3.9G → 1.3G because the rest
|
| 199 |
+
is optimizer state inference never reads. `load_backbone` needs no change for this, and `predict`
|
| 200 |
+
gives a bit-identical probability either way (0.524739 on `sub-20`, checked on GPU).
|
| 201 |
+
|
| 202 |
+
**The base image is `python:3.11-slim`, not a CUDA image.** The PyPI torch wheel *is* the cu128
|
| 203 |
+
build and vendors the whole CUDA userspace as `nvidia-*` packages, so all the container needs from
|
| 204 |
+
the host is the driver, which `--nv`/`--nvccli` binds in. That keeps the SIF at 5.3G (4.0G of
|
| 205 |
+
image, 1.3G of weights) against roughly double for `pytorch/pytorch` and far more for NGC.
|
| 206 |
+
Versions are pinned to the training environment
|
| 207 |
+
mostly so `head.joblib` unpickles against the numpy/sklearn that wrote it.
|
| 208 |
+
|
| 209 |
+
Apptainer caches the bootstrap layers but **always re-runs `%post`**, so every build re-downloads
|
| 210 |
+
~3G of wheels. If that gets annoying, bake a deps-only base SIF and `Bootstrap: localimage` off it.
|
| 211 |
+
|
| 212 |
+
### Validating
|
| 213 |
+
|
| 214 |
+
`third_party/container-validator` is the challenge's own validator, test niftis included:
|
| 215 |
+
|
| 216 |
+
```bash
|
| 217 |
+
python third_party/container-validator/container_validator/validate.py \
|
| 218 |
+
--task task1 --sif output/fomo_tune/task1_dwi/task1.sif
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
It runs `python /app/predict.py --flair /input/… --adc … --dwi … --swi … --output /output/<sid>.txt`
|
| 222 |
+
inside an `apptainer instance` with `/input`, `/output` and `/tmp` bound — which is exactly the
|
| 223 |
+
shim's contract, so nothing in `predict.py` is guessing at the interface.
|
| 224 |
+
|
| 225 |
+
One thing it does that is easy to miss: it takes GPU via `--nvccli` rather than `--nv`, and one of
|
| 226 |
+
its tests runs `nvidia-smi -L` **inside** the container. `python:3.11-slim` ships no `nvidia-smi`,
|
| 227 |
+
so that test passes only because `--nvccli` injects the host one — a CUDA base image would hide
|
| 228 |
+
that dependency rather than remove it.
|
| 229 |
+
|
| 230 |
+
**The `task1_dwi` container passes all 20 validator tests**, and `predict` inside it returns
|
| 231 |
+
0.524739 on `sub-20`, identical to the same call outside the container. So the packaging is
|
| 232 |
+
verified end to end, not just built.
|
| 233 |
+
|
| 234 |
+
**Run it on a compute node with apptainer, which as of 2026-08-11 means `n-6`** — `salloc
|
| 235 |
+
--nodelist=n-6`. The other nodes fail the validator's preflight. The login node has apptainer but
|
| 236 |
+
no driver, and
|
| 237 |
+
`predict` there dies inside `can_use_cudnn_attention` — the jagged-SDPA path reaches into CUDA even
|
| 238 |
+
when the tensors are on CPU, so a driver-less host fails at the forward pass rather than falling
|
| 239 |
+
back. That is the CPU gap worth remembering; it is not a container problem.
|
finetune/fomo_tune_baseline/output/task1/build/fomo_tune/backbone.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import inspect
|
| 2 |
+
|
| 3 |
+
import nibabel as nib
|
| 4 |
+
import numpy as np
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from einops import rearrange
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
import smri_mae.model_mae as models_mae
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class SmriMaeBackbone(nn.Module):
|
| 15 |
+
grid_coords: Tensor
|
| 16 |
+
|
| 17 |
+
def __init__(self, encoder: models_mae.MaskedEncoder):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.encoder = encoder
|
| 20 |
+
self.img_size = self.encoder.patchify.img_size
|
| 21 |
+
|
| 22 |
+
grid_size = self.encoder.patchify.grid_size
|
| 23 |
+
patch_size = np.array(self.encoder.patchify.patch_size)
|
| 24 |
+
grid_coords = rearrange(np.indices(grid_size), "c x y z -> (x y z) c")
|
| 25 |
+
grid_coords = grid_coords * patch_size + (patch_size - 1) / 2
|
| 26 |
+
grid_coords = torch.as_tensor(grid_coords, dtype=torch.float32)
|
| 27 |
+
self.register_buffer("grid_coords", grid_coords)
|
| 28 |
+
|
| 29 |
+
def forward(self, batch: dict[str, Tensor]) -> dict[str, Tensor]:
|
| 30 |
+
images = batch["image"]
|
| 31 |
+
mask = batch["mask"]
|
| 32 |
+
affine = batch["affine"]
|
| 33 |
+
|
| 34 |
+
B, C, X, Y, Z = images.shape
|
| 35 |
+
assert (X, Y, Z) == self.img_size, f"expected {self.img_size}, got {(X, Y, Z)}"
|
| 36 |
+
|
| 37 |
+
_, _, patch_embeds, _, patch_ids, token_mask = self.encoder(images, mask=mask)
|
| 38 |
+
|
| 39 |
+
# [B, L, 3] world xyz coords of embeddings
|
| 40 |
+
patch_coords = self.grid_coords[patch_ids, :]
|
| 41 |
+
rot = affine[:, :3, :3]
|
| 42 |
+
trans = affine[:, :3, 3]
|
| 43 |
+
patch_coords = patch_coords @ rot.transpose(1, 2) + trans[:, None, :]
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"patch_embeds": patch_embeds,
|
| 47 |
+
"patch_ids": patch_ids,
|
| 48 |
+
"token_mask": token_mask,
|
| 49 |
+
"patch_coords": patch_coords,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SmriMaeTransform:
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
img_size: tuple[int, int, int] = (208, 240, 208),
|
| 57 |
+
spacing: tuple[float, float, float] = (1.0, 1.0, 1.0),
|
| 58 |
+
):
|
| 59 |
+
self.img_size = img_size
|
| 60 |
+
self.spacing = spacing
|
| 61 |
+
|
| 62 |
+
def __call__(self, img: nib.Nifti1Image) -> dict[str, Tensor]:
|
| 63 |
+
# repack image to handle incomplete hf Nifti interface
|
| 64 |
+
img = nib.Nifti1Image(img.dataobj, img.affine, img.header)
|
| 65 |
+
img = nib.as_closest_canonical(img)
|
| 66 |
+
|
| 67 |
+
data = torch.from_numpy(np.ascontiguousarray(img.get_fdata(dtype=np.float32)))
|
| 68 |
+
affine = np.asarray(img.affine)
|
| 69 |
+
|
| 70 |
+
spacing = img.header.get_zooms()
|
| 71 |
+
if max(abs(s - s_) for s, s_ in zip(spacing, self.spacing)) > 0.05:
|
| 72 |
+
data, affine = rescale(data, affine, spacing, self.spacing)
|
| 73 |
+
|
| 74 |
+
data, affine = fit_to_shape(data, affine, target_shape=self.img_size)
|
| 75 |
+
|
| 76 |
+
# mean threshold, not the SynthSeg mask used in pretraining, so skull and neck stay in
|
| 77 |
+
mask = data > data.mean()
|
| 78 |
+
brain = data[mask]
|
| 79 |
+
mean = brain.mean()
|
| 80 |
+
# population std (correction=0) to match the pretraining normalization
|
| 81 |
+
std = brain.std(correction=0).clamp_min(1e-6)
|
| 82 |
+
data = torch.where(mask, (data - mean) / std, 0.0)
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"image": data.unsqueeze(0),
|
| 86 |
+
"mask": mask.unsqueeze(0),
|
| 87 |
+
"affine": torch.as_tensor(affine, dtype=torch.float32),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def rescale(
|
| 92 |
+
x: torch.Tensor,
|
| 93 |
+
affine: np.ndarray,
|
| 94 |
+
spacing: tuple[float, ...],
|
| 95 |
+
target_spacing: tuple[float, ...] = (1.0, 1.0, 1.0),
|
| 96 |
+
) -> tuple[torch.Tensor, np.ndarray]:
|
| 97 |
+
scales = tuple([current / target for current, target in zip(spacing, target_spacing)])
|
| 98 |
+
resampled = F.interpolate(x[None, None], scale_factor=scales, mode="trilinear").squeeze(0, 1)
|
| 99 |
+
|
| 100 |
+
# align_corners=False reads output voxel j from input voxel (j + 0.5) / scale - 0.5
|
| 101 |
+
scale = np.asarray(scales, dtype=float)
|
| 102 |
+
step = np.diag([*(1 / scale), 1.0])
|
| 103 |
+
step[:3, 3] = 0.5 / scale - 0.5
|
| 104 |
+
return resampled, affine @ step
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def fit_to_shape(
|
| 108 |
+
x: torch.Tensor, affine: np.ndarray, target_shape: tuple[int, ...]
|
| 109 |
+
) -> tuple[torch.Tensor, np.ndarray]:
|
| 110 |
+
"""Centre the volume in `target_shape`, padding the short axes and cropping the long ones."""
|
| 111 |
+
pads = [target - size for size, target in zip(x.shape, target_shape)]
|
| 112 |
+
padding = [side for pad in reversed(pads) for side in (pad // 2, pad - pad // 2)]
|
| 113 |
+
|
| 114 |
+
# a crop is a negative pad, so output voxel k came from input voxel k - pad // 2 either way
|
| 115 |
+
step = np.eye(4)
|
| 116 |
+
step[:3, 3] = [-(pad // 2) for pad in pads]
|
| 117 |
+
return F.pad(x, padding), affine @ step
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def resolve_ckpt(ckpt_path: str) -> str:
|
| 121 |
+
"""A local path for a checkpoint, downloading it if it is an hf://<org>/<repo>/<file> URI."""
|
| 122 |
+
from huggingface_hub import hf_hub_download
|
| 123 |
+
|
| 124 |
+
if ckpt_path.startswith("hf://"):
|
| 125 |
+
org, repo, *rest = ckpt_path.removeprefix("hf://").split("/")
|
| 126 |
+
return hf_hub_download(f"{org}/{repo}", "/".join(rest))
|
| 127 |
+
|
| 128 |
+
return ckpt_path
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def load_backbone(ckpt_path: str) -> tuple[SmriMaeBackbone, SmriMaeTransform]:
|
| 132 |
+
path = resolve_ckpt(ckpt_path)
|
| 133 |
+
ckpt = torch.load(path, map_location="cpu", weights_only=True, mmap=True)
|
| 134 |
+
args = ckpt["args"]
|
| 135 |
+
|
| 136 |
+
model_fn = models_mae.__dict__[args["model"]]
|
| 137 |
+
model: models_mae.MaskedAutoencoderViT = model_fn(
|
| 138 |
+
img_size=args["img_size"],
|
| 139 |
+
in_chans=args.get("in_chans", 1),
|
| 140 |
+
patch_size=args["patch_size"],
|
| 141 |
+
# older checkpoints carry training flags the current model_mae no longer takes
|
| 142 |
+
**filter_kwargs(models_mae.MaskedAutoencoderViT, args.get("model_kwargs") or {}),
|
| 143 |
+
)
|
| 144 |
+
model.load_state_dict(ckpt["model"])
|
| 145 |
+
backbone = SmriMaeBackbone(model.encoder)
|
| 146 |
+
transform = SmriMaeTransform(img_size=args["img_size"])
|
| 147 |
+
return backbone, transform
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def filter_kwargs(func, kwargs):
|
| 151 |
+
signature = inspect.signature(func)
|
| 152 |
+
kwargs = {k: v for k, v in kwargs.items() if k in signature.parameters}
|
| 153 |
+
return kwargs
|
finetune/fomo_tune_baseline/output/task1/build/fomo_tune/datasets.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import tempfile
|
| 4 |
+
import zipfile
|
| 5 |
+
from collections.abc import Generator
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import fsspec
|
| 10 |
+
from datasets import Dataset, Features, Nifti, Value
|
| 11 |
+
|
| 12 |
+
FOMO_EVAL_BASE_URL = os.getenv(
|
| 13 |
+
"FOMO_EVAL_BASE_URL",
|
| 14 |
+
"https://sid.erda.dk/share_redirect/fmeuvo1EdF",
|
| 15 |
+
)
|
| 16 |
+
FOMO_EVAL_TASK5_URL = os.getenv(
|
| 17 |
+
"FOMO_EVAL_TASK5_URL",
|
| 18 |
+
"https://huggingface.co/datasets/medarc/smri-fm/resolve/main/fomo_eval/Task_5.zip",
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@contextmanager
|
| 23 |
+
def open_zip(url: str) -> Generator[zipfile.ZipFile, None, None]:
|
| 24 |
+
"""Open a task zip, copying a remote url to a temp file first."""
|
| 25 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 26 |
+
local = Path(url)
|
| 27 |
+
if not local.exists():
|
| 28 |
+
local = Path(tmp) / "task.zip"
|
| 29 |
+
with fsspec.open(url) as src, local.open("wb") as dst:
|
| 30 |
+
shutil.copyfileobj(src, dst)
|
| 31 |
+
with zipfile.ZipFile(local) as zf:
|
| 32 |
+
yield zf
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def subject_ids(zf: zipfile.ZipFile) -> list[str]:
|
| 36 |
+
return sorted({name.split("/")[2] for name in zf.namelist() if name.endswith(".nii.gz")})
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---- Task 1: acute infarct (classification; positives also carry a lesion mask) --------
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def load_fomo_task1() -> Dataset:
|
| 43 |
+
# No 4th modality: it is swi on 16 subjects and t2s on the other 5.
|
| 44 |
+
suffixes = ("adc", "dwi_b1000", "flair")
|
| 45 |
+
features = Features(
|
| 46 |
+
{
|
| 47 |
+
"subject": Value("string"),
|
| 48 |
+
"label": Value("int32"),
|
| 49 |
+
**{suffix: Nifti() for suffix in suffixes},
|
| 50 |
+
}
|
| 51 |
+
)
|
| 52 |
+
dataset = Dataset.from_generator(
|
| 53 |
+
_fomo_task1_generator,
|
| 54 |
+
features=features,
|
| 55 |
+
gen_kwargs={"suffixes": suffixes},
|
| 56 |
+
writer_batch_size=16,
|
| 57 |
+
)
|
| 58 |
+
return dataset
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _fomo_task1_generator(suffixes: tuple[str, ...]):
|
| 62 |
+
url = f"{FOMO_EVAL_BASE_URL}/Task_1.zip"
|
| 63 |
+
with open_zip(url) as zf:
|
| 64 |
+
for sub in subject_ids(zf):
|
| 65 |
+
label = int(zf.read(f"Task_1/labels/{sub}/ses-01/label.txt").strip())
|
| 66 |
+
sample = {"subject": sub, "label": label}
|
| 67 |
+
for suffix in suffixes:
|
| 68 |
+
name = f"Task_1/preprocessed/{sub}/ses-01/{suffix}.nii.gz"
|
| 69 |
+
sample[suffix] = {"path": None, "bytes": zf.read(name)}
|
| 70 |
+
yield sample
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---- Task 2: meningioma segmentation ---------------------------------------------------
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_fomo_task2() -> Dataset:
|
| 77 |
+
# No 4th modality: it is t2s on 15 subjects and swi on the other 8.
|
| 78 |
+
suffixes = ("dwi_b1000", "flair")
|
| 79 |
+
features = Features(
|
| 80 |
+
{
|
| 81 |
+
"subject": Value("string"),
|
| 82 |
+
**{suffix: Nifti() for suffix in suffixes},
|
| 83 |
+
"seg": Nifti(),
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
dataset = Dataset.from_generator(
|
| 87 |
+
_fomo_task2_generator,
|
| 88 |
+
features=features,
|
| 89 |
+
gen_kwargs={"suffixes": suffixes},
|
| 90 |
+
writer_batch_size=16,
|
| 91 |
+
)
|
| 92 |
+
return dataset
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _fomo_task2_generator(suffixes: tuple[str, ...]):
|
| 96 |
+
url = f"{FOMO_EVAL_BASE_URL}/Task_2.zip"
|
| 97 |
+
with open_zip(url) as zf:
|
| 98 |
+
for sub in subject_ids(zf):
|
| 99 |
+
sample = {"subject": sub}
|
| 100 |
+
for suffix in suffixes:
|
| 101 |
+
name = f"Task_2/preprocessed/{sub}/ses-01/{suffix}.nii.gz"
|
| 102 |
+
sample[suffix] = {"path": None, "bytes": zf.read(name)}
|
| 103 |
+
# Seg is on the image grid (shapes match) but its affine differs by up to 0.03mm.
|
| 104 |
+
name = f"Task_2/labels/{sub}/ses-01/seg.nii.gz"
|
| 105 |
+
sample["seg"] = {"path": None, "bytes": zf.read(name)}
|
| 106 |
+
yield sample
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ---- Task 3: brain age regression ------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def load_fomo_task3() -> Dataset:
|
| 113 |
+
features = Features(
|
| 114 |
+
{
|
| 115 |
+
"subject": Value("string"),
|
| 116 |
+
"age": Value("int32"),
|
| 117 |
+
"t1w": Nifti(),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
dataset = Dataset.from_generator(
|
| 121 |
+
_fomo_task3_generator,
|
| 122 |
+
features=features,
|
| 123 |
+
writer_batch_size=16,
|
| 124 |
+
)
|
| 125 |
+
return dataset
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _fomo_task3_generator():
|
| 129 |
+
url = f"{FOMO_EVAL_BASE_URL}/Task_3.zip"
|
| 130 |
+
with open_zip(url) as zf:
|
| 131 |
+
for sub in subject_ids(zf):
|
| 132 |
+
age = int(zf.read(f"Task_3/labels/{sub}/ses-01/labels.txt").strip())
|
| 133 |
+
image_gz = zf.read(f"Task_3/preprocessed/{sub}/ses-01/t1w.nii.gz")
|
| 134 |
+
sample = {
|
| 135 |
+
"subject": sub,
|
| 136 |
+
"age": age,
|
| 137 |
+
"t1w": {"path": None, "bytes": image_gz},
|
| 138 |
+
}
|
| 139 |
+
yield sample
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ---- Task 4: trigeminal nerve/vessel segmentation --------------------------------------
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def load_fomo_task4() -> Dataset:
|
| 146 |
+
# Volumes are uncropped 0.5mm near-iso, ~360x512x512; crop before feeding a model.
|
| 147 |
+
features = Features(
|
| 148 |
+
{
|
| 149 |
+
"subject": Value("string"),
|
| 150 |
+
"t2w": Nifti(),
|
| 151 |
+
"seg": Nifti(),
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
dataset = Dataset.from_generator(
|
| 155 |
+
_fomo_task4_generator,
|
| 156 |
+
features=features,
|
| 157 |
+
writer_batch_size=16,
|
| 158 |
+
)
|
| 159 |
+
return dataset
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _fomo_task4_generator():
|
| 163 |
+
url = f"{FOMO_EVAL_BASE_URL}/Task_4.zip"
|
| 164 |
+
with open_zip(url) as zf:
|
| 165 |
+
for sub in subject_ids(zf):
|
| 166 |
+
image_gz = zf.read(f"Task_4/preprocessed/{sub}/ses-01/t2w.nii.gz")
|
| 167 |
+
seg_gz = zf.read(f"Task_4/labels/{sub}/ses-01/seg.nii.gz")
|
| 168 |
+
sample = {
|
| 169 |
+
"subject": sub,
|
| 170 |
+
"t2w": {"path": None, "bytes": image_gz},
|
| 171 |
+
"seg": {"path": None, "bytes": seg_gz},
|
| 172 |
+
}
|
| 173 |
+
yield sample
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ---- Task 5: polymicrogyria classification ---------------------------------------------
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def load_fomo_task5() -> Dataset:
|
| 180 |
+
features = Features(
|
| 181 |
+
{
|
| 182 |
+
"subject": Value("string"),
|
| 183 |
+
"label": Value("int32"),
|
| 184 |
+
"t1w": Nifti(),
|
| 185 |
+
}
|
| 186 |
+
)
|
| 187 |
+
dataset = Dataset.from_generator(
|
| 188 |
+
_fomo_task5_generator,
|
| 189 |
+
features=features,
|
| 190 |
+
writer_batch_size=16,
|
| 191 |
+
)
|
| 192 |
+
return dataset
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _fomo_task5_generator():
|
| 196 |
+
with open_zip(FOMO_EVAL_TASK5_URL) as zf:
|
| 197 |
+
for sub in subject_ids(zf):
|
| 198 |
+
label = int(zf.read(f"Task_5/labels/{sub}/ses_01/labels.txt").strip())
|
| 199 |
+
image_gz = zf.read(f"Task_5/preprocessed/{sub}/ses_01/t1.nii.gz")
|
| 200 |
+
sample = {
|
| 201 |
+
"subject": sub,
|
| 202 |
+
"label": label,
|
| 203 |
+
"t1w": {"path": None, "bytes": image_gz},
|
| 204 |
+
}
|
| 205 |
+
yield sample
|
finetune/fomo_tune_baseline/output/task1/build/fomo_tune/main_task1.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FOMO task 1: acute infarct classification, scored by AUROC as the challenge scores it.
|
| 2 |
+
|
| 3 |
+
`Task1Method` is the part we tune -- features, head, hyperparameters. The protocol below it is
|
| 4 |
+
fixed so scores stay comparable across iterations: leave one subject out, pool the out-of-fold
|
| 5 |
+
predictions, bootstrap subjects for the CI.
|
| 6 |
+
|
| 7 |
+
`train` runs that protocol then fits and saves a head; `predict` is the challenge contract,
|
| 8 |
+
modality paths in and one probability out. Both go through `Task1Method.predict`, so every fold
|
| 9 |
+
exercises the path the submission will run.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import joblib
|
| 20 |
+
import nibabel as nib
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
from omegaconf import OmegaConf
|
| 24 |
+
from sklearn.linear_model import LogisticRegressionCV
|
| 25 |
+
from sklearn.metrics import roc_auc_score
|
| 26 |
+
from sklearn.pipeline import make_pipeline
|
| 27 |
+
from sklearn.preprocessing import StandardScaler
|
| 28 |
+
|
| 29 |
+
from fomo_tune.backbone import load_backbone
|
| 30 |
+
from fomo_tune.utils import git_sha, set_seed, setup_logging
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("fomo_tune")
|
| 33 |
+
|
| 34 |
+
Images = dict[str, nib.Nifti1Image]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Config:
|
| 39 |
+
task: str = "task1"
|
| 40 |
+
ckpt_path: str = (
|
| 41 |
+
"/data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth"
|
| 42 |
+
)
|
| 43 |
+
modalities: list[str] = field(default_factory=lambda: ["dwi_b1000"])
|
| 44 |
+
output_root: str = "output/fomo_tune"
|
| 45 |
+
name: str = "task1"
|
| 46 |
+
device: str = "cuda"
|
| 47 |
+
seed: int = 4466
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---- method: the part we tune -----------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Task1Method:
|
| 54 |
+
"""Frozen sMRI MAE, mean-pooled tokens per modality concatenated, logistic head."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, cfg: Config):
|
| 57 |
+
self.cfg = cfg
|
| 58 |
+
self.backbone, self.transform = load_backbone(cfg.ckpt_path)
|
| 59 |
+
self.device = torch.device(cfg.device)
|
| 60 |
+
self.backbone.to(self.device).eval().requires_grad_(False)
|
| 61 |
+
self.modalities = list(cfg.modalities)
|
| 62 |
+
self.cache: dict[str, np.ndarray] = {}
|
| 63 |
+
self.head = None
|
| 64 |
+
|
| 65 |
+
@torch.inference_mode()
|
| 66 |
+
def features(self, images: Images) -> np.ndarray:
|
| 67 |
+
"""(D,) per subject. A pure function of the images, so training and inference agree."""
|
| 68 |
+
pooled = []
|
| 69 |
+
for modality in self.modalities:
|
| 70 |
+
sample = self.transform(images[modality])
|
| 71 |
+
batch = {key: value[None].to(self.device) for key, value in sample.items()}
|
| 72 |
+
|
| 73 |
+
with torch.autocast("cuda", torch.bfloat16, enabled=self.device.type == "cuda"):
|
| 74 |
+
out = self.backbone(batch)
|
| 75 |
+
|
| 76 |
+
patch_embeds = out["patch_embeds"]
|
| 77 |
+
token_mask = out["token_mask"].bool().unsqueeze(-1)
|
| 78 |
+
embed = (patch_embeds * token_mask).sum(dim=1) / token_mask.sum(dim=1)
|
| 79 |
+
pooled.append(embed[0].float().cpu())
|
| 80 |
+
|
| 81 |
+
return torch.cat(pooled).numpy()
|
| 82 |
+
|
| 83 |
+
def cached_features(self, row: dict) -> np.ndarray:
|
| 84 |
+
if row["subject"] not in self.cache:
|
| 85 |
+
self.cache[row["subject"]] = self.features(row)
|
| 86 |
+
return self.cache[row["subject"]]
|
| 87 |
+
|
| 88 |
+
def fit(self, rows: list[dict]) -> None:
|
| 89 |
+
X = np.stack([self.cached_features(row) for row in rows])
|
| 90 |
+
y = np.array([row["label"] for row in rows])
|
| 91 |
+
|
| 92 |
+
clf = LogisticRegressionCV(
|
| 93 |
+
Cs=10,
|
| 94 |
+
class_weight="balanced",
|
| 95 |
+
scoring="roc_auc",
|
| 96 |
+
max_iter=1000,
|
| 97 |
+
l1_ratios=(0,),
|
| 98 |
+
use_legacy_attributes=False,
|
| 99 |
+
)
|
| 100 |
+
self.head = make_pipeline(StandardScaler(), clf)
|
| 101 |
+
self.head.fit(X, y)
|
| 102 |
+
self.positive = list(self.head.classes_).index(1)
|
| 103 |
+
|
| 104 |
+
def predict(self, images: Images) -> float:
|
| 105 |
+
"""Positive-class probability. Indexes `classes_` rather than assuming column 1, which
|
| 106 |
+
would silently score the wrong class if the label order differed."""
|
| 107 |
+
X = self.features(images)[None]
|
| 108 |
+
probs = self.head.predict_proba(X)[0]
|
| 109 |
+
return float(probs[self.positive])
|
| 110 |
+
|
| 111 |
+
def save(self, model_dir: Path) -> None:
|
| 112 |
+
"""Everything `load` needs but the backbone weights, which stay wherever `ckpt_path`
|
| 113 |
+
points -- a few hundred KB, so a run saves one without copying a 3.7G checkpoint."""
|
| 114 |
+
model_dir.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
OmegaConf.save(self.cfg, model_dir / "config.yaml")
|
| 116 |
+
joblib.dump({"head": self.head, "positive": self.positive}, model_dir / "head.joblib")
|
| 117 |
+
|
| 118 |
+
@classmethod
|
| 119 |
+
def load(cls, model_dir: Path, **overrides) -> "Task1Method":
|
| 120 |
+
"""Rebuild a fitted method from `save`. Overrides are Config fields, for what differs
|
| 121 |
+
between here and the container -- the backbone path, the device."""
|
| 122 |
+
cfg = OmegaConf.merge(
|
| 123 |
+
OmegaConf.structured(Config), OmegaConf.load(model_dir / "config.yaml"), overrides
|
| 124 |
+
)
|
| 125 |
+
method = cls(cfg)
|
| 126 |
+
state = joblib.load(model_dir / "head.joblib")
|
| 127 |
+
method.head, method.positive = state["head"], state["positive"]
|
| 128 |
+
return method
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ---- protocol: the part we hold fixed ---------------------------------------------------
|
| 132 |
+
|
| 133 |
+
# Every image the task ships. The method picks which of them it wants, as at inference, where
|
| 134 |
+
# the challenge hands over all four modalities whether or not a model uses them.
|
| 135 |
+
IMAGE_COLS = ("adc", "dwi_b1000", "flair")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def leave_one_out(rows: list[dict], method: Task1Method) -> tuple[np.ndarray, np.ndarray]:
|
| 139 |
+
"""Out-of-fold score for every subject, each predicted by a head fit on the other n-1."""
|
| 140 |
+
y = np.array([row["label"] for row in rows])
|
| 141 |
+
oof = np.zeros(len(rows), dtype=float)
|
| 142 |
+
start = time.perf_counter()
|
| 143 |
+
for held_out, row in enumerate(rows):
|
| 144 |
+
method.fit([r for r in rows if r["subject"] != row["subject"]])
|
| 145 |
+
oof[held_out] = method.predict({key: row[key] for key in IMAGE_COLS})
|
| 146 |
+
logger.info(
|
| 147 |
+
f"fold {held_out + 1}/{len(rows)} {row['subject']} "
|
| 148 |
+
f"y={y[held_out]} p={oof[held_out]:.3f} ({time.perf_counter() - start:.0f}s)"
|
| 149 |
+
)
|
| 150 |
+
return y, oof
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def score(
|
| 154 |
+
y: np.ndarray, oof: np.ndarray, seed: int = 0, n_boot: int = 2000, alpha: float = 0.05
|
| 155 |
+
) -> dict:
|
| 156 |
+
"""AUROC, the challenge metric, plus a percentile CI resampling subjects with replacement."""
|
| 157 |
+
rng = np.random.default_rng(seed)
|
| 158 |
+
samples = []
|
| 159 |
+
for _ in range(n_boot):
|
| 160 |
+
rows = rng.integers(0, len(y), size=len(y))
|
| 161 |
+
if len(np.unique(y[rows])) < 2:
|
| 162 |
+
continue
|
| 163 |
+
samples.append(roc_auc_score(y[rows], oof[rows]))
|
| 164 |
+
|
| 165 |
+
low, high = np.percentile(samples, [100 * alpha / 2, 100 * (1 - alpha / 2)])
|
| 166 |
+
return {
|
| 167 |
+
"auroc": float(roc_auc_score(y, oof)),
|
| 168 |
+
"auroc_ci_low": float(low),
|
| 169 |
+
"auroc_ci_high": float(high),
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ---- entrypoints ------------------------------------------------------------------------
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def train(args: argparse.Namespace) -> None:
|
| 177 |
+
# imported here, not at the top, so the container needs no dataset stack to run `predict`
|
| 178 |
+
from fomo_tune.datasets import load_fomo_task1
|
| 179 |
+
|
| 180 |
+
cfg = OmegaConf.merge(OmegaConf.structured(Config), OmegaConf.from_dotlist(args.overrides))
|
| 181 |
+
run_dir = Path(cfg.output_root) / cfg.name
|
| 182 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 183 |
+
|
| 184 |
+
setup_logging(run_dir)
|
| 185 |
+
set_seed(cfg.seed)
|
| 186 |
+
logger.info(f"run {cfg.name} (git {git_sha()})")
|
| 187 |
+
logger.info(f"config:\n{OmegaConf.to_yaml(cfg).rstrip()}")
|
| 188 |
+
OmegaConf.save(cfg, run_dir / "config.yaml")
|
| 189 |
+
|
| 190 |
+
# decoded once: leave-one-out revisits every subject n times, and the niftis are small
|
| 191 |
+
rows = list(load_fomo_task1())
|
| 192 |
+
logger.info(f"dataset: {len(rows)} subjects, {sum(r['label'] for r in rows)} positive")
|
| 193 |
+
|
| 194 |
+
method = Task1Method(cfg)
|
| 195 |
+
start = time.perf_counter()
|
| 196 |
+
y, oof = leave_one_out(rows, method)
|
| 197 |
+
run_time = time.perf_counter() - start
|
| 198 |
+
summary = score(y, oof)
|
| 199 |
+
|
| 200 |
+
# the shipped head sees all n subjects, so it is not any of the models scored above
|
| 201 |
+
method.fit(rows)
|
| 202 |
+
method.save(run_dir / "model")
|
| 203 |
+
|
| 204 |
+
record = {"name": cfg.name, **summary, "run_time": round(run_time, 1)}
|
| 205 |
+
(run_dir / "metrics.json").write_text(json.dumps(record) + "\n")
|
| 206 |
+
scores = " ".join(f"{k}={v:.4f}" for k, v in summary.items())
|
| 207 |
+
logger.info(f"result: {scores} ({run_time:.0f}s)")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def predict(args: argparse.Namespace) -> None:
|
| 211 |
+
"""The challenge contract: modality paths in, one probability written to `--output`.
|
| 212 |
+
|
| 213 |
+
`/app/predict.py` in the container is a shim over this, so what scores the submission is the
|
| 214 |
+
code leave-one-out already ran, not something generated at build time.
|
| 215 |
+
"""
|
| 216 |
+
overrides = {"device": args.device}
|
| 217 |
+
if args.ckpt_path:
|
| 218 |
+
overrides["ckpt_path"] = args.ckpt_path
|
| 219 |
+
method = Task1Method.load(args.model_dir, **overrides)
|
| 220 |
+
|
| 221 |
+
# every image the challenge hands over, as in `leave_one_out`; the method takes what it uses
|
| 222 |
+
paths = {"adc": args.adc, "dwi_b1000": args.dwi, "flair": args.flair}
|
| 223 |
+
probability = method.predict({key: nib.load(path) for key, path in paths.items()})
|
| 224 |
+
|
| 225 |
+
args.output.write_text(f"{probability:.6f}\n")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def main() -> None:
|
| 229 |
+
parser = argparse.ArgumentParser()
|
| 230 |
+
modes = parser.add_subparsers(required=True)
|
| 231 |
+
|
| 232 |
+
train_parser = modes.add_parser("train", help="leave-one-out over the task, then fit and save")
|
| 233 |
+
train_parser.add_argument("overrides", nargs="*", help="config overrides, e.g. device=cpu")
|
| 234 |
+
train_parser.set_defaults(run=train)
|
| 235 |
+
|
| 236 |
+
predict_parser = modes.add_parser("predict", help="one subject, one probability")
|
| 237 |
+
for flag in ("--flair", "--adc", "--dwi"):
|
| 238 |
+
predict_parser.add_argument(flag, type=Path, required=True)
|
| 239 |
+
# accepted and ignored: the 4th modality is swi on some subjects and t2s on others
|
| 240 |
+
for flag in ("--t2s", "--swi"):
|
| 241 |
+
predict_parser.add_argument(flag, type=Path)
|
| 242 |
+
predict_parser.add_argument("--output", type=Path, required=True)
|
| 243 |
+
predict_parser.add_argument("--model-dir", type=Path, default=Path("/app/model"))
|
| 244 |
+
predict_parser.add_argument("--ckpt-path", help="overrides the trained config's backbone path")
|
| 245 |
+
predict_parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 246 |
+
predict_parser.set_defaults(run=predict)
|
| 247 |
+
|
| 248 |
+
args = parser.parse_args()
|
| 249 |
+
args.run(args)
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
if __name__ == "__main__":
|
| 253 |
+
main()
|
finetune/fomo_tune_baseline/output/task1/build/fomo_tune/main_task3.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FOMO task 3: brain age regression, scored by Pearson r and MAE as the challenge scores it.
|
| 2 |
+
|
| 3 |
+
`Task3Method` is the part we tune -- features, head, hyperparameters. The protocol below it is
|
| 4 |
+
fixed so scores stay comparable across iterations: 20-fold over the 494 subjects, pool the
|
| 5 |
+
out-of-fold predictions, bootstrap subjects for the CI.
|
| 6 |
+
|
| 7 |
+
`train` runs that protocol then fits and saves a head; `predict` is the challenge contract, one t1
|
| 8 |
+
path in and one age out. Both go through `Task3Method.predict`, so every fold exercises the path
|
| 9 |
+
the submission will run.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import joblib
|
| 20 |
+
import nibabel as nib
|
| 21 |
+
import numpy as np
|
| 22 |
+
import torch
|
| 23 |
+
from omegaconf import OmegaConf
|
| 24 |
+
from sklearn.linear_model import RidgeCV
|
| 25 |
+
from sklearn.model_selection import KFold
|
| 26 |
+
from sklearn.pipeline import make_pipeline
|
| 27 |
+
from sklearn.preprocessing import StandardScaler
|
| 28 |
+
|
| 29 |
+
from fomo_tune.backbone import load_backbone
|
| 30 |
+
from fomo_tune.utils import git_sha, set_seed, setup_logging
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("fomo_tune")
|
| 33 |
+
|
| 34 |
+
Images = dict[str, nib.Nifti1Image]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Config:
|
| 39 |
+
task: str = "task3"
|
| 40 |
+
ckpt_path: str = (
|
| 41 |
+
"/data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth"
|
| 42 |
+
)
|
| 43 |
+
output_root: str = "output/fomo_tune"
|
| 44 |
+
name: str = "task3"
|
| 45 |
+
device: str = "cuda"
|
| 46 |
+
seed: int = 4466
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---- method: the part we tune -----------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class Task3Method:
|
| 53 |
+
"""Frozen sMRI MAE, mean-pooled tokens over the t1w, ridge head."""
|
| 54 |
+
|
| 55 |
+
def __init__(self, cfg: Config):
|
| 56 |
+
self.cfg = cfg
|
| 57 |
+
self.backbone, self.transform = load_backbone(cfg.ckpt_path)
|
| 58 |
+
self.device = torch.device(cfg.device)
|
| 59 |
+
self.backbone.to(self.device).eval().requires_grad_(False)
|
| 60 |
+
self.cache: dict[str, np.ndarray] = {}
|
| 61 |
+
self.head = None
|
| 62 |
+
|
| 63 |
+
@torch.inference_mode()
|
| 64 |
+
def features(self, images: Images) -> np.ndarray:
|
| 65 |
+
"""(D,) per subject. A pure function of the images, so training and inference agree."""
|
| 66 |
+
sample = self.transform(images["t1w"])
|
| 67 |
+
batch = {key: value[None].to(self.device) for key, value in sample.items()}
|
| 68 |
+
|
| 69 |
+
with torch.autocast("cuda", torch.bfloat16, enabled=self.device.type == "cuda"):
|
| 70 |
+
out = self.backbone(batch)
|
| 71 |
+
|
| 72 |
+
patch_embeds = out["patch_embeds"]
|
| 73 |
+
token_mask = out["token_mask"].bool().unsqueeze(-1)
|
| 74 |
+
embed = (patch_embeds * token_mask).sum(dim=1) / token_mask.sum(dim=1)
|
| 75 |
+
return embed[0].float().cpu().numpy()
|
| 76 |
+
|
| 77 |
+
def cached_features(self, row: dict) -> np.ndarray:
|
| 78 |
+
if row["subject"] not in self.cache:
|
| 79 |
+
self.cache[row["subject"]] = self.features(row)
|
| 80 |
+
return self.cache[row["subject"]]
|
| 81 |
+
|
| 82 |
+
def fit(self, rows: list[dict]) -> None:
|
| 83 |
+
X = np.stack([self.cached_features(row) for row in rows])
|
| 84 |
+
y = np.array([row["age"] for row in rows], dtype=float)
|
| 85 |
+
|
| 86 |
+
# RidgeCV picks alpha by its own efficient leave-one-out, so the fold's own split is
|
| 87 |
+
# never touched by model selection
|
| 88 |
+
self.head = make_pipeline(StandardScaler(), RidgeCV(alphas=np.logspace(-3, 6, 19)))
|
| 89 |
+
self.head.fit(X, y)
|
| 90 |
+
|
| 91 |
+
def predict(self, images: Images) -> float:
|
| 92 |
+
"""Age in years."""
|
| 93 |
+
X = self.features(images)[None]
|
| 94 |
+
return float(self.head.predict(X)[0])
|
| 95 |
+
|
| 96 |
+
def save(self, model_dir: Path) -> None:
|
| 97 |
+
"""Everything `load` needs but the backbone weights, which stay wherever `ckpt_path`
|
| 98 |
+
points -- a few hundred KB, so a run saves one without copying a 3.7G checkpoint."""
|
| 99 |
+
model_dir.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
OmegaConf.save(self.cfg, model_dir / "config.yaml")
|
| 101 |
+
joblib.dump(self.head, model_dir / "head.joblib")
|
| 102 |
+
|
| 103 |
+
@classmethod
|
| 104 |
+
def load(cls, model_dir: Path, **overrides) -> "Task3Method":
|
| 105 |
+
"""Rebuild a fitted method from `save`. Overrides are Config fields, for what differs
|
| 106 |
+
between here and the container -- the backbone path, the device."""
|
| 107 |
+
cfg = OmegaConf.merge(
|
| 108 |
+
OmegaConf.structured(Config), OmegaConf.load(model_dir / "config.yaml"), overrides
|
| 109 |
+
)
|
| 110 |
+
method = cls(cfg)
|
| 111 |
+
method.head = joblib.load(model_dir / "head.joblib")
|
| 112 |
+
return method
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---- protocol: the part we hold fixed ---------------------------------------------------
|
| 116 |
+
|
| 117 |
+
# Every image the task ships. The method picks which of them it wants, as at inference, where the
|
| 118 |
+
# challenge hands over the modalities whether or not a model uses them.
|
| 119 |
+
IMAGE_COLS = ("t1w",)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def cross_validate(
|
| 123 |
+
rows: list[dict], method: Task3Method, seed: int = 0, n_folds: int = 20
|
| 124 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 125 |
+
"""Out-of-fold age for every subject, each predicted by a head fit on the other folds."""
|
| 126 |
+
y = np.array([row["age"] for row in rows], dtype=float)
|
| 127 |
+
oof = np.zeros(len(rows), dtype=float)
|
| 128 |
+
folds = KFold(n_splits=n_folds, shuffle=True, random_state=seed)
|
| 129 |
+
start = time.perf_counter()
|
| 130 |
+
for fold, (train, test) in enumerate(folds.split(rows)):
|
| 131 |
+
method.fit([rows[i] for i in train])
|
| 132 |
+
for i in test:
|
| 133 |
+
oof[i] = method.predict({key: rows[i][key] for key in IMAGE_COLS})
|
| 134 |
+
logger.info(
|
| 135 |
+
f"fold {fold + 1}/{n_folds} n={len(test)} mae={np.abs(y[test] - oof[test]).mean():.2f} "
|
| 136 |
+
f"({time.perf_counter() - start:.0f}s)"
|
| 137 |
+
)
|
| 138 |
+
return y, oof
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def metrics(y: np.ndarray, oof: np.ndarray) -> dict:
|
| 142 |
+
return {
|
| 143 |
+
"pearson_r": float(np.corrcoef(y, oof)[0, 1]),
|
| 144 |
+
"mae": float(np.abs(y - oof).mean()),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def score(
|
| 149 |
+
y: np.ndarray, oof: np.ndarray, seed: int = 0, n_boot: int = 2000, alpha: float = 0.05
|
| 150 |
+
) -> dict:
|
| 151 |
+
"""Both challenge metrics, each with a percentile CI resampling subjects with replacement."""
|
| 152 |
+
rng = np.random.default_rng(seed)
|
| 153 |
+
resamples = rng.integers(0, len(y), size=(n_boot, len(y)))
|
| 154 |
+
|
| 155 |
+
summary = {}
|
| 156 |
+
for name, point in metrics(y, oof).items():
|
| 157 |
+
samples = [metrics(y[rows], oof[rows])[name] for rows in resamples]
|
| 158 |
+
low, high = np.percentile(samples, [100 * alpha / 2, 100 * (1 - alpha / 2)])
|
| 159 |
+
summary[name] = point
|
| 160 |
+
summary[f"{name}_ci_low"] = float(low)
|
| 161 |
+
summary[f"{name}_ci_high"] = float(high)
|
| 162 |
+
return summary
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---- entrypoints ------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def train(args: argparse.Namespace) -> None:
|
| 169 |
+
# imported here, not at the top, so the container needs no dataset stack to run `predict`
|
| 170 |
+
from fomo_tune.datasets import load_fomo_task3
|
| 171 |
+
|
| 172 |
+
cfg = OmegaConf.merge(OmegaConf.structured(Config), OmegaConf.from_dotlist(args.overrides))
|
| 173 |
+
run_dir = Path(cfg.output_root) / cfg.name
|
| 174 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
|
| 176 |
+
setup_logging(run_dir)
|
| 177 |
+
set_seed(cfg.seed)
|
| 178 |
+
logger.info(f"run {cfg.name} (git {git_sha()})")
|
| 179 |
+
logger.info(f"config:\n{OmegaConf.to_yaml(cfg).rstrip()}")
|
| 180 |
+
OmegaConf.save(cfg, run_dir / "config.yaml")
|
| 181 |
+
|
| 182 |
+
rows = list(load_fomo_task3())
|
| 183 |
+
ages = np.array([row["age"] for row in rows])
|
| 184 |
+
logger.info(
|
| 185 |
+
f"dataset: {len(rows)} subjects, age {ages.min()}-{ages.max()} mean {ages.mean():.1f}"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
method = Task3Method(cfg)
|
| 189 |
+
start = time.perf_counter()
|
| 190 |
+
y, oof = cross_validate(rows, method)
|
| 191 |
+
run_time = time.perf_counter() - start
|
| 192 |
+
summary = score(y, oof)
|
| 193 |
+
|
| 194 |
+
# the shipped head sees all n subjects, so it is not any of the models scored above
|
| 195 |
+
method.fit(rows)
|
| 196 |
+
method.save(run_dir / "model")
|
| 197 |
+
|
| 198 |
+
record = {"name": cfg.name, **summary, "run_time": round(run_time, 1)}
|
| 199 |
+
(run_dir / "metrics.json").write_text(json.dumps(record) + "\n")
|
| 200 |
+
scores = " ".join(f"{k}={v:.4f}" for k, v in summary.items())
|
| 201 |
+
logger.info(f"result: {scores} ({run_time:.0f}s)")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def predict(args: argparse.Namespace) -> None:
|
| 205 |
+
"""The challenge contract: a t1 path in, one age written to `--output`.
|
| 206 |
+
|
| 207 |
+
`/app/predict.py` in the container is a shim over this, so what scores the submission is the
|
| 208 |
+
code cross-validation already ran, not something generated at build time.
|
| 209 |
+
"""
|
| 210 |
+
overrides = {"device": args.device}
|
| 211 |
+
if args.ckpt_path:
|
| 212 |
+
overrides["ckpt_path"] = args.ckpt_path
|
| 213 |
+
method = Task3Method.load(args.model_dir, **overrides)
|
| 214 |
+
|
| 215 |
+
age = method.predict({"t1w": nib.load(args.t1)})
|
| 216 |
+
|
| 217 |
+
args.output.write_text(f"{age:.6f}\n")
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def main() -> None:
|
| 221 |
+
parser = argparse.ArgumentParser()
|
| 222 |
+
modes = parser.add_subparsers(required=True)
|
| 223 |
+
|
| 224 |
+
train_parser = modes.add_parser("train", help="cross-validate over the task, then fit and save")
|
| 225 |
+
train_parser.add_argument("overrides", nargs="*", help="config overrides, e.g. device=cpu")
|
| 226 |
+
train_parser.set_defaults(run=train)
|
| 227 |
+
|
| 228 |
+
predict_parser = modes.add_parser("predict", help="one subject, one age in years")
|
| 229 |
+
predict_parser.add_argument("--t1", type=Path, required=True)
|
| 230 |
+
predict_parser.add_argument("--output", type=Path, required=True)
|
| 231 |
+
predict_parser.add_argument("--model-dir", type=Path, default=Path("/app/model"))
|
| 232 |
+
predict_parser.add_argument("--ckpt-path", help="overrides the trained config's backbone path")
|
| 233 |
+
predict_parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 234 |
+
predict_parser.set_defaults(run=predict)
|
| 235 |
+
|
| 236 |
+
args = parser.parse_args()
|
| 237 |
+
args.run(args)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
if __name__ == "__main__":
|
| 241 |
+
main()
|