Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -0
- fomo_tune_baseline/build.sh +31 -0
- fomo_tune_baseline/launch.sh +43 -0
- fomo_tune_baseline/output/task1/build/Apptainer.def +31 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/README.md +239 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/backbone.py +153 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/datasets.py +205 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/main_task1.py +253 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/main_task3.py +241 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/main_task5.py +245 -0
- fomo_tune_baseline/output/task1/build/fomo_tune/utils.py +33 -0
- fomo_tune_baseline/output/task1/build/model/backbone.pth +3 -0
- fomo_tune_baseline/output/task1/build/model/config.yaml +8 -0
- fomo_tune_baseline/output/task1/build/model/head.joblib +3 -0
- fomo_tune_baseline/output/task1/build/predict.py +16 -0
- fomo_tune_baseline/output/task1/build/smri_mae/config/default_pretrain.yaml +98 -0
- fomo_tune_baseline/output/task1/build/smri_mae/main_pretrain.py +486 -0
- fomo_tune_baseline/output/task1/build/smri_mae/masking.py +80 -0
- fomo_tune_baseline/output/task1/build/smri_mae/model_mae.py +916 -0
- fomo_tune_baseline/output/task1/build/smri_mae/modules.py +453 -0
- fomo_tune_baseline/output/task1/build/smri_mae/utils.py +581 -0
- fomo_tune_baseline/output/task1/build/smri_mae/visualization.py +400 -0
- fomo_tune_baseline/output/task1/config.yaml +8 -0
- fomo_tune_baseline/output/task1/log.txt +33 -0
- fomo_tune_baseline/output/task1/metrics.json +1 -0
- fomo_tune_baseline/output/task1/model/config.yaml +8 -0
- fomo_tune_baseline/output/task1/model/head.joblib +3 -0
- fomo_tune_baseline/output/task1/task1.sif +3 -0
- fomo_tune_baseline/output/task3/build/Apptainer.def +31 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/README.md +239 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/backbone.py +153 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/datasets.py +205 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/main_task1.py +253 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/main_task3.py +241 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/main_task5.py +245 -0
- fomo_tune_baseline/output/task3/build/fomo_tune/utils.py +33 -0
- fomo_tune_baseline/output/task3/build/model/backbone.pth +3 -0
- fomo_tune_baseline/output/task3/build/model/config.yaml +6 -0
- fomo_tune_baseline/output/task3/build/model/head.joblib +3 -0
- fomo_tune_baseline/output/task3/build/predict.py +16 -0
- fomo_tune_baseline/output/task3/build/smri_mae/config/default_pretrain.yaml +98 -0
- fomo_tune_baseline/output/task3/build/smri_mae/main_pretrain.py +486 -0
- fomo_tune_baseline/output/task3/build/smri_mae/masking.py +80 -0
- fomo_tune_baseline/output/task3/build/smri_mae/model_mae.py +916 -0
- fomo_tune_baseline/output/task3/build/smri_mae/modules.py +453 -0
- fomo_tune_baseline/output/task3/build/smri_mae/utils.py +581 -0
- fomo_tune_baseline/output/task3/build/smri_mae/visualization.py +400 -0
- fomo_tune_baseline/output/task3/config.yaml +6 -0
- fomo_tune_baseline/output/task3/log.txt +30 -0
- fomo_tune_baseline/output/task3/metrics.json +1 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,6 @@ 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 |
+
fomo_tune_baseline/output/task1/task1.sif filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
fomo_tune_baseline/output/task3/task3.sif filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
fomo_tune_baseline/output/task5/task5.sif filter=lfs diff=lfs merge=lfs -text
|
fomo_tune_baseline/build.sh
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Package each trained run into its challenge .sif. Run on the login node, after launch.sh:
|
| 3 |
+
# apptainer lives there and on n-6 only, and a build needs no GPU driver.
|
| 4 |
+
#
|
| 5 |
+
# Slow. Apptainer always re-runs %post, so each of the three re-downloads ~3G of wheels.
|
| 6 |
+
|
| 7 |
+
set -euo pipefail
|
| 8 |
+
|
| 9 |
+
ROOT="/data/connor/nanobrain.1"
|
| 10 |
+
cd $ROOT
|
| 11 |
+
|
| 12 |
+
EXP_DIR="experiments/fomo_tune_baseline"
|
| 13 |
+
OUT_DIR="${EXP_DIR}/output"
|
| 14 |
+
|
| 15 |
+
runs=(task1 task5 task3)
|
| 16 |
+
|
| 17 |
+
for name in "${runs[@]}"; do
|
| 18 |
+
# build.py names the sif after `task` in the run's saved config, which is the run name here
|
| 19 |
+
sif="${OUT_DIR}/${name}/${name}.sif"
|
| 20 |
+
|
| 21 |
+
if [[ -f "${sif}" ]]; then
|
| 22 |
+
echo "sif ${sif} exists; skipping"
|
| 23 |
+
continue
|
| 24 |
+
fi
|
| 25 |
+
|
| 26 |
+
echo "=== ${name} ==="
|
| 27 |
+
uv run --no-sync python -m fomo_tune.build "${OUT_DIR}/${name}"
|
| 28 |
+
done
|
| 29 |
+
|
| 30 |
+
echo "=== sifs ==="
|
| 31 |
+
ls -lh "${OUT_DIR}"/*/*.sif
|
fomo_tune_baseline/launch.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
#SBATCH --job-name=fomo_tune
|
| 3 |
+
#SBATCH --nodes=1
|
| 4 |
+
#SBATCH --ntasks-per-node=1
|
| 5 |
+
#SBATCH --gpus-per-task=1
|
| 6 |
+
#SBATCH --time=2:00:00
|
| 7 |
+
#SBATCH --partition=main
|
| 8 |
+
#SBATCH --output=slurms/slurm-%j.out
|
| 9 |
+
#SBATCH --account=sophont
|
| 10 |
+
|
| 11 |
+
set -euo pipefail
|
| 12 |
+
|
| 13 |
+
ROOT="/data/connor/nanobrain.1"
|
| 14 |
+
cd $ROOT
|
| 15 |
+
|
| 16 |
+
EXP_DIR="experiments/fomo_tune_baseline"
|
| 17 |
+
OUT_DIR="${EXP_DIR}/output"
|
| 18 |
+
|
| 19 |
+
# name, module. Cheapest first, so a broken environment fails in 90s.
|
| 20 |
+
# One job, run sequentially: the whole grid is ~12 minutes and each task loads the same
|
| 21 |
+
# 3.9G checkpoint, so an array would only add queue time and cache contention.
|
| 22 |
+
runs=(
|
| 23 |
+
"task1 main_task1"
|
| 24 |
+
"task5 main_task5"
|
| 25 |
+
"task3 main_task3"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
for run in "${runs[@]}"; do
|
| 29 |
+
read -r name module <<<"${run}"
|
| 30 |
+
|
| 31 |
+
if [[ -f "${OUT_DIR}/${name}/metrics.json" ]]; then
|
| 32 |
+
echo "result ${name} exists; skipping"
|
| 33 |
+
continue
|
| 34 |
+
fi
|
| 35 |
+
|
| 36 |
+
echo "=== ${name} ==="
|
| 37 |
+
uv run --no-sync python -m "fomo_tune.${module}" train \
|
| 38 |
+
output_root="${OUT_DIR}" \
|
| 39 |
+
name="${name}"
|
| 40 |
+
done
|
| 41 |
+
|
| 42 |
+
echo "=== results ==="
|
| 43 |
+
cat "${OUT_DIR}"/*/metrics.json
|
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 "$@"
|
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.
|
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
|
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
|
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()
|
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()
|
fomo_tune_baseline/output/task1/build/fomo_tune/main_task5.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FOMO task 5: polymicrogyria classification, scored by AUROC as the challenge scores it.
|
| 2 |
+
|
| 3 |
+
`Task5Method` 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 48 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 probability out. Both go through `Task5Method.predict`, so every fold exercises
|
| 9 |
+
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
|
| 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.model_selection import KFold
|
| 27 |
+
from sklearn.pipeline import make_pipeline
|
| 28 |
+
from sklearn.preprocessing import StandardScaler
|
| 29 |
+
|
| 30 |
+
from fomo_tune.backbone import load_backbone
|
| 31 |
+
from fomo_tune.utils import git_sha, set_seed, setup_logging
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("fomo_tune")
|
| 34 |
+
|
| 35 |
+
Images = dict[str, nib.Nifti1Image]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Config:
|
| 40 |
+
task: str = "task5"
|
| 41 |
+
ckpt_path: str = (
|
| 42 |
+
"/data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth"
|
| 43 |
+
)
|
| 44 |
+
output_root: str = "output/fomo_tune"
|
| 45 |
+
name: str = "task5"
|
| 46 |
+
device: str = "cuda"
|
| 47 |
+
seed: int = 4466
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---- method: the part we tune -----------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Task5Method:
|
| 54 |
+
"""Frozen sMRI MAE, mean-pooled tokens over the t1w, 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.cache: dict[str, np.ndarray] = {}
|
| 62 |
+
self.head = None
|
| 63 |
+
|
| 64 |
+
@torch.inference_mode()
|
| 65 |
+
def features(self, images: Images) -> np.ndarray:
|
| 66 |
+
"""(D,) per subject. A pure function of the images, so training and inference agree."""
|
| 67 |
+
sample = self.transform(images["t1w"])
|
| 68 |
+
batch = {key: value[None].to(self.device) for key, value in sample.items()}
|
| 69 |
+
|
| 70 |
+
with torch.autocast("cuda", torch.bfloat16, enabled=self.device.type == "cuda"):
|
| 71 |
+
out = self.backbone(batch)
|
| 72 |
+
|
| 73 |
+
patch_embeds = out["patch_embeds"]
|
| 74 |
+
token_mask = out["token_mask"].bool().unsqueeze(-1)
|
| 75 |
+
embed = (patch_embeds * token_mask).sum(dim=1) / token_mask.sum(dim=1)
|
| 76 |
+
return embed[0].float().cpu().numpy()
|
| 77 |
+
|
| 78 |
+
def cached_features(self, row: dict) -> np.ndarray:
|
| 79 |
+
if row["subject"] not in self.cache:
|
| 80 |
+
self.cache[row["subject"]] = self.features(row)
|
| 81 |
+
return self.cache[row["subject"]]
|
| 82 |
+
|
| 83 |
+
def fit(self, rows: list[dict]) -> None:
|
| 84 |
+
X = np.stack([self.cached_features(row) for row in rows])
|
| 85 |
+
y = np.array([row["label"] for row in rows])
|
| 86 |
+
|
| 87 |
+
clf = LogisticRegressionCV(
|
| 88 |
+
Cs=10,
|
| 89 |
+
class_weight="balanced",
|
| 90 |
+
scoring="roc_auc",
|
| 91 |
+
max_iter=1000,
|
| 92 |
+
l1_ratios=(0,),
|
| 93 |
+
use_legacy_attributes=False,
|
| 94 |
+
)
|
| 95 |
+
self.head = make_pipeline(StandardScaler(), clf)
|
| 96 |
+
self.head.fit(X, y)
|
| 97 |
+
self.positive = list(self.head.classes_).index(1)
|
| 98 |
+
|
| 99 |
+
def predict(self, images: Images) -> float:
|
| 100 |
+
"""Positive-class probability. Indexes `classes_` rather than assuming column 1, which
|
| 101 |
+
would silently score the wrong class if the label order differed."""
|
| 102 |
+
X = self.features(images)[None]
|
| 103 |
+
probs = self.head.predict_proba(X)[0]
|
| 104 |
+
return float(probs[self.positive])
|
| 105 |
+
|
| 106 |
+
def save(self, model_dir: Path) -> None:
|
| 107 |
+
"""Everything `load` needs but the backbone weights, which stay wherever `ckpt_path`
|
| 108 |
+
points -- a few hundred KB, so a run saves one without copying a 3.7G checkpoint."""
|
| 109 |
+
model_dir.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
OmegaConf.save(self.cfg, model_dir / "config.yaml")
|
| 111 |
+
joblib.dump({"head": self.head, "positive": self.positive}, model_dir / "head.joblib")
|
| 112 |
+
|
| 113 |
+
@classmethod
|
| 114 |
+
def load(cls, model_dir: Path, **overrides) -> "Task5Method":
|
| 115 |
+
"""Rebuild a fitted method from `save`. Overrides are Config fields, for what differs
|
| 116 |
+
between here and the container -- the backbone path, the device."""
|
| 117 |
+
cfg = OmegaConf.merge(
|
| 118 |
+
OmegaConf.structured(Config), OmegaConf.load(model_dir / "config.yaml"), overrides
|
| 119 |
+
)
|
| 120 |
+
method = cls(cfg)
|
| 121 |
+
state = joblib.load(model_dir / "head.joblib")
|
| 122 |
+
method.head, method.positive = state["head"], state["positive"]
|
| 123 |
+
return method
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---- protocol: the part we hold fixed ---------------------------------------------------
|
| 127 |
+
|
| 128 |
+
# Every image the task ships. The method picks which of them it wants, as at inference, where the
|
| 129 |
+
# challenge hands over the modalities whether or not a model uses them.
|
| 130 |
+
IMAGE_COLS = ("t1w",)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def cross_validate(
|
| 134 |
+
rows: list[dict], method: Task5Method, seed: int = 0, n_folds: int = 20
|
| 135 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 136 |
+
"""Out-of-fold score for every subject, each predicted by a head fit on the other folds."""
|
| 137 |
+
y = np.array([row["label"] for row in rows])
|
| 138 |
+
oof = np.zeros(len(rows), dtype=float)
|
| 139 |
+
folds = KFold(n_splits=n_folds, shuffle=True, random_state=seed)
|
| 140 |
+
start = time.perf_counter()
|
| 141 |
+
for fold, (train, test) in enumerate(folds.split(rows)):
|
| 142 |
+
method.fit([rows[i] for i in train])
|
| 143 |
+
for i in test:
|
| 144 |
+
oof[i] = method.predict({key: rows[i][key] for key in IMAGE_COLS})
|
| 145 |
+
logger.info(
|
| 146 |
+
f"fold {fold + 1}/{n_folds} n={len(test)} y={y[test]} "
|
| 147 |
+
f"p={np.round(oof[test], 3)} ({time.perf_counter() - start:.0f}s)"
|
| 148 |
+
)
|
| 149 |
+
return y, oof
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def score(
|
| 153 |
+
y: np.ndarray, oof: np.ndarray, seed: int = 0, n_boot: int = 2000, alpha: float = 0.05
|
| 154 |
+
) -> dict:
|
| 155 |
+
"""AUROC, the challenge metric, plus a percentile CI resampling subjects with replacement."""
|
| 156 |
+
rng = np.random.default_rng(seed)
|
| 157 |
+
samples = []
|
| 158 |
+
for _ in range(n_boot):
|
| 159 |
+
rows = rng.integers(0, len(y), size=len(y))
|
| 160 |
+
if len(np.unique(y[rows])) < 2:
|
| 161 |
+
continue
|
| 162 |
+
samples.append(roc_auc_score(y[rows], oof[rows]))
|
| 163 |
+
|
| 164 |
+
low, high = np.percentile(samples, [100 * alpha / 2, 100 * (1 - alpha / 2)])
|
| 165 |
+
return {
|
| 166 |
+
"auroc": float(roc_auc_score(y, oof)),
|
| 167 |
+
"auroc_ci_low": float(low),
|
| 168 |
+
"auroc_ci_high": float(high),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---- entrypoints ------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def train(args: argparse.Namespace) -> None:
|
| 176 |
+
# imported here, not at the top, so the container needs no dataset stack to run `predict`
|
| 177 |
+
from fomo_tune.datasets import load_fomo_task5
|
| 178 |
+
|
| 179 |
+
cfg = OmegaConf.merge(OmegaConf.structured(Config), OmegaConf.from_dotlist(args.overrides))
|
| 180 |
+
run_dir = Path(cfg.output_root) / cfg.name
|
| 181 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 182 |
+
|
| 183 |
+
setup_logging(run_dir)
|
| 184 |
+
set_seed(cfg.seed)
|
| 185 |
+
logger.info(f"run {cfg.name} (git {git_sha()})")
|
| 186 |
+
logger.info(f"config:\n{OmegaConf.to_yaml(cfg).rstrip()}")
|
| 187 |
+
OmegaConf.save(cfg, run_dir / "config.yaml")
|
| 188 |
+
|
| 189 |
+
rows = list(load_fomo_task5())
|
| 190 |
+
logger.info(f"dataset: {len(rows)} subjects, {sum(r['label'] for r in rows)} positive")
|
| 191 |
+
|
| 192 |
+
method = Task5Method(cfg)
|
| 193 |
+
start = time.perf_counter()
|
| 194 |
+
y, oof = cross_validate(rows, method)
|
| 195 |
+
run_time = time.perf_counter() - start
|
| 196 |
+
summary = score(y, oof)
|
| 197 |
+
|
| 198 |
+
# the shipped head sees all n subjects, so it is not any of the models scored above
|
| 199 |
+
method.fit(rows)
|
| 200 |
+
method.save(run_dir / "model")
|
| 201 |
+
|
| 202 |
+
record = {"name": cfg.name, **summary, "run_time": round(run_time, 1)}
|
| 203 |
+
(run_dir / "metrics.json").write_text(json.dumps(record) + "\n")
|
| 204 |
+
scores = " ".join(f"{k}={v:.4f}" for k, v in summary.items())
|
| 205 |
+
logger.info(f"result: {scores} ({run_time:.0f}s)")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def predict(args: argparse.Namespace) -> None:
|
| 209 |
+
"""The challenge contract: a t1 path in, one probability written to `--output`.
|
| 210 |
+
|
| 211 |
+
`/app/predict.py` in the container is a shim over this, so what scores the submission is the
|
| 212 |
+
code cross-validation already ran, not something generated at build time.
|
| 213 |
+
"""
|
| 214 |
+
overrides = {"device": args.device}
|
| 215 |
+
if args.ckpt_path:
|
| 216 |
+
overrides["ckpt_path"] = args.ckpt_path
|
| 217 |
+
method = Task5Method.load(args.model_dir, **overrides)
|
| 218 |
+
|
| 219 |
+
probability = method.predict({"t1w": nib.load(args.t1)})
|
| 220 |
+
|
| 221 |
+
args.output.write_text(f"{probability:.6f}\n")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def main() -> None:
|
| 225 |
+
parser = argparse.ArgumentParser()
|
| 226 |
+
modes = parser.add_subparsers(required=True)
|
| 227 |
+
|
| 228 |
+
train_parser = modes.add_parser("train", help="cross-validate over the task, then fit and save")
|
| 229 |
+
train_parser.add_argument("overrides", nargs="*", help="config overrides, e.g. device=cpu")
|
| 230 |
+
train_parser.set_defaults(run=train)
|
| 231 |
+
|
| 232 |
+
predict_parser = modes.add_parser("predict", help="one subject, one probability")
|
| 233 |
+
predict_parser.add_argument("--t1", type=Path, required=True)
|
| 234 |
+
predict_parser.add_argument("--output", type=Path, required=True)
|
| 235 |
+
predict_parser.add_argument("--model-dir", type=Path, default=Path("/app/model"))
|
| 236 |
+
predict_parser.add_argument("--ckpt-path", help="overrides the trained config's backbone path")
|
| 237 |
+
predict_parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 238 |
+
predict_parser.set_defaults(run=predict)
|
| 239 |
+
|
| 240 |
+
args = parser.parse_args()
|
| 241 |
+
args.run(args)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
if __name__ == "__main__":
|
| 245 |
+
main()
|
fomo_tune_baseline/output/task1/build/fomo_tune/utils.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import random
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("fomo_tune")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def set_seed(seed: int) -> None:
|
| 14 |
+
random.seed(seed)
|
| 15 |
+
np.random.seed(seed)
|
| 16 |
+
torch.manual_seed(seed)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def git_sha() -> str:
|
| 20 |
+
kwargs = dict(cwd=Path(__file__).parent, capture_output=True, text=True, check=True)
|
| 21 |
+
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], **kwargs).stdout.strip()
|
| 22 |
+
dirty = subprocess.run(["git", "status", "--porcelain", "-uno"], **kwargs).stdout.strip()
|
| 23 |
+
return f"{sha}-dirty" if dirty else sha
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def setup_logging(run_dir: Path) -> None:
|
| 27 |
+
handlers = [logging.StreamHandler(sys.stdout), logging.FileHandler(run_dir / "log.txt")]
|
| 28 |
+
logger.setLevel(logging.INFO)
|
| 29 |
+
logger.handlers.clear()
|
| 30 |
+
for handler in handlers:
|
| 31 |
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S"))
|
| 32 |
+
logger.addHandler(handler)
|
| 33 |
+
logger.propagate = False
|
fomo_tune_baseline/output/task1/build/model/backbone.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aacf2582e9464e7fd4bd3d5ed41a5b9aa770e5b04a24b2a82c1c4ba5fb5f6e7a
|
| 3 |
+
size 1389681124
|
fomo_tune_baseline/output/task1/build/model/config.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: task1
|
| 2 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 3 |
+
modalities:
|
| 4 |
+
- dwi_b1000
|
| 5 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 6 |
+
name: task1
|
| 7 |
+
device: cuda
|
| 8 |
+
seed: 4466
|
fomo_tune_baseline/output/task1/build/model/head.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f4a4826f5ea9f3f74279b798caf6a5970a3c63b02b70c4d0828ee5325d29e0dd
|
| 3 |
+
size 445215
|
fomo_tune_baseline/output/task1/build/predict.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
from fomo_tune.main_task1 import main
|
| 5 |
+
|
| 6 |
+
sys.argv = [
|
| 7 |
+
sys.argv[0],
|
| 8 |
+
"predict",
|
| 9 |
+
*sys.argv[1:],
|
| 10 |
+
"--model-dir",
|
| 11 |
+
"/app/model",
|
| 12 |
+
"--ckpt-path",
|
| 13 |
+
"/app/model/backbone.pth",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
main()
|
fomo_tune_baseline/output/task1/build/smri_mae/config/default_pretrain.yaml
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Name of the run. Used for output directory suffix and wandb.
|
| 2 |
+
name: pretrain
|
| 3 |
+
|
| 4 |
+
# Description of the run. Goes in wandb notes.
|
| 5 |
+
notes: null
|
| 6 |
+
|
| 7 |
+
# Root output directory.
|
| 8 |
+
# The run writes to checkpoints/<name> when name is set.
|
| 9 |
+
output_dir: checkpoints
|
| 10 |
+
|
| 11 |
+
# Standard 3D structural MRI volume size.
|
| 12 |
+
img_size: [208, 240, 208]
|
| 13 |
+
patch_size: 8
|
| 14 |
+
|
| 15 |
+
# Masking.
|
| 16 |
+
mask_ratio: 0.80
|
| 17 |
+
pred_mask_ratio: null
|
| 18 |
+
pad_to_multiple: 32
|
| 19 |
+
|
| 20 |
+
# Model.
|
| 21 |
+
model: mae_vit_large
|
| 22 |
+
model_kwargs:
|
| 23 |
+
# target normalization: null/none, global, slice, or patch.
|
| 24 |
+
target_norm: none
|
| 25 |
+
|
| 26 |
+
no_decode_pos: false
|
| 27 |
+
mask_drop_scale: false
|
| 28 |
+
|
| 29 |
+
class_token: true
|
| 30 |
+
reg_tokens: 0
|
| 31 |
+
no_embed_class: false
|
| 32 |
+
|
| 33 |
+
decoder_depth: 4
|
| 34 |
+
drop_path_rate: 0.0
|
| 35 |
+
|
| 36 |
+
# Datasets.
|
| 37 |
+
datasets:
|
| 38 |
+
fomo_train:
|
| 39 |
+
url: datasets/FOMO_with_dwi/shard.{000000..001620}.tar
|
| 40 |
+
samples_per_epoch: 243200
|
| 41 |
+
shuffle: true
|
| 42 |
+
buffer_size: 8000
|
| 43 |
+
drop_last: true
|
| 44 |
+
|
| 45 |
+
fomo_val:
|
| 46 |
+
url: datasets/FOMO_with_dwi/shard.{001621..001800}.tar
|
| 47 |
+
samples_per_epoch: 26880
|
| 48 |
+
shuffle: false
|
| 49 |
+
buffer_size: 1000
|
| 50 |
+
drop_last: true
|
| 51 |
+
|
| 52 |
+
train_dataset: fomo_train
|
| 53 |
+
eval_datasets:
|
| 54 |
+
- fomo_val
|
| 55 |
+
|
| 56 |
+
# Data loader.
|
| 57 |
+
num_workers: 4
|
| 58 |
+
prefetch_factor: 2
|
| 59 |
+
presend_cuda: true
|
| 60 |
+
|
| 61 |
+
# Optimization.
|
| 62 |
+
epochs: 100
|
| 63 |
+
batch_size: 64
|
| 64 |
+
accum_iter: 1
|
| 65 |
+
|
| 66 |
+
base_lr: 0.001
|
| 67 |
+
min_lr: 1e-6
|
| 68 |
+
warmup_epochs: 10
|
| 69 |
+
weight_decay: 0.05
|
| 70 |
+
betas: [0.9, 0.95]
|
| 71 |
+
clip_grad: 1.0
|
| 72 |
+
|
| 73 |
+
amp: true
|
| 74 |
+
amp_dtype: bfloat16
|
| 75 |
+
|
| 76 |
+
# Checkpointing.
|
| 77 |
+
ckpt: null
|
| 78 |
+
resume: false
|
| 79 |
+
auto_resume: true
|
| 80 |
+
start_epoch: 0
|
| 81 |
+
checkpoint_period: 10
|
| 82 |
+
max_checkpoints: 5
|
| 83 |
+
|
| 84 |
+
# Evaluation.
|
| 85 |
+
eval_period: 10
|
| 86 |
+
|
| 87 |
+
# Sync checkpoints to an R2 bucket using the AWS CLI. Set to a URL to enable.
|
| 88 |
+
r2_sync: null
|
| 89 |
+
|
| 90 |
+
device: cuda
|
| 91 |
+
distributed: false
|
| 92 |
+
seed: 7338
|
| 93 |
+
eval_seed: 7338
|
| 94 |
+
debug: false
|
| 95 |
+
|
| 96 |
+
wandb: false
|
| 97 |
+
wandb_entity: null
|
| 98 |
+
wandb_project: smri-fm
|
fomo_tune_baseline/output/task1/build/smri_mae/main_pretrain.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# deit: https://github.com/facebookresearch/deit/blob/main/main.py
|
| 6 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/train_capi.py
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import datetime
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
import random
|
| 13 |
+
import subprocess
|
| 14 |
+
import time
|
| 15 |
+
from contextlib import nullcontext
|
| 16 |
+
from functools import partial
|
| 17 |
+
from itertools import islice
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Iterable, Sequence
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import wandb
|
| 24 |
+
import webdataset as wds
|
| 25 |
+
from omegaconf import DictConfig, OmegaConf
|
| 26 |
+
from PIL import Image
|
| 27 |
+
|
| 28 |
+
from matplotlib import pyplot as plt
|
| 29 |
+
from torch import Tensor
|
| 30 |
+
|
| 31 |
+
import data.mri_data as mri_data
|
| 32 |
+
import smri_mae.model_mae as models_mae
|
| 33 |
+
import smri_mae.utils as ut
|
| 34 |
+
import smri_mae.visualization as vis
|
| 35 |
+
|
| 36 |
+
DEFAULT_CONFIG = Path(__file__).parent / "config/default_pretrain.yaml"
|
| 37 |
+
|
| 38 |
+
MODELS_DICT = models_mae.__dict__
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main(args: DictConfig):
|
| 42 |
+
# setup
|
| 43 |
+
ut.init_distributed_mode(args)
|
| 44 |
+
global_rank = ut.get_rank()
|
| 45 |
+
is_master = global_rank == 0
|
| 46 |
+
world_size = ut.get_world_size()
|
| 47 |
+
device = torch.device(args.device)
|
| 48 |
+
ut.configure_flash_sdpa()
|
| 49 |
+
ut.random_seed(args.seed, rank=global_rank)
|
| 50 |
+
|
| 51 |
+
if args.name and not args.output_dir.endswith(args.name):
|
| 52 |
+
args.output_dir = f"{args.output_dir}/{args.name}"
|
| 53 |
+
output_dir = Path(args.output_dir)
|
| 54 |
+
|
| 55 |
+
if is_master:
|
| 56 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
out_cfg_path = output_dir / "config.yaml"
|
| 58 |
+
if out_cfg_path.exists():
|
| 59 |
+
prev_cfg = OmegaConf.load(out_cfg_path)
|
| 60 |
+
assert args == prev_cfg, "current config doesn't match previous config"
|
| 61 |
+
else:
|
| 62 |
+
OmegaConf.save(args, out_cfg_path)
|
| 63 |
+
|
| 64 |
+
if args.wandb:
|
| 65 |
+
wandb.init(
|
| 66 |
+
entity=args.wandb_entity,
|
| 67 |
+
project=args.wandb_project,
|
| 68 |
+
name=args.name,
|
| 69 |
+
notes=args.notes,
|
| 70 |
+
config=OmegaConf.to_container(args),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
ut.setup_for_distributed(log_path=output_dir / "log.txt")
|
| 74 |
+
|
| 75 |
+
print("pretraining 3D ViTMAE")
|
| 76 |
+
print(f"start: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 77 |
+
print(f"cwd: {Path.cwd()}")
|
| 78 |
+
print(ut.get_sha())
|
| 79 |
+
print("config:", OmegaConf.to_yaml(args), sep="\n")
|
| 80 |
+
|
| 81 |
+
# data loaders
|
| 82 |
+
train_loader, eval_loaders = create_data_loaders(args)
|
| 83 |
+
|
| 84 |
+
# model
|
| 85 |
+
model = MODELS_DICT[args.model](
|
| 86 |
+
img_size=args.img_size,
|
| 87 |
+
in_chans=args.get("in_chans", 1),
|
| 88 |
+
patch_size=args.patch_size,
|
| 89 |
+
**(args.get("model_kwargs") or {}),
|
| 90 |
+
)
|
| 91 |
+
model.to(device)
|
| 92 |
+
print("model:", model, sep="\n")
|
| 93 |
+
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 94 |
+
print(f"num params: {num_params / 1e6:.1f}M")
|
| 95 |
+
|
| 96 |
+
model_without_ddp = model
|
| 97 |
+
if args.distributed:
|
| 98 |
+
model = torch.nn.parallel.DistributedDataParallel(
|
| 99 |
+
model,
|
| 100 |
+
device_ids=[args.gpu],
|
| 101 |
+
gradient_as_bucket_view=True,
|
| 102 |
+
)
|
| 103 |
+
model_without_ddp = model.module
|
| 104 |
+
|
| 105 |
+
# optimizer
|
| 106 |
+
total_batch_size = args.batch_size * args.accum_iter * world_size
|
| 107 |
+
print(
|
| 108 |
+
f"total batch size: {total_batch_size} = "
|
| 109 |
+
f"{args.batch_size} bs per gpu x {args.accum_iter} accum x {world_size} gpus"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if not args.get("lr"):
|
| 113 |
+
args.lr = args.base_lr * total_batch_size / 256
|
| 114 |
+
print(f"lr: {args.lr:.2e} = {args.base_lr:.2e} x {total_batch_size} / 256")
|
| 115 |
+
else:
|
| 116 |
+
print(f"lr: {args.lr:.2e}")
|
| 117 |
+
|
| 118 |
+
param_groups = ut.get_param_groups(model)
|
| 119 |
+
ut.update_lr(param_groups, args.lr)
|
| 120 |
+
ut.update_wd(param_groups, args.weight_decay)
|
| 121 |
+
# cast or else it corrupts the checkpoint
|
| 122 |
+
betas = tuple(args.betas) if args.betas is not None else None
|
| 123 |
+
optimizer = torch.optim.AdamW(param_groups, betas=betas, fused=True)
|
| 124 |
+
|
| 125 |
+
epoch_num_batches = len(train_loader)
|
| 126 |
+
steps_per_epoch = math.ceil(epoch_num_batches / args.accum_iter)
|
| 127 |
+
total_steps = args.epochs * steps_per_epoch
|
| 128 |
+
warmup_steps = args.warmup_epochs * steps_per_epoch
|
| 129 |
+
lr_schedule = ut.WarmupThenCosine(
|
| 130 |
+
base_value=args.lr,
|
| 131 |
+
final_value=args.min_lr,
|
| 132 |
+
total_iters=total_steps,
|
| 133 |
+
warmup_iters=warmup_steps,
|
| 134 |
+
)
|
| 135 |
+
print(f"full schedule: epochs = {args.epochs} (steps = {total_steps})")
|
| 136 |
+
print(f"warmup: epochs = {args.warmup_epochs} (steps = {warmup_steps})")
|
| 137 |
+
|
| 138 |
+
# loss scaling not needed for bfloat16 (according to timm)
|
| 139 |
+
if args.amp and args.amp_dtype != "bfloat16":
|
| 140 |
+
loss_scaler = torch.GradScaler(device.type)
|
| 141 |
+
else:
|
| 142 |
+
loss_scaler = None
|
| 143 |
+
|
| 144 |
+
# load checkpoint/resume training
|
| 145 |
+
ut.load_model(args, model_without_ddp, optimizer, loss_scaler)
|
| 146 |
+
|
| 147 |
+
print(f"start training for {args.epochs} epochs")
|
| 148 |
+
start_time = time.monotonic()
|
| 149 |
+
for epoch in range(args.start_epoch, args.epochs):
|
| 150 |
+
train_stats = train_one_epoch(
|
| 151 |
+
args,
|
| 152 |
+
model,
|
| 153 |
+
train_loader,
|
| 154 |
+
optimizer,
|
| 155 |
+
loss_scaler,
|
| 156 |
+
lr_schedule,
|
| 157 |
+
epoch,
|
| 158 |
+
device,
|
| 159 |
+
)
|
| 160 |
+
eval_stats = {}
|
| 161 |
+
eval_plots = {}
|
| 162 |
+
eval_period = args.get("eval_period", 1)
|
| 163 |
+
if eval_period and (epoch % eval_period == 0 or epoch == args.epochs - 1):
|
| 164 |
+
for name, loader in eval_loaders.items():
|
| 165 |
+
stats, plots = evaluate(
|
| 166 |
+
args,
|
| 167 |
+
model,
|
| 168 |
+
loader,
|
| 169 |
+
epoch,
|
| 170 |
+
device,
|
| 171 |
+
eval_name=name,
|
| 172 |
+
)
|
| 173 |
+
eval_stats.update(stats)
|
| 174 |
+
eval_plots.update(plots)
|
| 175 |
+
|
| 176 |
+
merged_stats = {"epoch": epoch, **train_stats, **eval_stats}
|
| 177 |
+
if is_master:
|
| 178 |
+
with (output_dir / "log.json").open("a") as f:
|
| 179 |
+
print(json.dumps(merged_stats), file=f)
|
| 180 |
+
|
| 181 |
+
for plot_name, img in eval_plots.items():
|
| 182 |
+
plot_name = plot_name.replace("/", "__")
|
| 183 |
+
img.save(output_dir / f"{plot_name}__{epoch:05d}.png")
|
| 184 |
+
|
| 185 |
+
ut.save_model(args, epoch, model_without_ddp, optimizer, loss_scaler)
|
| 186 |
+
sync_checkpoints_to_r2(args, output_dir)
|
| 187 |
+
|
| 188 |
+
if args.distributed:
|
| 189 |
+
torch.distributed.destroy_process_group()
|
| 190 |
+
|
| 191 |
+
total_time = time.monotonic() - start_time
|
| 192 |
+
print(f"done! training time: {datetime.timedelta(seconds=int(total_time))}")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def create_data_loaders(args: DictConfig):
|
| 196 |
+
data_loaders = {}
|
| 197 |
+
dataset_names = [args.train_dataset] + args.eval_datasets
|
| 198 |
+
|
| 199 |
+
for dataset_name in dataset_names:
|
| 200 |
+
dataset_config = args.datasets[dataset_name].copy()
|
| 201 |
+
drop_last = dataset_config.pop("drop_last")
|
| 202 |
+
is_train = dataset_name == args.train_dataset
|
| 203 |
+
|
| 204 |
+
print(f"loading dataset: {dataset_name}\n\n{OmegaConf.to_yaml(dataset_config)}")
|
| 205 |
+
shuffle = dataset_config["shuffle"]
|
| 206 |
+
samples_per_epoch = dataset_config.pop("samples_per_epoch")
|
| 207 |
+
dataset = mri_data.make_sparse_wds_dataset(
|
| 208 |
+
dataset_config["url"],
|
| 209 |
+
shuffle=shuffle,
|
| 210 |
+
buffer_size=dataset_config["buffer_size"],
|
| 211 |
+
)
|
| 212 |
+
num_workers = int(args.num_workers)
|
| 213 |
+
loader_kwargs = {
|
| 214 |
+
"batch_size": args.batch_size,
|
| 215 |
+
"collate_fn": partial(mri_data.collate, include_meta=not is_train),
|
| 216 |
+
"shuffle": False,
|
| 217 |
+
"num_workers": num_workers,
|
| 218 |
+
"persistent_workers": num_workers > 0,
|
| 219 |
+
"pin_memory": True,
|
| 220 |
+
"drop_last": drop_last,
|
| 221 |
+
"prefetch_factor": args.prefetch_factor,
|
| 222 |
+
}
|
| 223 |
+
loader = wds.WebLoader(dataset, **loader_kwargs)
|
| 224 |
+
num_batches = samples_per_epoch // (ut.get_world_size() * args.batch_size)
|
| 225 |
+
loader = loader.with_epoch(num_batches)
|
| 226 |
+
loader = loader.with_length(num_batches, silent=True)
|
| 227 |
+
|
| 228 |
+
data_loaders[dataset_name] = loader
|
| 229 |
+
|
| 230 |
+
train_loader = data_loaders.pop(args.train_dataset)
|
| 231 |
+
return train_loader, data_loaders
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def sync_checkpoints_to_r2(args: DictConfig, output_dir: Path) -> None:
|
| 235 |
+
r2_sync_url = args.get("r2_sync")
|
| 236 |
+
if not r2_sync_url or not ut.is_main_process():
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
+
cmd = ["aws", "s3", "sync", str(output_dir), str(r2_sync_url), "--profile", "r2"]
|
| 240 |
+
print(f"syncing checkpoints to R2: {output_dir} -> {r2_sync_url}")
|
| 241 |
+
subprocess.run(cmd, check=True)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def train_one_epoch(
|
| 245 |
+
args: DictConfig,
|
| 246 |
+
model: nn.Module,
|
| 247 |
+
data_loader: Iterable,
|
| 248 |
+
optimizer: torch.optim.Optimizer,
|
| 249 |
+
loss_scaler: torch.GradScaler | None,
|
| 250 |
+
lr_schedule: Sequence[float],
|
| 251 |
+
epoch: int,
|
| 252 |
+
device: torch.device,
|
| 253 |
+
):
|
| 254 |
+
model.train()
|
| 255 |
+
|
| 256 |
+
metric_logger = ut.MetricLogger(delimiter=" ")
|
| 257 |
+
metric_logger.add_meter("lr", ut.SmoothedValue(window_size=1, fmt="{value:.6f}"))
|
| 258 |
+
metric_logger.add_meter("grad", ut.SmoothedValue())
|
| 259 |
+
header = f"Train: [{epoch}]"
|
| 260 |
+
log_wandb = args.wandb and ut.is_main_process()
|
| 261 |
+
|
| 262 |
+
epoch_num_batches = len(data_loader)
|
| 263 |
+
steps_per_epoch = math.ceil(epoch_num_batches / args.accum_iter)
|
| 264 |
+
|
| 265 |
+
print_freq = args.get("print_freq", 100) if not args.debug else 1
|
| 266 |
+
num_batches = epoch_num_batches if not args.debug else 10
|
| 267 |
+
amp_dtype = getattr(torch, args.amp_dtype)
|
| 268 |
+
use_cuda = device.type == "cuda"
|
| 269 |
+
if use_cuda and args.presend_cuda:
|
| 270 |
+
data_loader = ut.pre_send_to_cuda_wrapper(
|
| 271 |
+
data_loader, device, dtype_map={torch.float16: amp_dtype}
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
optimizer.zero_grad()
|
| 275 |
+
|
| 276 |
+
for batch_idx, batch in enumerate(
|
| 277 |
+
metric_logger.log_every(data_loader, print_freq, header, total_steps=num_batches)
|
| 278 |
+
):
|
| 279 |
+
if use_cuda and not args.presend_cuda:
|
| 280 |
+
batch = ut.send_data(batch, device, dtype_map={torch.float16: amp_dtype})
|
| 281 |
+
|
| 282 |
+
batch_step = batch_idx + 1
|
| 283 |
+
log_step = batch_step % print_freq == 0 or batch_step == num_batches
|
| 284 |
+
update_in_epoch = batch_idx // args.accum_iter
|
| 285 |
+
group_size = min(args.accum_iter, num_batches - update_in_epoch * args.accum_iter)
|
| 286 |
+
need_update = batch_step % args.accum_iter == 0 or batch_step == num_batches
|
| 287 |
+
global_step = epoch * steps_per_epoch + update_in_epoch
|
| 288 |
+
lr = lr_schedule[global_step]
|
| 289 |
+
if need_update:
|
| 290 |
+
ut.update_lr(optimizer.param_groups, lr)
|
| 291 |
+
|
| 292 |
+
images, img_mask = mri_data.densify_sparse_image_batch(
|
| 293 |
+
batch["image_values"],
|
| 294 |
+
batch["img_mask"],
|
| 295 |
+
(int(args.get("in_chans", 1)), *args.img_size),
|
| 296 |
+
dtype=amp_dtype,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
sync_context = model.no_sync() if args.distributed and not need_update else nullcontext()
|
| 300 |
+
with sync_context:
|
| 301 |
+
with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=args.amp):
|
| 302 |
+
loss = model(
|
| 303 |
+
images,
|
| 304 |
+
img_mask=img_mask,
|
| 305 |
+
mask_ratio=args.mask_ratio,
|
| 306 |
+
pred_mask_ratio=args.pred_mask_ratio,
|
| 307 |
+
pad_to_multiple=args.pad_to_multiple,
|
| 308 |
+
with_state=False,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
loss_for_log = loss.detach()
|
| 312 |
+
torch._assert_async(torch.isfinite(loss_for_log), "non-finite loss")
|
| 313 |
+
|
| 314 |
+
grad_norm = ut.backward_step(
|
| 315 |
+
loss / group_size,
|
| 316 |
+
optimizer,
|
| 317 |
+
scaler=loss_scaler,
|
| 318 |
+
need_update=need_update,
|
| 319 |
+
max_norm=args.clip_grad,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
if need_update and log_step:
|
| 323 |
+
loss_value = loss_for_log.item()
|
| 324 |
+
grad_norm_value = grad_norm.item()
|
| 325 |
+
metric_logger.update(loss=loss_value, lr=lr, grad=grad_norm_value)
|
| 326 |
+
if log_wandb:
|
| 327 |
+
wandb.log(
|
| 328 |
+
{
|
| 329 |
+
"train/loss": loss_value,
|
| 330 |
+
"train/lr": lr,
|
| 331 |
+
"train/grad": grad_norm_value,
|
| 332 |
+
},
|
| 333 |
+
step=int(1000 * (epoch + batch_step / epoch_num_batches)),
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# gather the stats from all processes
|
| 337 |
+
metric_logger.synchronize_between_processes()
|
| 338 |
+
print("Averaged stats:", metric_logger)
|
| 339 |
+
return {f"train/{k}": meter.global_avg for k, meter in metric_logger.meters.items()}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@torch.inference_mode()
|
| 343 |
+
def evaluate(
|
| 344 |
+
args: DictConfig,
|
| 345 |
+
model: nn.Module,
|
| 346 |
+
data_loader: Iterable,
|
| 347 |
+
epoch: int,
|
| 348 |
+
device: torch.device,
|
| 349 |
+
eval_name: str,
|
| 350 |
+
):
|
| 351 |
+
model.eval()
|
| 352 |
+
|
| 353 |
+
metric_logger = ut.MetricLogger(delimiter=" ")
|
| 354 |
+
header = f"Eval ({eval_name}): [{epoch}]"
|
| 355 |
+
is_master = ut.is_main_process()
|
| 356 |
+
log_wandb = args.wandb and is_master
|
| 357 |
+
|
| 358 |
+
epoch_num_batches = len(data_loader)
|
| 359 |
+
if epoch_num_batches <= 0:
|
| 360 |
+
raise ValueError(f"eval loader {eval_name!r} has zero batches")
|
| 361 |
+
|
| 362 |
+
print_freq = args.get("print_freq", 100) if not args.debug else 1
|
| 363 |
+
num_batches = epoch_num_batches if not args.debug else 10
|
| 364 |
+
num_batches = min(num_batches, epoch_num_batches)
|
| 365 |
+
eval_seed = int(args.get("eval_seed", args.seed)) + ut.get_rank()
|
| 366 |
+
example_step = random.Random(eval_seed).randint(1, num_batches)
|
| 367 |
+
amp_dtype = getattr(torch, args.amp_dtype)
|
| 368 |
+
use_cuda = device.type == "cuda"
|
| 369 |
+
rng_state = ut.capture_rng_state()
|
| 370 |
+
torch.set_rng_state(torch.Generator().manual_seed(eval_seed).get_state())
|
| 371 |
+
if use_cuda:
|
| 372 |
+
torch.cuda.manual_seed(eval_seed)
|
| 373 |
+
if use_cuda and args.presend_cuda:
|
| 374 |
+
data_loader = ut.pre_send_to_cuda_wrapper(
|
| 375 |
+
data_loader, device, dtype_map={torch.float16: amp_dtype}
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
eval_batches = islice(data_loader, num_batches)
|
| 379 |
+
for batch_idx, batch in enumerate(
|
| 380 |
+
metric_logger.log_every(eval_batches, print_freq, header, total_steps=num_batches)
|
| 381 |
+
):
|
| 382 |
+
if use_cuda and not args.presend_cuda:
|
| 383 |
+
batch = ut.send_data(batch, device, dtype_map={torch.float16: amp_dtype})
|
| 384 |
+
|
| 385 |
+
batch_step = batch_idx + 1
|
| 386 |
+
|
| 387 |
+
images, img_mask = mri_data.densify_sparse_image_batch(
|
| 388 |
+
batch["image_values"],
|
| 389 |
+
batch["img_mask"],
|
| 390 |
+
(int(args.get("in_chans", 1)), *args.img_size),
|
| 391 |
+
dtype=amp_dtype,
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=args.amp):
|
| 395 |
+
loss, state = model(
|
| 396 |
+
images,
|
| 397 |
+
img_mask=img_mask,
|
| 398 |
+
mask_ratio=args.mask_ratio,
|
| 399 |
+
pred_mask_ratio=args.pred_mask_ratio,
|
| 400 |
+
pad_to_multiple=args.pad_to_multiple,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
loss_value = loss.detach().float().item()
|
| 404 |
+
finite = torch.tensor(int(math.isfinite(loss_value)), dtype=torch.int32, device=device)
|
| 405 |
+
if args.distributed:
|
| 406 |
+
torch.distributed.all_reduce(finite, op=torch.distributed.ReduceOp.MIN)
|
| 407 |
+
if not finite.item():
|
| 408 |
+
raise RuntimeError("non-finite validation loss detected")
|
| 409 |
+
metric_logger.meters["loss"].update(loss_value, n=int(batch["img_mask"].shape[0]))
|
| 410 |
+
|
| 411 |
+
if is_master and batch_step == example_step:
|
| 412 |
+
example_batch = {"image": images[:1], "img_mask": img_mask[:1]}
|
| 413 |
+
if "meta" in batch:
|
| 414 |
+
example_batch["meta"] = batch["meta"][:1]
|
| 415 |
+
example_state = {
|
| 416 |
+
"pred_images": state["pred_images"][:1],
|
| 417 |
+
"pred_mask": state["pred_mask"][:1],
|
| 418 |
+
}
|
| 419 |
+
example_data = {
|
| 420 |
+
"batch": ut.send_data(example_batch, "cpu"),
|
| 421 |
+
"state": ut.send_data(example_state, "cpu"),
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
# gather the stats from all processes
|
| 425 |
+
metric_logger.synchronize_between_processes()
|
| 426 |
+
print(f"Averaged stats ({eval_name}):", metric_logger)
|
| 427 |
+
stats = {f"eval/{eval_name}/{k}": meter.global_avg for k, meter in metric_logger.meters.items()}
|
| 428 |
+
|
| 429 |
+
plots = {}
|
| 430 |
+
if is_master:
|
| 431 |
+
print(f"Making plots ({eval_name}): example={example_step}")
|
| 432 |
+
plots = make_plots(args, **example_data)
|
| 433 |
+
plots = {f"eval/{eval_name}/{k}": img for k, img in plots.items()}
|
| 434 |
+
|
| 435 |
+
if log_wandb:
|
| 436 |
+
wandb.log(stats, step=1000 * (epoch + 1))
|
| 437 |
+
wandb.log(
|
| 438 |
+
{k: wandb.Image(img, caption=f"example={example_step}") for k, img in plots.items()},
|
| 439 |
+
step=1000 * (epoch + 1),
|
| 440 |
+
)
|
| 441 |
+
ut.restore_rng_state(rng_state)
|
| 442 |
+
return stats, plots
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def make_plots(
|
| 446 |
+
args: DictConfig,
|
| 447 |
+
batch: dict[str, Tensor],
|
| 448 |
+
state: dict[str, Tensor],
|
| 449 |
+
) -> dict[str, Image.Image]:
|
| 450 |
+
fig_kwargs = args.get("fig_kwargs", {})
|
| 451 |
+
|
| 452 |
+
images = batch["image"]
|
| 453 |
+
img_mask = batch.get("img_mask")
|
| 454 |
+
if img_mask is not None:
|
| 455 |
+
img_mask = img_mask.expand_as(images)
|
| 456 |
+
|
| 457 |
+
raw_mean, raw_std = vis.raw_stats_from_batch(batch)
|
| 458 |
+
|
| 459 |
+
plots = {}
|
| 460 |
+
mask_pred_fig = vis.plot_mask_pred(
|
| 461 |
+
target=images,
|
| 462 |
+
pred=state["pred_images"],
|
| 463 |
+
pred_mask=state["pred_mask"],
|
| 464 |
+
img_mask=img_mask,
|
| 465 |
+
patch_size=args.patch_size,
|
| 466 |
+
raw_mean=raw_mean,
|
| 467 |
+
raw_std=raw_std,
|
| 468 |
+
**ut.filter_kwargs(vis.plot_mask_pred, fig_kwargs),
|
| 469 |
+
)
|
| 470 |
+
plots["mask_pred"] = vis.fig2pil(mask_pred_fig)
|
| 471 |
+
plt.close(mask_pred_fig)
|
| 472 |
+
|
| 473 |
+
return plots
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
if __name__ == "__main__":
|
| 477 |
+
parser = argparse.ArgumentParser()
|
| 478 |
+
parser.add_argument("--cfg-path", type=str, default=None)
|
| 479 |
+
parser.add_argument("--overrides", type=str, default=None, nargs="+")
|
| 480 |
+
args = parser.parse_args()
|
| 481 |
+
cfg = OmegaConf.load(DEFAULT_CONFIG)
|
| 482 |
+
if args.cfg_path:
|
| 483 |
+
cfg = OmegaConf.unsafe_merge(cfg, OmegaConf.load(args.cfg_path))
|
| 484 |
+
if args.overrides:
|
| 485 |
+
cfg = OmegaConf.unsafe_merge(cfg, OmegaConf.from_dotlist(args.overrides))
|
| 486 |
+
main(cfg)
|
fomo_tune_baseline/output/task1/build/smri_mae/masking.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from jaxtyping import Float, Int
|
| 3 |
+
from torch import Tensor
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def pad_patch_mask(
|
| 7 |
+
patch_mask: Float[Tensor, "B N"],
|
| 8 |
+
mask_ratio: float,
|
| 9 |
+
shuffle: bool = False,
|
| 10 |
+
generator: torch.Generator | None = None,
|
| 11 |
+
pad_to_multiple: int | None = None,
|
| 12 |
+
) -> tuple[Float[Tensor, "B N"], Int[Tensor, "B L"], Tensor]:
|
| 13 |
+
"""
|
| 14 |
+
Select each row's own mask-ratio count, then pad ids to the batch max length.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
- selected patch mask [B, N]
|
| 18 |
+
- padded selected patch ids [B, Lpad]
|
| 19 |
+
- token mask [B, Lpad], true for real ids and false for padding
|
| 20 |
+
"""
|
| 21 |
+
if not 0.0 <= mask_ratio <= 1.0:
|
| 22 |
+
raise ValueError(f"mask_ratio must be in [0, 1], got {mask_ratio}")
|
| 23 |
+
|
| 24 |
+
B, N = patch_mask.shape
|
| 25 |
+
device = patch_mask.device
|
| 26 |
+
patch_mask = patch_mask.to(dtype=torch.bool)
|
| 27 |
+
|
| 28 |
+
valid_counts = patch_mask.sum(dim=1)
|
| 29 |
+
num_keep = torch.floor(valid_counts.to(torch.float64) * (1.0 - mask_ratio)).to(torch.long)
|
| 30 |
+
if not shuffle:
|
| 31 |
+
selected = patch_mask & (patch_mask.cumsum(dim=1) <= num_keep.unsqueeze(1))
|
| 32 |
+
padded_ids, token_mask = patch_ids_from_mask(
|
| 33 |
+
selected,
|
| 34 |
+
pad_to_multiple=pad_to_multiple,
|
| 35 |
+
)
|
| 36 |
+
return selected, padded_ids, token_mask
|
| 37 |
+
|
| 38 |
+
# One masked sort directly produces random valid IDs. The previous
|
| 39 |
+
# shuffle/select/inverse-shuffle path required two full argsorts plus a
|
| 40 |
+
# dynamic nonzero/scatter solely to recover the same selected set.
|
| 41 |
+
noise = torch.rand(B, N, generator=generator, device=device)
|
| 42 |
+
noise.masked_fill_(~patch_mask, torch.inf)
|
| 43 |
+
shuffled_ids = torch.argsort(noise, dim=1)
|
| 44 |
+
|
| 45 |
+
max_count = int(num_keep.max().item())
|
| 46 |
+
if pad_to_multiple is not None:
|
| 47 |
+
if pad_to_multiple <= 0:
|
| 48 |
+
raise ValueError(f"pad_to_multiple must be positive, got {pad_to_multiple}")
|
| 49 |
+
max_count = (max_count + pad_to_multiple - 1) // pad_to_multiple * pad_to_multiple
|
| 50 |
+
padded_ids = shuffled_ids[:, :max_count]
|
| 51 |
+
token_mask = torch.arange(max_count, device=device).unsqueeze(0) < num_keep.unsqueeze(1)
|
| 52 |
+
selected = torch.zeros_like(patch_mask).scatter_(1, padded_ids, token_mask)
|
| 53 |
+
return selected, padded_ids, token_mask
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def patch_ids_from_mask(
|
| 57 |
+
patch_mask: Tensor,
|
| 58 |
+
pad_to_multiple: int | None = None,
|
| 59 |
+
) -> tuple[Int[Tensor, "B L"], Tensor]:
|
| 60 |
+
"""Return optionally rounded patch IDs and their token-validity mask."""
|
| 61 |
+
if pad_to_multiple is not None and pad_to_multiple <= 0:
|
| 62 |
+
raise ValueError(f"pad_to_multiple must be positive, got {pad_to_multiple}")
|
| 63 |
+
|
| 64 |
+
patch_mask = patch_mask.to(dtype=torch.bool)
|
| 65 |
+
B, N = patch_mask.shape
|
| 66 |
+
device = patch_mask.device
|
| 67 |
+
counts = patch_mask.sum(dim=1)
|
| 68 |
+
max_count = int(counts.max().item())
|
| 69 |
+
if pad_to_multiple is not None:
|
| 70 |
+
max_count = (max_count + pad_to_multiple - 1) // pad_to_multiple * pad_to_multiple
|
| 71 |
+
|
| 72 |
+
patch_ids = torch.zeros((B, max_count), dtype=torch.long, device=device)
|
| 73 |
+
token_mask = torch.arange(max_count, device=device).unsqueeze(0) < counts.unsqueeze(1)
|
| 74 |
+
if max_count == 0:
|
| 75 |
+
return patch_ids, token_mask
|
| 76 |
+
|
| 77 |
+
batch_ids, selected_ids = patch_mask.nonzero(as_tuple=True)
|
| 78 |
+
slot_ids = patch_mask.cumsum(dim=1)[batch_ids, selected_ids].to(torch.long) - 1
|
| 79 |
+
patch_ids[batch_ids, slot_ids] = selected_ids
|
| 80 |
+
return patch_ids, token_mask
|
fomo_tune_baseline/output/task1/build/smri_mae/model_mae.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/model.py
|
| 6 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/v1.0.20/timm/models/vision_transformer.py
|
| 7 |
+
|
| 8 |
+
"""
|
| 9 |
+
From-scratch re-implementation of the original MAE model.
|
| 10 |
+
|
| 11 |
+
MaskedEncoder: standard ViT with masking
|
| 12 |
+
MaskedDecoder: standard self-attention MAE decoder
|
| 13 |
+
MaskedAutoEncoderViT: full MAE model for 3D structural MRI volumes
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from collections.abc import Sequence
|
| 17 |
+
from typing import Literal
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
from torch import Tensor
|
| 23 |
+
from torch.utils.checkpoint import checkpoint
|
| 24 |
+
from huggingface_hub import PyTorchModelHubMixin
|
| 25 |
+
from jaxtyping import Float, Int
|
| 26 |
+
|
| 27 |
+
from .modules import (
|
| 28 |
+
AbsolutePosEmbed,
|
| 29 |
+
Block,
|
| 30 |
+
LayerNorm,
|
| 31 |
+
Normalize,
|
| 32 |
+
JaggedBatch,
|
| 33 |
+
Patchify3D,
|
| 34 |
+
SeparablePosEmbed,
|
| 35 |
+
SinCosPosEmbed3D,
|
| 36 |
+
unpack_tokens,
|
| 37 |
+
)
|
| 38 |
+
from .masking import pad_patch_mask
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class MaskedEncoder(nn.Module):
|
| 42 |
+
"""
|
| 43 |
+
Masked transformer encoder.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
patchify: nn.Module,
|
| 49 |
+
patch_embed: nn.Module,
|
| 50 |
+
pos_embed: nn.Module,
|
| 51 |
+
depth: int = 12,
|
| 52 |
+
embed_dim: int = 768,
|
| 53 |
+
num_heads: int = 12,
|
| 54 |
+
qkv_bias: bool = True,
|
| 55 |
+
proj_bias: bool = True,
|
| 56 |
+
mlp_ratio: int | float = 4,
|
| 57 |
+
class_token: bool = True,
|
| 58 |
+
reg_tokens: int = 0,
|
| 59 |
+
no_embed_class: bool = False,
|
| 60 |
+
final_norm: bool = True,
|
| 61 |
+
drop_path_rate: float = 0.0,
|
| 62 |
+
mask_drop_scale: bool = False,
|
| 63 |
+
):
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.num_prefix_tokens = int(class_token) + reg_tokens
|
| 66 |
+
self.num_reg_tokens = reg_tokens
|
| 67 |
+
self.has_class_token = class_token
|
| 68 |
+
self.no_embed_class = no_embed_class
|
| 69 |
+
|
| 70 |
+
# scale inputs by 1 / observed rate (like dropout)
|
| 71 |
+
self.mask_drop_scale = mask_drop_scale
|
| 72 |
+
|
| 73 |
+
# inject tokenization modules, so that the encoder doesn't specifically need to
|
| 74 |
+
# know how the data are tokenized, while still implementing a complete
|
| 75 |
+
# self-contained model.
|
| 76 |
+
self.patchify = patchify
|
| 77 |
+
self.patch_embed = patch_embed
|
| 78 |
+
self.pos_embed = pos_embed
|
| 79 |
+
|
| 80 |
+
R = reg_tokens
|
| 81 |
+
self.cls_token = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 82 |
+
self.reg_token = nn.Parameter(torch.empty(1, R, embed_dim)) if reg_tokens else None
|
| 83 |
+
|
| 84 |
+
if not no_embed_class:
|
| 85 |
+
self.cls_token_pos = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 86 |
+
self.reg_token_pos = nn.Parameter(torch.empty(1, R, embed_dim)) if reg_tokens else None
|
| 87 |
+
else:
|
| 88 |
+
self.cls_token_pos = self.reg_token_pos = None
|
| 89 |
+
|
| 90 |
+
# stochastic depth decay rule
|
| 91 |
+
dpr = [x.item() for x in torch.linspace(0, drop_path_rate, depth)]
|
| 92 |
+
|
| 93 |
+
self.blocks = nn.ModuleList(
|
| 94 |
+
[
|
| 95 |
+
Block(
|
| 96 |
+
dim=embed_dim,
|
| 97 |
+
num_heads=num_heads,
|
| 98 |
+
qkv_bias=qkv_bias,
|
| 99 |
+
proj_bias=proj_bias,
|
| 100 |
+
mlp_ratio=mlp_ratio,
|
| 101 |
+
drop_path=dpr[ii],
|
| 102 |
+
)
|
| 103 |
+
for ii in range(depth)
|
| 104 |
+
]
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
self.norm = LayerNorm(embed_dim) if final_norm else nn.Identity()
|
| 108 |
+
|
| 109 |
+
self.reset_parameters()
|
| 110 |
+
|
| 111 |
+
def extra_repr(self):
|
| 112 |
+
return (
|
| 113 |
+
f"class_token={self.has_class_token}, reg_tokens={self.num_reg_tokens}, "
|
| 114 |
+
f"no_embed_class={self.no_embed_class}, mask_drop_scale={self.mask_drop_scale}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def reset_parameters(self) -> None:
|
| 118 |
+
for p in [self.cls_token, self.cls_token_pos, self.reg_token, self.reg_token_pos]:
|
| 119 |
+
if p is not None:
|
| 120 |
+
nn.init.trunc_normal_(p, std=0.02)
|
| 121 |
+
|
| 122 |
+
def cat_tokens(self, x: Tensor) -> Tensor:
|
| 123 |
+
# prepend cls and reg tokens with optional learned position embedding
|
| 124 |
+
# the cls and reg pos embedding is ofc redundant, but included in many other
|
| 125 |
+
# implementations.
|
| 126 |
+
B, _, _ = x.shape
|
| 127 |
+
|
| 128 |
+
to_cat = []
|
| 129 |
+
if self.has_class_token:
|
| 130 |
+
cls_token = self.cls_token
|
| 131 |
+
if not self.no_embed_class:
|
| 132 |
+
cls_token = cls_token + self.cls_token_pos
|
| 133 |
+
to_cat.append(cls_token.expand(B, -1, -1))
|
| 134 |
+
|
| 135 |
+
if self.num_reg_tokens:
|
| 136 |
+
reg_token = self.reg_token
|
| 137 |
+
if not self.no_embed_class:
|
| 138 |
+
reg_token = reg_token + self.reg_token_pos
|
| 139 |
+
to_cat.append(reg_token.expand(B, -1, -1))
|
| 140 |
+
|
| 141 |
+
if to_cat:
|
| 142 |
+
x = torch.cat(to_cat + [x], dim=1)
|
| 143 |
+
return x
|
| 144 |
+
|
| 145 |
+
def cat_token_mask(self, token_mask: Tensor, batch_size: int) -> Tensor:
|
| 146 |
+
if self.num_prefix_tokens:
|
| 147 |
+
prefix_mask = torch.ones(
|
| 148 |
+
(batch_size, self.num_prefix_tokens),
|
| 149 |
+
dtype=torch.bool,
|
| 150 |
+
device=token_mask.device,
|
| 151 |
+
)
|
| 152 |
+
token_mask = torch.cat([prefix_mask, token_mask], dim=1)
|
| 153 |
+
return token_mask
|
| 154 |
+
|
| 155 |
+
def chunk_tokens(self, x: Tensor) -> tuple[Tensor | None, Tensor | None, Tensor]:
|
| 156 |
+
cls_offset = int(self.has_class_token)
|
| 157 |
+
cls = x[:, :cls_offset] if self.has_class_token else None
|
| 158 |
+
if self.num_reg_tokens:
|
| 159 |
+
reg = x[:, cls_offset : self.num_prefix_tokens, :]
|
| 160 |
+
else:
|
| 161 |
+
reg = None
|
| 162 |
+
patch = x[:, self.num_prefix_tokens :, :]
|
| 163 |
+
return cls, reg, patch
|
| 164 |
+
|
| 165 |
+
def forward(
|
| 166 |
+
self,
|
| 167 |
+
x: Tensor,
|
| 168 |
+
mask: Tensor | None = None,
|
| 169 |
+
mask_ratio: float | None = None,
|
| 170 |
+
pad_to_multiple: int | None = None,
|
| 171 |
+
) -> tuple[
|
| 172 |
+
Float[Tensor, "B 1 D"] | None,
|
| 173 |
+
Float[Tensor, "B R D"] | None,
|
| 174 |
+
Float[Tensor, "B L D"],
|
| 175 |
+
Tensor | None,
|
| 176 |
+
Int[Tensor, "B L"] | None,
|
| 177 |
+
Tensor | None,
|
| 178 |
+
]:
|
| 179 |
+
"""
|
| 180 |
+
x: input data shape [B, C, D, H, W]
|
| 181 |
+
mask: visible mask, 1 = visible, 0 = invisible. broadcastable shape
|
| 182 |
+
mask_ratio: mask ratio for uniform random masking
|
| 183 |
+
|
| 184 |
+
returns:
|
| 185 |
+
- cls_embeds: [B, 1, D]
|
| 186 |
+
- reg_embeds: [B, R, D]
|
| 187 |
+
- patch_embeds: [B, L, D], where L is the number of visible patches
|
| 188 |
+
- mask: observed mask, 1 = observed, 0 = unobserved. same shape as input
|
| 189 |
+
- mask_ids: indices of visible patches [B L]
|
| 190 |
+
- token_mask: valid token mask for padded per-sample masking [B L]
|
| 191 |
+
"""
|
| 192 |
+
# apply mask to the input
|
| 193 |
+
if mask is not None:
|
| 194 |
+
mask = mask.to(device=x.device, dtype=torch.bool).expand_as(x)
|
| 195 |
+
x = x.masked_fill(~mask, 0)
|
| 196 |
+
|
| 197 |
+
# patchify input
|
| 198 |
+
x = self.patchify(x)
|
| 199 |
+
B, N, P = x.shape
|
| 200 |
+
|
| 201 |
+
# patchify mask and apply dropout style scaling
|
| 202 |
+
if mask is not None:
|
| 203 |
+
mask_patches = self.patchify(mask)
|
| 204 |
+
patch_num_obs = mask_patches.sum(dim=-1)
|
| 205 |
+
patch_mask = patch_num_obs > 0
|
| 206 |
+
if self.mask_drop_scale:
|
| 207 |
+
patch_num_obs = patch_num_obs.to(x.dtype)
|
| 208 |
+
x = x * (P / patch_num_obs.unsqueeze(-1).clamp(min=1.0))
|
| 209 |
+
elif mask_ratio is not None:
|
| 210 |
+
patch_mask = torch.ones((B, N), dtype=torch.bool, device=x.device)
|
| 211 |
+
mask_patches = patch_mask.unsqueeze(-1).expand(-1, -1, P)
|
| 212 |
+
else:
|
| 213 |
+
patch_mask = mask_patches = None
|
| 214 |
+
|
| 215 |
+
# patch and position embed
|
| 216 |
+
x = self.patch_embed(x)
|
| 217 |
+
x = self.pos_embed(x)
|
| 218 |
+
|
| 219 |
+
if mask is not None or mask_ratio is not None:
|
| 220 |
+
mask_ratio = 0.0 if mask_ratio is None else mask_ratio
|
| 221 |
+
patch_mask, mask_ids, token_mask = pad_patch_mask(
|
| 222 |
+
patch_mask,
|
| 223 |
+
mask_ratio=mask_ratio,
|
| 224 |
+
shuffle=mask_ratio > 0,
|
| 225 |
+
pad_to_multiple=pad_to_multiple,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
mask_patches = mask_patches & patch_mask.unsqueeze(-1)
|
| 229 |
+
mask = self.patchify.unpatchify(mask_patches)
|
| 230 |
+
x = x.gather(1, mask_ids.unsqueeze(-1).expand(-1, -1, x.shape[-1]))
|
| 231 |
+
else:
|
| 232 |
+
mask_ids = None
|
| 233 |
+
token_mask = None
|
| 234 |
+
|
| 235 |
+
cls_embeds, reg_embeds, patch_embeds = self.forward_patch_embeds(
|
| 236 |
+
x,
|
| 237 |
+
token_mask=token_mask,
|
| 238 |
+
)
|
| 239 |
+
return cls_embeds, reg_embeds, patch_embeds, mask, mask_ids, token_mask
|
| 240 |
+
|
| 241 |
+
def forward_patch_embeds(
|
| 242 |
+
self,
|
| 243 |
+
x: Float[Tensor, "B L D"],
|
| 244 |
+
token_mask: Tensor | None = None,
|
| 245 |
+
) -> tuple[
|
| 246 |
+
Float[Tensor, "B 1 D"] | None,
|
| 247 |
+
Float[Tensor, "B R D"] | None,
|
| 248 |
+
Float[Tensor, "B L D"],
|
| 249 |
+
]:
|
| 250 |
+
B = x.shape[0]
|
| 251 |
+
if token_mask is None:
|
| 252 |
+
token_mask = torch.ones(x.shape[:2], dtype=torch.bool, device=x.device)
|
| 253 |
+
x = self.cat_tokens(x)
|
| 254 |
+
token_mask = self.cat_token_mask(token_mask, B)
|
| 255 |
+
jagged_batch = JaggedBatch.from_mask(token_mask)
|
| 256 |
+
x = x[token_mask]
|
| 257 |
+
for block in self.blocks:
|
| 258 |
+
x = block(x, jagged_batch=jagged_batch)
|
| 259 |
+
x = self.norm(x)
|
| 260 |
+
x = unpack_tokens(x, token_mask)
|
| 261 |
+
|
| 262 |
+
cls_embeds, reg_embeds, patch_embeds = self.chunk_tokens(x)
|
| 263 |
+
return cls_embeds, reg_embeds, patch_embeds
|
| 264 |
+
|
| 265 |
+
def forward_visible_ids(
|
| 266 |
+
self,
|
| 267 |
+
x: Tensor,
|
| 268 |
+
visible_ids: Int[Tensor, "B L"],
|
| 269 |
+
img_mask: Tensor | None = None,
|
| 270 |
+
) -> tuple[
|
| 271 |
+
Float[Tensor, "B 1 D"] | None,
|
| 272 |
+
Float[Tensor, "B R D"] | None,
|
| 273 |
+
Float[Tensor, "B L D"],
|
| 274 |
+
]:
|
| 275 |
+
if img_mask is not None:
|
| 276 |
+
img_mask = img_mask.to(device=x.device, dtype=torch.bool).expand_as(x)
|
| 277 |
+
x = x.masked_fill(~img_mask, 0)
|
| 278 |
+
|
| 279 |
+
x = self.patchify(x)
|
| 280 |
+
if self.mask_drop_scale and img_mask is not None:
|
| 281 |
+
mask_patches = self.patchify(img_mask)
|
| 282 |
+
patch_num_obs = mask_patches.sum(dim=-1).to(x.dtype)
|
| 283 |
+
x = x * (self.patchify.patch_dim / patch_num_obs.unsqueeze(-1).clamp(min=1.0))
|
| 284 |
+
x = self.patch_embed(x)
|
| 285 |
+
x = self.pos_embed(x)
|
| 286 |
+
visible_ids = visible_ids.to(device=x.device)
|
| 287 |
+
x = x.gather(1, visible_ids.unsqueeze(-1).expand(-1, -1, x.shape[-1]))
|
| 288 |
+
return self.forward_patch_embeds(x)
|
| 289 |
+
|
| 290 |
+
def forward_embedding(
|
| 291 |
+
self,
|
| 292 |
+
x: Tensor,
|
| 293 |
+
mask: Tensor | None = None,
|
| 294 |
+
mask_ratio: float | None = None,
|
| 295 |
+
):
|
| 296 |
+
cls_embeds, reg_embeds, patch_embeds, *_ = self.forward(
|
| 297 |
+
x,
|
| 298 |
+
mask=mask,
|
| 299 |
+
mask_ratio=mask_ratio,
|
| 300 |
+
)
|
| 301 |
+
return cls_embeds, reg_embeds, patch_embeds
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class MaskedDecoder(nn.Module):
|
| 305 |
+
"""Self-attention MAE decoder supporting sparse subset decoding via pred_ids."""
|
| 306 |
+
|
| 307 |
+
def __init__(
|
| 308 |
+
self,
|
| 309 |
+
pos_embed: nn.Module,
|
| 310 |
+
head: nn.Module | None = None,
|
| 311 |
+
input_dim: int | None = None,
|
| 312 |
+
depth: int = 12,
|
| 313 |
+
embed_dim: int = 768,
|
| 314 |
+
num_heads: int = 12,
|
| 315 |
+
qkv_bias: bool = True,
|
| 316 |
+
proj_bias: bool = True,
|
| 317 |
+
mlp_ratio: int | float = 4,
|
| 318 |
+
class_token: bool = True,
|
| 319 |
+
no_embed_class: bool = False,
|
| 320 |
+
final_norm: bool = True,
|
| 321 |
+
):
|
| 322 |
+
super().__init__()
|
| 323 |
+
input_dim = embed_dim if input_dim is None else input_dim
|
| 324 |
+
self.has_class_token = class_token
|
| 325 |
+
self.no_embed_class = no_embed_class
|
| 326 |
+
|
| 327 |
+
self.cls_token = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 328 |
+
self.cls_token_pos = (
|
| 329 |
+
nn.Parameter(torch.empty(1, 1, embed_dim))
|
| 330 |
+
if class_token and not no_embed_class
|
| 331 |
+
else None
|
| 332 |
+
)
|
| 333 |
+
self.mask_token = nn.Parameter(torch.empty(1, 1, embed_dim))
|
| 334 |
+
|
| 335 |
+
# decoder position embedding, encodes query position information into masks
|
| 336 |
+
self.pos_embed = pos_embed
|
| 337 |
+
|
| 338 |
+
self.proj = nn.Identity() if input_dim == embed_dim else nn.Linear(input_dim, embed_dim)
|
| 339 |
+
|
| 340 |
+
self.blocks = nn.ModuleList(
|
| 341 |
+
[
|
| 342 |
+
Block(
|
| 343 |
+
dim=embed_dim,
|
| 344 |
+
num_heads=num_heads,
|
| 345 |
+
qkv_bias=qkv_bias,
|
| 346 |
+
proj_bias=proj_bias,
|
| 347 |
+
mlp_ratio=mlp_ratio,
|
| 348 |
+
)
|
| 349 |
+
for _ in range(depth)
|
| 350 |
+
]
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
self.norm = LayerNorm(embed_dim) if final_norm else nn.Identity()
|
| 354 |
+
|
| 355 |
+
# optional injected prediction head
|
| 356 |
+
self.head = nn.Identity() if head is None else head
|
| 357 |
+
|
| 358 |
+
self.reset_parameters()
|
| 359 |
+
|
| 360 |
+
def extra_repr(self):
|
| 361 |
+
return f"class_token={self.has_class_token}, no_embed_class={self.no_embed_class}"
|
| 362 |
+
|
| 363 |
+
def reset_parameters(self) -> None:
|
| 364 |
+
# official mae initializes decoder cls token to zeros
|
| 365 |
+
# although perhaps this was an oversight
|
| 366 |
+
if self.cls_token is not None:
|
| 367 |
+
nn.init.zeros_(self.cls_token)
|
| 368 |
+
if self.cls_token_pos is not None:
|
| 369 |
+
nn.init.trunc_normal_(self.cls_token_pos, std=0.02)
|
| 370 |
+
nn.init.trunc_normal_(self.mask_token, std=0.02)
|
| 371 |
+
|
| 372 |
+
def cat_tokens(self, x: Tensor) -> Tensor:
|
| 373 |
+
if not self.has_class_token:
|
| 374 |
+
return x
|
| 375 |
+
cls_token = self.cls_token
|
| 376 |
+
if not self.no_embed_class:
|
| 377 |
+
cls_token = cls_token + self.cls_token_pos
|
| 378 |
+
return torch.cat([cls_token.expand(x.shape[0], -1, -1), x], dim=1)
|
| 379 |
+
|
| 380 |
+
def cat_token_mask(self, token_mask: Tensor, batch_size: int) -> Tensor:
|
| 381 |
+
if self.has_class_token:
|
| 382 |
+
cls_mask = torch.ones(
|
| 383 |
+
(batch_size, 1),
|
| 384 |
+
dtype=torch.bool,
|
| 385 |
+
device=token_mask.device,
|
| 386 |
+
)
|
| 387 |
+
token_mask = torch.cat([cls_mask, token_mask], dim=1)
|
| 388 |
+
return token_mask
|
| 389 |
+
|
| 390 |
+
def chunk_tokens(self, x: Tensor) -> tuple[Tensor | None, Tensor]:
|
| 391 |
+
cls_offset = int(self.has_class_token)
|
| 392 |
+
cls = x[:, :cls_offset] if self.has_class_token else None
|
| 393 |
+
patch = x[:, cls_offset:, :]
|
| 394 |
+
return cls, patch
|
| 395 |
+
|
| 396 |
+
def forward(
|
| 397 |
+
self,
|
| 398 |
+
embeds: Float[Tensor, "B L D"],
|
| 399 |
+
embed_ids: Int[Tensor, "B L"] | None = None,
|
| 400 |
+
pred_ids: Int[Tensor, "B Q"] | None = None,
|
| 401 |
+
embed_token_mask: Tensor | None = None,
|
| 402 |
+
pred_token_mask: Tensor | None = None,
|
| 403 |
+
packed_output: bool = False,
|
| 404 |
+
) -> Float[Tensor, "B Q P"] | Float[Tensor, "T P"]:
|
| 405 |
+
"""
|
| 406 |
+
embeds: input patch embeddings.
|
| 407 |
+
embed_ids: optional patch indices for input embeddings. If not provided, no
|
| 408 |
+
position will be added to the embeddings.
|
| 409 |
+
pred_ids: patch indices of query mask positions. If None, decode *all* patches.
|
| 410 |
+
|
| 411 |
+
returns:
|
| 412 |
+
- pred [B, Q, P] where Q is the number of prediction patches and P is the output
|
| 413 |
+
dimension
|
| 414 |
+
"""
|
| 415 |
+
B, L, _ = embeds.shape
|
| 416 |
+
|
| 417 |
+
Q = self.pos_embed.num_patches if pred_ids is None else pred_ids.shape[1]
|
| 418 |
+
mask = self.mask_token.expand(B, Q, -1)
|
| 419 |
+
mask = self.pos_embed(mask, pos_ids=pred_ids)
|
| 420 |
+
|
| 421 |
+
embeds = self.proj(embeds)
|
| 422 |
+
|
| 423 |
+
if embed_ids is not None:
|
| 424 |
+
embeds = self.pos_embed(embeds, pos_ids=embed_ids)
|
| 425 |
+
if embed_token_mask is None:
|
| 426 |
+
embed_token_mask = torch.ones((B, L), dtype=torch.bool, device=embeds.device)
|
| 427 |
+
if pred_token_mask is None:
|
| 428 |
+
pred_token_mask = torch.ones((B, Q), dtype=torch.bool, device=embeds.device)
|
| 429 |
+
x = torch.cat([embeds, mask], dim=1)
|
| 430 |
+
token_mask = torch.cat([embed_token_mask, pred_token_mask], dim=1)
|
| 431 |
+
|
| 432 |
+
x = self.cat_tokens(x)
|
| 433 |
+
token_mask = self.cat_token_mask(token_mask, B)
|
| 434 |
+
jagged_batch = JaggedBatch.from_mask(token_mask)
|
| 435 |
+
x = x[token_mask]
|
| 436 |
+
# Keep headroom for rare maximum-length PSP batches.
|
| 437 |
+
checkpoint_start = max(0, len(self.blocks) - 2)
|
| 438 |
+
for block_index, block in enumerate(self.blocks):
|
| 439 |
+
if self.training and torch.is_grad_enabled() and block_index >= checkpoint_start:
|
| 440 |
+
x = checkpoint(block, x, jagged_batch, use_reentrant=False)
|
| 441 |
+
else:
|
| 442 |
+
x = block(x, jagged_batch=jagged_batch)
|
| 443 |
+
|
| 444 |
+
x = self.norm(x)
|
| 445 |
+
if packed_output:
|
| 446 |
+
pred_offset = int(self.has_class_token) + L
|
| 447 |
+
prediction_mask = F.pad(pred_token_mask, (pred_offset, 0))
|
| 448 |
+
return self.head(x[prediction_mask[token_mask]])
|
| 449 |
+
|
| 450 |
+
x = unpack_tokens(x, token_mask)
|
| 451 |
+
_, x = self.chunk_tokens(x)
|
| 452 |
+
|
| 453 |
+
pred = x[:, L:]
|
| 454 |
+
pred = pred.masked_fill(~pred_token_mask.unsqueeze(-1), 0)
|
| 455 |
+
pred = self.head(pred)
|
| 456 |
+
return pred
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
class MaskedAutoencoderViT(nn.Module, PyTorchModelHubMixin):
|
| 460 |
+
def __init__(
|
| 461 |
+
self,
|
| 462 |
+
img_size: int | tuple[int, int, int] = (208, 240, 208),
|
| 463 |
+
patch_size: int | tuple[int, int, int] = (16, 16, 16),
|
| 464 |
+
in_chans: int = 1,
|
| 465 |
+
depth: int = 12,
|
| 466 |
+
embed_dim: int = 768,
|
| 467 |
+
num_heads: int = 12,
|
| 468 |
+
decoder_depth: int = 4,
|
| 469 |
+
decoder_embed_dim: int | None = 512,
|
| 470 |
+
decoder_num_heads: int | None = 16, # default from mae, head dim = 32
|
| 471 |
+
qkv_bias: bool = True,
|
| 472 |
+
proj_bias: bool = True,
|
| 473 |
+
mlp_ratio: int | float = 4,
|
| 474 |
+
class_token: bool = True,
|
| 475 |
+
reg_tokens: int = 0,
|
| 476 |
+
no_embed_class: bool = False,
|
| 477 |
+
drop_path_rate: float = 0.0,
|
| 478 |
+
mask_drop_scale: bool = False,
|
| 479 |
+
no_decode_pos: bool = False,
|
| 480 |
+
pos_embed: Literal["abs", "sep", "sincos"] = "sincos",
|
| 481 |
+
target_norm: Literal["none", "global", "slice", "patch"] | None = None,
|
| 482 |
+
):
|
| 483 |
+
super().__init__()
|
| 484 |
+
img_size = _to_3d_tuple(img_size, "img_size")
|
| 485 |
+
patch_size = _to_3d_tuple(patch_size, "patch_size")
|
| 486 |
+
|
| 487 |
+
self.no_decode_pos = no_decode_pos # don't pos encode embeddings in decoder
|
| 488 |
+
|
| 489 |
+
# patchify reshapes input into sequence of flattened patches, shape [B, N, P]
|
| 490 |
+
ndim = 3
|
| 491 |
+
patchify = Patchify3D(img_size, patch_size, in_chans=in_chans)
|
| 492 |
+
|
| 493 |
+
# linear patch embedding P -> D
|
| 494 |
+
patch_embed = nn.Linear(patchify.patch_dim, embed_dim)
|
| 495 |
+
|
| 496 |
+
# position embedding
|
| 497 |
+
# separable position embedding decouples the first spatial axis from the
|
| 498 |
+
# others. Fixed sin/cos embeddings are the default for sMRI volumes.
|
| 499 |
+
if pos_embed == "sincos":
|
| 500 |
+
pos_embed_layer = SinCosPosEmbed3D
|
| 501 |
+
else:
|
| 502 |
+
pos_embed_layer = {"abs": AbsolutePosEmbed, "sep": SeparablePosEmbed}[pos_embed]
|
| 503 |
+
pos_embed = pos_embed_layer(embed_dim, patchify.grid_size)
|
| 504 |
+
|
| 505 |
+
# encoder. for inference, this model can be extracted and used like a regular vit
|
| 506 |
+
self.encoder = MaskedEncoder(
|
| 507 |
+
patchify=patchify,
|
| 508 |
+
patch_embed=patch_embed,
|
| 509 |
+
pos_embed=pos_embed,
|
| 510 |
+
depth=depth,
|
| 511 |
+
embed_dim=embed_dim,
|
| 512 |
+
num_heads=num_heads,
|
| 513 |
+
qkv_bias=qkv_bias,
|
| 514 |
+
proj_bias=proj_bias,
|
| 515 |
+
mlp_ratio=mlp_ratio,
|
| 516 |
+
class_token=class_token,
|
| 517 |
+
reg_tokens=reg_tokens,
|
| 518 |
+
no_embed_class=no_embed_class,
|
| 519 |
+
drop_path_rate=drop_path_rate,
|
| 520 |
+
mask_drop_scale=mask_drop_scale,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
self.pred_patchify = patchify
|
| 524 |
+
|
| 525 |
+
# fall back to encoder architecture width
|
| 526 |
+
decoder_embed_dim = decoder_embed_dim or embed_dim
|
| 527 |
+
decoder_num_heads = decoder_num_heads or num_heads
|
| 528 |
+
|
| 529 |
+
decoder_pos_embed = pos_embed_layer(decoder_embed_dim, self.pred_patchify.grid_size)
|
| 530 |
+
# we might want to try tying the weights of the prediction head to the patch
|
| 531 |
+
# embedding at some point.
|
| 532 |
+
decoder_head = nn.Linear(decoder_embed_dim, self.pred_patchify.patch_dim)
|
| 533 |
+
|
| 534 |
+
self.decoder = MaskedDecoder(
|
| 535 |
+
pos_embed=decoder_pos_embed,
|
| 536 |
+
head=decoder_head,
|
| 537 |
+
input_dim=embed_dim,
|
| 538 |
+
depth=decoder_depth,
|
| 539 |
+
embed_dim=decoder_embed_dim,
|
| 540 |
+
num_heads=decoder_num_heads,
|
| 541 |
+
qkv_bias=qkv_bias,
|
| 542 |
+
proj_bias=proj_bias,
|
| 543 |
+
mlp_ratio=mlp_ratio,
|
| 544 |
+
class_token=class_token,
|
| 545 |
+
no_embed_class=no_embed_class,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
# mae style target normalization
|
| 549 |
+
# dim is relative to an unflattened embedding tensor of shape [B, *grid_size, D]
|
| 550 |
+
if target_norm not in {"none", None}:
|
| 551 |
+
norm_dim = {
|
| 552 |
+
"global": tuple(range(1, ndim + 2)), # full sequence
|
| 553 |
+
"slice": tuple(range(2, ndim + 2)), # each depth slice along first dim
|
| 554 |
+
"patch": -1, # normalize each patch independently (mae pix norm loss)
|
| 555 |
+
}[target_norm]
|
| 556 |
+
self.target_norm = Normalize(self.pred_patchify.grid_size, dim=norm_dim)
|
| 557 |
+
else:
|
| 558 |
+
self.target_norm = None
|
| 559 |
+
|
| 560 |
+
self.init_weights()
|
| 561 |
+
|
| 562 |
+
def extra_repr(self):
|
| 563 |
+
return f"no_decode_pos={self.no_decode_pos}"
|
| 564 |
+
|
| 565 |
+
def init_weights(self):
|
| 566 |
+
self.apply(_init_weights)
|
| 567 |
+
|
| 568 |
+
def prepare_targets(self, images: Tensor, img_mask: Tensor | None):
|
| 569 |
+
"""
|
| 570 |
+
images: [B, C, D, H, W]
|
| 571 |
+
img_mask: mask of valid data. only used for computing correct normalization
|
| 572 |
+
stats. same shape as images.
|
| 573 |
+
"""
|
| 574 |
+
targets_patches = self.pred_patchify(images) # [B, N, P]
|
| 575 |
+
|
| 576 |
+
# target normalization
|
| 577 |
+
if self.target_norm is not None:
|
| 578 |
+
# full image data mask used for normalization stats only
|
| 579 |
+
if img_mask is not None:
|
| 580 |
+
img_mask_patches = self.pred_patchify(img_mask)
|
| 581 |
+
else:
|
| 582 |
+
img_mask_patches = None
|
| 583 |
+
targets_patches, *targets_stats = self.target_norm(targets_patches, img_mask_patches)
|
| 584 |
+
else:
|
| 585 |
+
targets_stats = None
|
| 586 |
+
|
| 587 |
+
return targets_patches, targets_stats
|
| 588 |
+
|
| 589 |
+
def prepare_masks(
|
| 590 |
+
self,
|
| 591 |
+
img_mask: Tensor,
|
| 592 |
+
visible_mask: Tensor | None,
|
| 593 |
+
pred_mask: Tensor | None,
|
| 594 |
+
device: torch.device,
|
| 595 |
+
):
|
| 596 |
+
img_mask = img_mask.to(device=device, dtype=torch.bool)
|
| 597 |
+
|
| 598 |
+
if visible_mask is None:
|
| 599 |
+
visible_mask = img_mask
|
| 600 |
+
else:
|
| 601 |
+
visible_mask = img_mask & visible_mask.to(device=device, dtype=torch.bool)
|
| 602 |
+
|
| 603 |
+
if pred_mask is None:
|
| 604 |
+
pred_mask = img_mask
|
| 605 |
+
else:
|
| 606 |
+
pred_mask = img_mask & pred_mask.to(device=device, dtype=torch.bool)
|
| 607 |
+
|
| 608 |
+
return img_mask, visible_mask, pred_mask
|
| 609 |
+
|
| 610 |
+
def prepare_pred_mask(
|
| 611 |
+
self,
|
| 612 |
+
visible_mask: Tensor,
|
| 613 |
+
pred_mask: Tensor | None = None,
|
| 614 |
+
pred_mask_ratio: float | None = None,
|
| 615 |
+
pad_to_multiple: int | None = None,
|
| 616 |
+
):
|
| 617 |
+
"""
|
| 618 |
+
prepare prediction mask by removing visible content
|
| 619 |
+
visible_mask: [B, C, D, H, W], 1 = visible, 0 = invisible
|
| 620 |
+
pred_mask: same shape, 1 = predict, 0 = don't predict
|
| 621 |
+
"""
|
| 622 |
+
if pred_mask is None:
|
| 623 |
+
pred_mask = torch.ones_like(visible_mask)
|
| 624 |
+
|
| 625 |
+
pred_mask = pred_mask & ~visible_mask
|
| 626 |
+
|
| 627 |
+
pred_mask_patches = self.pred_patchify(pred_mask)
|
| 628 |
+
pred_patch_mask = pred_mask_patches.any(dim=-1)
|
| 629 |
+
# Optionally subsample the prediction candidates.
|
| 630 |
+
mask_ratio = 0.0 if pred_mask_ratio is None else pred_mask_ratio
|
| 631 |
+
pred_patch_mask, pred_ids, pred_token_mask = pad_patch_mask(
|
| 632 |
+
pred_patch_mask,
|
| 633 |
+
mask_ratio=mask_ratio,
|
| 634 |
+
# With per-sample padding every candidate is retained when the ratio
|
| 635 |
+
# is zero, so randomizing their order is pure overhead.
|
| 636 |
+
shuffle=mask_ratio > 0,
|
| 637 |
+
pad_to_multiple=pad_to_multiple,
|
| 638 |
+
)
|
| 639 |
+
pred_mask_patches = pred_mask_patches & pred_patch_mask.unsqueeze(-1)
|
| 640 |
+
return pred_mask_patches, pred_ids, pred_token_mask
|
| 641 |
+
|
| 642 |
+
def forward_decoder(
|
| 643 |
+
self,
|
| 644 |
+
patch_embeds: Float[Tensor, "B L D"],
|
| 645 |
+
visible_ids: Int[Tensor, "B L"],
|
| 646 |
+
pred_ids: Int[Tensor, "B Q"] | None,
|
| 647 |
+
visible_token_mask: Tensor | None = None,
|
| 648 |
+
pred_token_mask: Tensor | None = None,
|
| 649 |
+
packed_output: bool = False,
|
| 650 |
+
) -> Float[Tensor, "B Q P"] | Float[Tensor, "T P"]:
|
| 651 |
+
return self.decoder(
|
| 652 |
+
patch_embeds,
|
| 653 |
+
embed_ids=None if self.no_decode_pos else visible_ids,
|
| 654 |
+
pred_ids=pred_ids,
|
| 655 |
+
embed_token_mask=visible_token_mask,
|
| 656 |
+
pred_token_mask=pred_token_mask,
|
| 657 |
+
packed_output=packed_output,
|
| 658 |
+
)
|
| 659 |
+
|
| 660 |
+
def forward_loss(
|
| 661 |
+
self,
|
| 662 |
+
preds: Float[Tensor, "T P"],
|
| 663 |
+
targets_patches: Float[Tensor, "B N P"],
|
| 664 |
+
pred_mask_patches: Float[Tensor, "B N P"],
|
| 665 |
+
pred_ids: Int[Tensor, "B Q"],
|
| 666 |
+
pred_token_mask: Tensor,
|
| 667 |
+
) -> Tensor:
|
| 668 |
+
"""Average valid-voxel MSE within each scan, then average across scans."""
|
| 669 |
+
batch_ids, slot_ids = pred_token_mask.nonzero(as_tuple=True)
|
| 670 |
+
patch_ids = pred_ids[batch_ids, slot_ids]
|
| 671 |
+
targets = targets_patches[batch_ids, patch_ids]
|
| 672 |
+
voxel_mask = pred_mask_patches[batch_ids, patch_ids]
|
| 673 |
+
|
| 674 |
+
patch_errors = ((preds - targets) ** 2 * voxel_mask).sum(dim=1)
|
| 675 |
+
patch_voxels = voxel_mask.sum(dim=1).to(dtype=patch_errors.dtype)
|
| 676 |
+
batch_size = targets_patches.shape[0]
|
| 677 |
+
scan_errors = patch_errors.new_zeros(batch_size).scatter_add_(0, batch_ids, patch_errors)
|
| 678 |
+
scan_voxels = patch_voxels.new_zeros(batch_size).scatter_add_(0, batch_ids, patch_voxels)
|
| 679 |
+
return (scan_errors / scan_voxels).mean()
|
| 680 |
+
|
| 681 |
+
@torch.no_grad()
|
| 682 |
+
def forward_pred_images(
|
| 683 |
+
self,
|
| 684 |
+
preds: Float[Tensor, "B Q P"],
|
| 685 |
+
pred_ids: Int[Tensor, "B Q"],
|
| 686 |
+
pred_token_mask: Tensor | None = None,
|
| 687 |
+
img_mask: Tensor | None = None,
|
| 688 |
+
targets_stats: tuple[Tensor, Tensor] | None = None,
|
| 689 |
+
) -> Tensor:
|
| 690 |
+
B, _, P = preds.shape
|
| 691 |
+
N = self.pred_patchify.num_patches
|
| 692 |
+
if pred_token_mask is not None:
|
| 693 |
+
preds = preds.masked_fill(~pred_token_mask.unsqueeze(-1), 0)
|
| 694 |
+
|
| 695 |
+
preds = torch.zeros((B, N, P), dtype=preds.dtype, device=preds.device).scatter_add_(
|
| 696 |
+
1, pred_ids.unsqueeze(-1).expand(-1, -1, P), preds
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
if targets_stats is not None:
|
| 700 |
+
targets_mean, targets_std = targets_stats
|
| 701 |
+
preds = preds * targets_std + targets_mean
|
| 702 |
+
|
| 703 |
+
pred_images = self.pred_patchify.unpatchify(preds)
|
| 704 |
+
if img_mask is not None:
|
| 705 |
+
pred_images = pred_images.masked_fill(~img_mask, 0)
|
| 706 |
+
return pred_images
|
| 707 |
+
|
| 708 |
+
def forward(
|
| 709 |
+
self,
|
| 710 |
+
images: Tensor,
|
| 711 |
+
img_mask: Tensor,
|
| 712 |
+
mask_ratio: float,
|
| 713 |
+
pred_mask_ratio: float | None = None,
|
| 714 |
+
pad_to_multiple: int | None = None,
|
| 715 |
+
with_state: bool = True,
|
| 716 |
+
) -> Tensor | tuple[Tensor, dict]:
|
| 717 |
+
img_mask, visible_mask, pred_mask = self.prepare_masks(
|
| 718 |
+
img_mask,
|
| 719 |
+
None,
|
| 720 |
+
None,
|
| 721 |
+
device=images.device,
|
| 722 |
+
)
|
| 723 |
+
targets_patches, targets_stats = self.prepare_targets(images, img_mask)
|
| 724 |
+
|
| 725 |
+
(
|
| 726 |
+
cls_embeds,
|
| 727 |
+
reg_embeds,
|
| 728 |
+
patch_embeds,
|
| 729 |
+
visible_mask,
|
| 730 |
+
visible_ids,
|
| 731 |
+
visible_token_mask,
|
| 732 |
+
) = self.encoder(
|
| 733 |
+
images,
|
| 734 |
+
mask=visible_mask,
|
| 735 |
+
mask_ratio=mask_ratio,
|
| 736 |
+
pad_to_multiple=pad_to_multiple,
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
pred_mask_patches, pred_ids, pred_token_mask = self.prepare_pred_mask(
|
| 740 |
+
visible_mask,
|
| 741 |
+
pred_mask=pred_mask,
|
| 742 |
+
pred_mask_ratio=pred_mask_ratio,
|
| 743 |
+
pad_to_multiple=pad_to_multiple,
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
preds = self.forward_decoder(
|
| 747 |
+
patch_embeds,
|
| 748 |
+
visible_ids,
|
| 749 |
+
pred_ids,
|
| 750 |
+
visible_token_mask=visible_token_mask,
|
| 751 |
+
pred_token_mask=pred_token_mask,
|
| 752 |
+
packed_output=not with_state,
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
loss_preds = preds if not with_state else preds[pred_token_mask]
|
| 756 |
+
loss = self.forward_loss(
|
| 757 |
+
loss_preds,
|
| 758 |
+
targets_patches,
|
| 759 |
+
pred_mask_patches,
|
| 760 |
+
pred_ids,
|
| 761 |
+
pred_token_mask,
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
if not with_state:
|
| 765 |
+
return loss
|
| 766 |
+
|
| 767 |
+
pred_mask = self.pred_patchify.unpatchify(pred_mask_patches)
|
| 768 |
+
pred_images = self.forward_pred_images(
|
| 769 |
+
preds,
|
| 770 |
+
pred_ids,
|
| 771 |
+
pred_token_mask=pred_token_mask,
|
| 772 |
+
img_mask=img_mask,
|
| 773 |
+
targets_stats=targets_stats,
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
state = {
|
| 777 |
+
"targets_patches": targets_patches,
|
| 778 |
+
"targets_stats": targets_stats,
|
| 779 |
+
"patch_embeds": patch_embeds,
|
| 780 |
+
"cls_embeds": cls_embeds,
|
| 781 |
+
"reg_embeds": reg_embeds,
|
| 782 |
+
"visible_mask": visible_mask,
|
| 783 |
+
"visible_ids": visible_ids,
|
| 784 |
+
"visible_token_mask": visible_token_mask,
|
| 785 |
+
"pred_mask": pred_mask,
|
| 786 |
+
"pred_ids": pred_ids,
|
| 787 |
+
"pred_token_mask": pred_token_mask,
|
| 788 |
+
"preds": preds,
|
| 789 |
+
"pred_images": pred_images,
|
| 790 |
+
}
|
| 791 |
+
return loss, state
|
| 792 |
+
|
| 793 |
+
def forward_embedding(
|
| 794 |
+
self,
|
| 795 |
+
x: Tensor,
|
| 796 |
+
mask: Tensor | None = None,
|
| 797 |
+
mask_ratio: float | None = None,
|
| 798 |
+
):
|
| 799 |
+
return self.encoder.forward_embedding(x, mask=mask, mask_ratio=mask_ratio)
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
class MaskedViT(MaskedEncoder, PyTorchModelHubMixin):
|
| 803 |
+
def __init__(
|
| 804 |
+
self,
|
| 805 |
+
img_size: int | tuple[int, int, int] = (208, 240, 208),
|
| 806 |
+
in_chans: int = 1,
|
| 807 |
+
patch_size: int | tuple[int, int, int] = (16, 16, 16),
|
| 808 |
+
depth: int = 12,
|
| 809 |
+
embed_dim: int = 768,
|
| 810 |
+
num_heads: int = 12,
|
| 811 |
+
qkv_bias: bool = True,
|
| 812 |
+
proj_bias: bool = True,
|
| 813 |
+
mlp_ratio: int | float = 4,
|
| 814 |
+
class_token: bool = True,
|
| 815 |
+
reg_tokens: int = 0,
|
| 816 |
+
no_embed_class: bool = False,
|
| 817 |
+
final_norm: bool = True,
|
| 818 |
+
drop_path_rate: float = 0.0,
|
| 819 |
+
mask_drop_scale: bool = False,
|
| 820 |
+
pos_embed: Literal["abs", "sep", "sincos"] = "sincos",
|
| 821 |
+
):
|
| 822 |
+
img_size = _to_3d_tuple(img_size, "img_size")
|
| 823 |
+
patch_size = _to_3d_tuple(patch_size, "patch_size")
|
| 824 |
+
|
| 825 |
+
patchify = Patchify3D(img_size, patch_size, in_chans=in_chans)
|
| 826 |
+
patch_embed = nn.Linear(patchify.patch_dim, embed_dim)
|
| 827 |
+
if pos_embed == "sincos":
|
| 828 |
+
pos_embed_layer = SinCosPosEmbed3D
|
| 829 |
+
else:
|
| 830 |
+
pos_embed_layer = {"abs": AbsolutePosEmbed, "sep": SeparablePosEmbed}[pos_embed]
|
| 831 |
+
pos_embed = pos_embed_layer(embed_dim, patchify.grid_size)
|
| 832 |
+
|
| 833 |
+
super().__init__(
|
| 834 |
+
patchify=patchify,
|
| 835 |
+
patch_embed=patch_embed,
|
| 836 |
+
pos_embed=pos_embed,
|
| 837 |
+
depth=depth,
|
| 838 |
+
embed_dim=embed_dim,
|
| 839 |
+
num_heads=num_heads,
|
| 840 |
+
qkv_bias=qkv_bias,
|
| 841 |
+
proj_bias=proj_bias,
|
| 842 |
+
mlp_ratio=mlp_ratio,
|
| 843 |
+
class_token=class_token,
|
| 844 |
+
reg_tokens=reg_tokens,
|
| 845 |
+
no_embed_class=no_embed_class,
|
| 846 |
+
final_norm=final_norm,
|
| 847 |
+
drop_path_rate=drop_path_rate,
|
| 848 |
+
mask_drop_scale=mask_drop_scale,
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
self.init_weights()
|
| 852 |
+
|
| 853 |
+
def init_weights(self):
|
| 854 |
+
self.apply(_init_weights)
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _to_3d_tuple(value: int | Sequence[int], name: str) -> tuple[int, int, int]:
|
| 858 |
+
if isinstance(value, int):
|
| 859 |
+
return (value, value, value)
|
| 860 |
+
if len(value) != 3:
|
| 861 |
+
raise ValueError(f"{name} must have exactly 3 spatial dimensions, got {tuple(value)}")
|
| 862 |
+
return tuple(int(item) for item in value)
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
# JAX ViT xavier uniform init
|
| 866 |
+
# https://github.com/facebookresearch/capi/blob/main/model.py
|
| 867 |
+
def _init_weights(m: nn.Module) -> None:
|
| 868 |
+
if isinstance(m, nn.Linear):
|
| 869 |
+
nn.init.xavier_uniform_(m.weight)
|
| 870 |
+
if m.bias is not None:
|
| 871 |
+
nn.init.constant_(m.bias, 0)
|
| 872 |
+
elif isinstance(m, nn.LayerNorm) and m.elementwise_affine:
|
| 873 |
+
nn.init.constant_(m.weight, 1.0)
|
| 874 |
+
if m.bias is not None:
|
| 875 |
+
nn.init.constant_(m.bias, 0)
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _create_vit(**kwargs):
|
| 879 |
+
model = MaskedViT(**kwargs)
|
| 880 |
+
return model
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def _create_mae_vit(**kwargs):
|
| 884 |
+
model = MaskedAutoencoderViT(**kwargs)
|
| 885 |
+
return model
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def mae_vit_small(**kwargs):
|
| 889 |
+
model_args = dict(embed_dim=384, depth=12, num_heads=6)
|
| 890 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def mae_vit_base(**kwargs):
|
| 894 |
+
model_args = dict(embed_dim=768, depth=12, num_heads=12)
|
| 895 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
def mae_vit_large(**kwargs):
|
| 899 |
+
model_args = dict(embed_dim=1024, depth=24, num_heads=16)
|
| 900 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def mae_vit_huge(**kwargs):
|
| 904 |
+
model_args = dict(embed_dim=1280, depth=32, num_heads=16)
|
| 905 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
# "patch embed" baseline model, depth 0 ViT (hah)
|
| 909 |
+
def patch_embed_small(**kwargs):
|
| 910 |
+
model_args = dict(embed_dim=384, depth=0)
|
| 911 |
+
return _create_vit(**model_args, **kwargs)
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
def patch_embed_base(**kwargs):
|
| 915 |
+
model_args = dict(embed_dim=768, depth=0)
|
| 916 |
+
return _create_vit(**model_args, **kwargs)
|
fomo_tune_baseline/output/task1/build/smri_mae/modules.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 2 |
+
#
|
| 3 |
+
# References:
|
| 4 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/model.py
|
| 5 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/v1.0.20/timm/models/vision_transformer.py
|
| 6 |
+
# vjepa2: https://github.com/facebookresearch/vjepa2/blob/main/src/models/utils/pos_embs.py
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from functools import partial
|
| 10 |
+
from typing import NamedTuple, Type
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
from torch import Tensor
|
| 17 |
+
from einops import rearrange
|
| 18 |
+
from jaxtyping import Float, Int
|
| 19 |
+
from timm.layers import DropPath, to_3tuple
|
| 20 |
+
|
| 21 |
+
Layer = Type[nn.Module]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class JaggedBatch(NamedTuple):
|
| 25 |
+
"""Sequence boundaries and cached launch metadata for jagged attention."""
|
| 26 |
+
|
| 27 |
+
offsets: Tensor
|
| 28 |
+
max_seqlen: int
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
def from_mask(cls, mask: Tensor) -> "JaggedBatch":
|
| 32 |
+
mask = mask.to(dtype=torch.bool)
|
| 33 |
+
counts = mask.sum(dim=1)
|
| 34 |
+
return cls(
|
| 35 |
+
offsets=F.pad(counts.cumsum(dim=0), (1, 0)),
|
| 36 |
+
max_seqlen=mask.shape[1],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def as_nested(self, tokens: Tensor) -> Tensor:
|
| 40 |
+
# Cached conservative bounds avoid min/max reductions and GPU-to-CPU
|
| 41 |
+
# synchronization when Flash SDPA inspects the jagged sequence lengths.
|
| 42 |
+
return torch.nested.nested_tensor_from_jagged(
|
| 43 |
+
tokens,
|
| 44 |
+
self.offsets,
|
| 45 |
+
min_seqlen=1,
|
| 46 |
+
max_seqlen=self.max_seqlen,
|
| 47 |
+
).transpose(1, 2)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def unpack_tokens(tokens: Tensor, token_mask: Tensor) -> Tensor:
|
| 51 |
+
"""Restore packed values to a padded batch, filling invalid slots with zero."""
|
| 52 |
+
output = tokens.new_zeros((*token_mask.shape, *tokens.shape[1:]))
|
| 53 |
+
return output.index_put((token_mask,), tokens)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def jagged_scaled_dot_product_attention(
|
| 57 |
+
query: Tensor,
|
| 58 |
+
key: Tensor,
|
| 59 |
+
value: Tensor,
|
| 60 |
+
jagged_batch: JaggedBatch,
|
| 61 |
+
) -> Tensor:
|
| 62 |
+
"""Run SDPA on a packed batch of variable-length sequences."""
|
| 63 |
+
output_jagged = F.scaled_dot_product_attention(
|
| 64 |
+
jagged_batch.as_nested(query),
|
| 65 |
+
jagged_batch.as_nested(key),
|
| 66 |
+
jagged_batch.as_nested(value),
|
| 67 |
+
)
|
| 68 |
+
return output_jagged.transpose(1, 2).values()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Attention(nn.Module):
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
dim: int,
|
| 75 |
+
num_heads: int,
|
| 76 |
+
qkv_bias: bool = False,
|
| 77 |
+
proj_bias: bool = False,
|
| 78 |
+
) -> None:
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.num_heads = num_heads
|
| 81 |
+
self.head_dim = dim // num_heads
|
| 82 |
+
self.qkv = nn.Linear(dim, 3 * dim, bias=qkv_bias)
|
| 83 |
+
self.proj = nn.Linear(dim, dim, bias=proj_bias)
|
| 84 |
+
|
| 85 |
+
def extra_repr(self):
|
| 86 |
+
return f"num_heads={self.num_heads}"
|
| 87 |
+
|
| 88 |
+
def forward(
|
| 89 |
+
self,
|
| 90 |
+
x: Float[Tensor, "L D"],
|
| 91 |
+
jagged_batch: JaggedBatch,
|
| 92 |
+
) -> Float[Tensor, "L D"]:
|
| 93 |
+
L, D = x.shape
|
| 94 |
+
h, dh = self.num_heads, self.head_dim
|
| 95 |
+
|
| 96 |
+
qkv = self.qkv(x).reshape(L, 3, h, dh)
|
| 97 |
+
q, k, v = qkv.unbind(1)
|
| 98 |
+
|
| 99 |
+
x = jagged_scaled_dot_product_attention(
|
| 100 |
+
q,
|
| 101 |
+
k,
|
| 102 |
+
v,
|
| 103 |
+
jagged_batch=jagged_batch,
|
| 104 |
+
)
|
| 105 |
+
x = x.reshape(L, D)
|
| 106 |
+
x = self.proj(x)
|
| 107 |
+
return x
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Mlp(nn.Module):
|
| 111 |
+
def __init__(
|
| 112 |
+
self,
|
| 113 |
+
dim: int,
|
| 114 |
+
mlp_ratio: int | float = 4,
|
| 115 |
+
bias: bool = False,
|
| 116 |
+
) -> None:
|
| 117 |
+
super().__init__()
|
| 118 |
+
hidden_features = int(dim * mlp_ratio)
|
| 119 |
+
self.fc1 = nn.Linear(dim, hidden_features, bias=bias)
|
| 120 |
+
self.act = nn.GELU()
|
| 121 |
+
self.fc2 = nn.Linear(hidden_features, dim, bias=bias)
|
| 122 |
+
|
| 123 |
+
def forward(self, x: Float[Tensor, "... D"]) -> Float[Tensor, "... D"]:
|
| 124 |
+
x = self.fc1(x)
|
| 125 |
+
x = self.act(x)
|
| 126 |
+
x = self.fc2(x)
|
| 127 |
+
return x
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# timm default eps=1e-6
|
| 131 |
+
LayerNorm = partial(nn.LayerNorm, eps=1e-6)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class Block(nn.Module):
|
| 135 |
+
def __init__(
|
| 136 |
+
self,
|
| 137 |
+
dim: int,
|
| 138 |
+
num_heads: int,
|
| 139 |
+
qkv_bias: bool = False,
|
| 140 |
+
proj_bias: bool = False,
|
| 141 |
+
mlp_ratio: int | float = 4,
|
| 142 |
+
drop_path: float = 0.0,
|
| 143 |
+
norm_layer: Layer = LayerNorm,
|
| 144 |
+
) -> None:
|
| 145 |
+
super().__init__()
|
| 146 |
+
self.norm1 = norm_layer(dim)
|
| 147 |
+
self.attn = Attention(
|
| 148 |
+
dim=dim,
|
| 149 |
+
num_heads=num_heads,
|
| 150 |
+
qkv_bias=qkv_bias,
|
| 151 |
+
proj_bias=proj_bias,
|
| 152 |
+
)
|
| 153 |
+
self.drop_path1 = DropPath(drop_path) if drop_path > 0 else nn.Identity()
|
| 154 |
+
|
| 155 |
+
self.norm2 = norm_layer(dim)
|
| 156 |
+
self.mlp = Mlp(
|
| 157 |
+
dim=dim,
|
| 158 |
+
mlp_ratio=mlp_ratio,
|
| 159 |
+
bias=proj_bias,
|
| 160 |
+
)
|
| 161 |
+
self.drop_path2 = DropPath(drop_path) if drop_path > 0 else nn.Identity()
|
| 162 |
+
|
| 163 |
+
def forward(
|
| 164 |
+
self,
|
| 165 |
+
x: Float[Tensor, "L D"],
|
| 166 |
+
jagged_batch: JaggedBatch,
|
| 167 |
+
) -> Float[Tensor, "L D"]:
|
| 168 |
+
x = x + self.drop_path1(
|
| 169 |
+
self.attn(
|
| 170 |
+
self.norm1(x),
|
| 171 |
+
jagged_batch=jagged_batch,
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
x = x + self.drop_path2(self.mlp(self.norm2(x)))
|
| 175 |
+
return x
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# Patching and position embedding modules
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class Patchify3D(nn.Module):
|
| 182 |
+
def __init__(
|
| 183 |
+
self,
|
| 184 |
+
img_size: int | tuple[int, int, int],
|
| 185 |
+
patch_size: int | tuple[int, int, int],
|
| 186 |
+
in_chans: int = 3,
|
| 187 |
+
) -> None:
|
| 188 |
+
super().__init__()
|
| 189 |
+
self.img_size = to_3tuple(img_size)
|
| 190 |
+
self.patch_size = to_3tuple(patch_size)
|
| 191 |
+
self.in_chans = in_chans
|
| 192 |
+
|
| 193 |
+
T, H, W = self.img_size
|
| 194 |
+
p_t, p_h, p_w = self.patch_size
|
| 195 |
+
if T % p_t or H % p_h or W % p_w:
|
| 196 |
+
raise ValueError(
|
| 197 |
+
f"img_size {self.img_size} must be divisible by patch_size {self.patch_size}"
|
| 198 |
+
)
|
| 199 |
+
self.grid_size = (T // p_t, H // p_h, W // p_w)
|
| 200 |
+
self.num_patches = math.prod(self.grid_size)
|
| 201 |
+
self.patch_dim = in_chans * math.prod(self.patch_size)
|
| 202 |
+
|
| 203 |
+
def forward(self, x: Float[Tensor, "B C T H W"]) -> Float[Tensor, "B N P"]:
|
| 204 |
+
x = patchify3d(x, self.patch_size)
|
| 205 |
+
return x
|
| 206 |
+
|
| 207 |
+
def unpatchify(self, x: Float[Tensor, "B N P"]) -> Float[Tensor, "B C T H W"]:
|
| 208 |
+
x = unpatchify3d(x, patch_size=self.patch_size, img_size=self.img_size)
|
| 209 |
+
return x
|
| 210 |
+
|
| 211 |
+
def extra_repr(self):
|
| 212 |
+
return f"{self.img_size}, {self.patch_size}, in_chans={self.in_chans}"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def patchify3d(x: Tensor, patch_size: tuple[int, int, int]) -> Tensor:
|
| 216 |
+
p_t, p_h, p_w = to_3tuple(patch_size)
|
| 217 |
+
B, C, T, H, W = x.shape
|
| 218 |
+
x = rearrange(x, "b c (t u) (h p) (w q) -> b (t h w) (c u p q)", u=p_t, p=p_h, q=p_w)
|
| 219 |
+
return x
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def unpatchify3d(
|
| 223 |
+
x: Tensor,
|
| 224 |
+
patch_size: tuple[int, int, int],
|
| 225 |
+
img_size: tuple[int, int, int],
|
| 226 |
+
) -> Tensor:
|
| 227 |
+
B, N, P = x.shape
|
| 228 |
+
p_t, p_h, p_w = to_3tuple(patch_size)
|
| 229 |
+
T, H, W = to_3tuple(img_size)
|
| 230 |
+
x = rearrange(
|
| 231 |
+
x,
|
| 232 |
+
"b (t h w) (c u p q) -> b c (t u) (h p) (w q)",
|
| 233 |
+
t=T // p_t,
|
| 234 |
+
h=H // p_h,
|
| 235 |
+
w=W // p_w,
|
| 236 |
+
u=p_t,
|
| 237 |
+
p=p_h,
|
| 238 |
+
q=p_w,
|
| 239 |
+
)
|
| 240 |
+
return x
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class AbsolutePosEmbed(nn.Module):
|
| 244 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, ...]) -> None:
|
| 245 |
+
super().__init__()
|
| 246 |
+
self.embed_dim = embed_dim
|
| 247 |
+
self.grid_size = grid_size
|
| 248 |
+
self.num_patches = math.prod(grid_size)
|
| 249 |
+
|
| 250 |
+
self.weight = nn.Parameter(torch.empty(self.num_patches, embed_dim))
|
| 251 |
+
self.reset_parameters()
|
| 252 |
+
|
| 253 |
+
def reset_parameters(self):
|
| 254 |
+
nn.init.trunc_normal_(self.weight, std=0.02)
|
| 255 |
+
|
| 256 |
+
def forward(
|
| 257 |
+
self,
|
| 258 |
+
x: Float[Tensor, "B L D"],
|
| 259 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 260 |
+
) -> Float[Tensor, "B L D"]:
|
| 261 |
+
x = apply_pos_embed(x, self.weight, pos_ids=pos_ids)
|
| 262 |
+
return x
|
| 263 |
+
|
| 264 |
+
def extra_repr(self):
|
| 265 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class SeparablePosEmbed(nn.Module):
|
| 269 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, ...]) -> None:
|
| 270 |
+
super().__init__()
|
| 271 |
+
self.embed_dim = embed_dim
|
| 272 |
+
self.grid_size = grid_size
|
| 273 |
+
self.num_patches = math.prod(grid_size)
|
| 274 |
+
|
| 275 |
+
N_t, *grid_size_spatial = grid_size
|
| 276 |
+
N_s = math.prod(grid_size_spatial)
|
| 277 |
+
self.weight_spatial = nn.Parameter(torch.empty(1, N_s, embed_dim))
|
| 278 |
+
self.weight_temporal = nn.Parameter(torch.empty(N_t, 1, embed_dim))
|
| 279 |
+
self.reset_parameters()
|
| 280 |
+
|
| 281 |
+
def reset_parameters(self):
|
| 282 |
+
nn.init.trunc_normal_(self.weight_spatial, std=0.02)
|
| 283 |
+
nn.init.trunc_normal_(self.weight_temporal, std=0.02)
|
| 284 |
+
|
| 285 |
+
def forward(
|
| 286 |
+
self,
|
| 287 |
+
x: Float[Tensor, "B L D"],
|
| 288 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 289 |
+
) -> Float[Tensor, "B L D"]:
|
| 290 |
+
B, N, D = x.shape
|
| 291 |
+
weight = (self.weight_temporal + self.weight_spatial).flatten(0, 1) # [N, D]
|
| 292 |
+
x = apply_pos_embed(x, weight, pos_ids=pos_ids)
|
| 293 |
+
return x
|
| 294 |
+
|
| 295 |
+
def extra_repr(self):
|
| 296 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class SinCosPosEmbed3D(nn.Module):
|
| 300 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, int, int]) -> None:
|
| 301 |
+
super().__init__()
|
| 302 |
+
self.embed_dim = embed_dim
|
| 303 |
+
self.grid_size = grid_size
|
| 304 |
+
self.num_patches = math.prod(grid_size)
|
| 305 |
+
|
| 306 |
+
N_t, N_h, N_w = grid_size
|
| 307 |
+
weight = get_3d_sincos_pos_embed(
|
| 308 |
+
embed_dim=embed_dim,
|
| 309 |
+
grid_size=(N_h, N_w),
|
| 310 |
+
grid_depth=N_t,
|
| 311 |
+
uniform_power=True,
|
| 312 |
+
)
|
| 313 |
+
self.weight = nn.Parameter(torch.from_numpy(weight).float(), requires_grad=False)
|
| 314 |
+
|
| 315 |
+
def forward(
|
| 316 |
+
self,
|
| 317 |
+
x: Float[Tensor, "B L D"],
|
| 318 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 319 |
+
) -> Float[Tensor, "B L D"]:
|
| 320 |
+
x = apply_pos_embed(x, self.weight, pos_ids=pos_ids)
|
| 321 |
+
return x
|
| 322 |
+
|
| 323 |
+
def extra_repr(self):
|
| 324 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# sincos pos embed utils from vjepa2, but fixed the confusing meshgrid indexing
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def get_3d_sincos_pos_embed(embed_dim, grid_size, grid_depth, cls_token=False, uniform_power=False):
|
| 331 |
+
"""
|
| 332 |
+
grid_size: tuple of int of the grid height and width
|
| 333 |
+
grid_depth: int of the grid depth
|
| 334 |
+
returns:
|
| 335 |
+
pos_embed: [grid_depth*grid_height*grid_width, embed_dim] (w/o cls_token)
|
| 336 |
+
or [1+grid_depth*grid_height*grid_width, embed_dim] (w/ cls_token)
|
| 337 |
+
"""
|
| 338 |
+
grid_d = np.arange(grid_depth, dtype=float)
|
| 339 |
+
grid_h = np.arange(grid_size[0], dtype=float)
|
| 340 |
+
grid_w = np.arange(grid_size[1], dtype=float)
|
| 341 |
+
grid_d, grid_h, grid_w = np.meshgrid(grid_d, grid_h, grid_w, indexing="ij")
|
| 342 |
+
|
| 343 |
+
if not uniform_power:
|
| 344 |
+
h_embed_dim = embed_dim // 4
|
| 345 |
+
w_embed_dim = embed_dim // 4
|
| 346 |
+
d_embed_dim = embed_dim // 2
|
| 347 |
+
else:
|
| 348 |
+
h_embed_dim = w_embed_dim = d_embed_dim = int(np.ceil(embed_dim / 6) * 2)
|
| 349 |
+
|
| 350 |
+
emb_h = get_1d_sincos_pos_embed_from_grid(h_embed_dim, grid_h) # (T*H*W, D1)
|
| 351 |
+
emb_w = get_1d_sincos_pos_embed_from_grid(w_embed_dim, grid_w) # (T*H*W, D2)
|
| 352 |
+
emb_d = get_1d_sincos_pos_embed_from_grid(d_embed_dim, grid_d) # (T*H*W, D3)
|
| 353 |
+
pos_embed = np.concatenate([emb_d, emb_h, emb_w], axis=1)
|
| 354 |
+
pos_embed = pos_embed[:, :embed_dim]
|
| 355 |
+
if cls_token:
|
| 356 |
+
pos_embed = np.concatenate([np.zeros([1, embed_dim]), pos_embed], axis=0)
|
| 357 |
+
return pos_embed
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def get_1d_sincos_pos_embed_from_grid(embed_dim, pos):
|
| 361 |
+
"""
|
| 362 |
+
embed_dim: output dimension for each position
|
| 363 |
+
pos: a list of positions to be encoded: size (M,)
|
| 364 |
+
returns: (M, D)
|
| 365 |
+
"""
|
| 366 |
+
assert embed_dim % 2 == 0
|
| 367 |
+
omega = np.arange(embed_dim // 2, dtype=float)
|
| 368 |
+
omega /= embed_dim / 2.0
|
| 369 |
+
omega = 1.0 / 10000**omega # (D/2,)
|
| 370 |
+
|
| 371 |
+
pos = pos.reshape(-1) # (M,)
|
| 372 |
+
out = np.einsum("m,d->md", pos, omega) # (M, D/2), outer product
|
| 373 |
+
|
| 374 |
+
emb_sin = np.sin(out) # (M, D/2)
|
| 375 |
+
emb_cos = np.cos(out) # (M, D/2)
|
| 376 |
+
|
| 377 |
+
emb = np.concatenate([emb_sin, emb_cos], axis=1) # (M, D)
|
| 378 |
+
return emb
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def apply_pos_embed(
|
| 382 |
+
x: Float[Tensor, "B L D"],
|
| 383 |
+
weight: Float[Tensor, "N D"],
|
| 384 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 385 |
+
) -> Float[Tensor, "B L D"]:
|
| 386 |
+
B, L, D = x.shape
|
| 387 |
+
weight = weight.expand(B, -1, -1)
|
| 388 |
+
if pos_ids is not None:
|
| 389 |
+
weight = weight.gather(1, pos_ids.unsqueeze(-1).expand(-1, -1, D))
|
| 390 |
+
x = x + weight
|
| 391 |
+
return x
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# (masked) normalization used for MAE target normalization
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class Normalize(nn.Module):
|
| 398 |
+
def __init__(
|
| 399 |
+
self,
|
| 400 |
+
grid_size: tuple[int, ...],
|
| 401 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 402 |
+
eps: float = 1e-6,
|
| 403 |
+
) -> None:
|
| 404 |
+
super().__init__()
|
| 405 |
+
self.grid_size = grid_size
|
| 406 |
+
self.dim = dim
|
| 407 |
+
self.eps = eps
|
| 408 |
+
|
| 409 |
+
def forward(self, x: Tensor, mask: Tensor | None = None) -> tuple[Tensor, Tensor, Tensor]:
|
| 410 |
+
"""
|
| 411 |
+
Normalize input sequence along dim(s) after reshaping to grid.
|
| 412 |
+
Returns tuple of (x, mean, std).
|
| 413 |
+
"""
|
| 414 |
+
B, N, D = x.shape
|
| 415 |
+
x = x.reshape((B, *self.grid_size, D))
|
| 416 |
+
if mask is not None:
|
| 417 |
+
mask = mask.reshape((B, *self.grid_size, D))
|
| 418 |
+
x, mean, std = masked_normalize(x, mask, dim=self.dim, eps=self.eps)
|
| 419 |
+
else:
|
| 420 |
+
x, mean, std = normalize(x, dim=self.dim, eps=self.eps)
|
| 421 |
+
mean = mean.expand_as(x).reshape(B, N, D)
|
| 422 |
+
std = std.expand_as(x).reshape(B, N, D)
|
| 423 |
+
x = x.reshape(B, N, D)
|
| 424 |
+
return x, mean, std
|
| 425 |
+
|
| 426 |
+
def extra_repr(self):
|
| 427 |
+
return f"{self.grid_size}, dim={self.dim}"
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def masked_normalize(
|
| 431 |
+
x: Tensor,
|
| 432 |
+
mask: Tensor,
|
| 433 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 434 |
+
eps: float = 1e-6,
|
| 435 |
+
) -> tuple[Tensor, Tensor, Tensor]:
|
| 436 |
+
num_obs = mask.sum(dim=dim, keepdim=True).clamp(min=1)
|
| 437 |
+
mean = (mask * x).sum(dim=dim, keepdim=True) / num_obs
|
| 438 |
+
var = (mask * (x - mean) ** 2).sum(dim=dim, keepdim=True) / num_obs
|
| 439 |
+
std = (var + eps) ** 0.5
|
| 440 |
+
x = mask * (x - mean) / std
|
| 441 |
+
return x, mean, std
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def normalize(
|
| 445 |
+
x: Tensor,
|
| 446 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 447 |
+
eps: float = 1e-6,
|
| 448 |
+
) -> tuple[Tensor, Tensor, Tensor]:
|
| 449 |
+
mean = x.mean(dim=dim, keepdim=True)
|
| 450 |
+
var = torch.var(x, dim=dim, keepdim=True, unbiased=False)
|
| 451 |
+
std = (var + eps) ** 0.5
|
| 452 |
+
x = (x - mean) / std
|
| 453 |
+
return x, mean, std
|
fomo_tune_baseline/output/task1/build/smri_mae/utils.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# deit: https://github.com/facebookresearch/deit/blob/main/utils.py
|
| 6 |
+
# beit3: https://github.com/microsoft/unilm/blob/master/beit3/utils.py
|
| 7 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/utils.py
|
| 8 |
+
# dinov2: https://github.com/facebookresearch/dinov2/blob/main/dinov2/utils/param_groups.py
|
| 9 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/main/timm/utils/cuda.py
|
| 10 |
+
# dino: https://github.com/facebookresearch/dino/blob/main/utils.py
|
| 11 |
+
|
| 12 |
+
import datetime
|
| 13 |
+
import inspect
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import random
|
| 17 |
+
import subprocess
|
| 18 |
+
import time
|
| 19 |
+
from collections import defaultdict, deque
|
| 20 |
+
from omegaconf import OmegaConf
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
import torch.distributed as dist
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
from torch import Tensor
|
| 28 |
+
from torch.amp import GradScaler
|
| 29 |
+
from torch.optim import Optimizer
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# these very useful utils copied from deit with only minor changes
|
| 33 |
+
# thanks to the original authors, wherever you are
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def configure_flash_sdpa() -> None:
|
| 37 |
+
"""Use Flash Attention exclusively for CUDA SDPA."""
|
| 38 |
+
torch.backends.cuda.enable_flash_sdp(True)
|
| 39 |
+
torch.backends.cuda.enable_mem_efficient_sdp(False)
|
| 40 |
+
torch.backends.cuda.enable_math_sdp(False)
|
| 41 |
+
torch.backends.cuda.enable_cudnn_sdp(False)
|
| 42 |
+
print("SDPA backend: flash")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class SmoothedValue:
|
| 46 |
+
"""Track a series of values and provide access to smoothed values over a
|
| 47 |
+
window or the global series average.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, window_size=20, fmt=None):
|
| 51 |
+
if fmt is None:
|
| 52 |
+
fmt = "{median:.4f} ({global_avg:.4f})"
|
| 53 |
+
self.deque = deque(maxlen=window_size)
|
| 54 |
+
self.total = 0.0
|
| 55 |
+
self.count = 0
|
| 56 |
+
self.fmt = fmt
|
| 57 |
+
|
| 58 |
+
def update(self, value, n=1):
|
| 59 |
+
value = float(value)
|
| 60 |
+
if math.isfinite(value):
|
| 61 |
+
self.deque.append(value)
|
| 62 |
+
self.count += n
|
| 63 |
+
self.total += value * n
|
| 64 |
+
|
| 65 |
+
def synchronize_between_processes(self):
|
| 66 |
+
"""
|
| 67 |
+
Warning: does not synchronize the deque!
|
| 68 |
+
"""
|
| 69 |
+
if not is_dist_avail_and_initialized():
|
| 70 |
+
return
|
| 71 |
+
t = torch.tensor([self.count, self.total], dtype=torch.float64, device="cuda")
|
| 72 |
+
dist.barrier()
|
| 73 |
+
dist.all_reduce(t)
|
| 74 |
+
t = t.tolist()
|
| 75 |
+
self.count = int(t[0])
|
| 76 |
+
self.total = t[1]
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def median(self):
|
| 80 |
+
if not self.count:
|
| 81 |
+
return float("nan")
|
| 82 |
+
d = torch.tensor(list(self.deque))
|
| 83 |
+
return d.median().item()
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def avg(self):
|
| 87 |
+
if not self.count:
|
| 88 |
+
return float("nan")
|
| 89 |
+
d = torch.tensor(list(self.deque), dtype=torch.float32)
|
| 90 |
+
return d.mean().item()
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def global_avg(self):
|
| 94 |
+
if not self.count:
|
| 95 |
+
return float("nan")
|
| 96 |
+
return self.total / self.count
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def max(self):
|
| 100 |
+
if not self.count:
|
| 101 |
+
return float("nan")
|
| 102 |
+
return max(self.deque)
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def value(self):
|
| 106 |
+
if not self.count:
|
| 107 |
+
return float("nan")
|
| 108 |
+
return self.deque[-1]
|
| 109 |
+
|
| 110 |
+
def __str__(self):
|
| 111 |
+
return self.fmt.format(
|
| 112 |
+
median=self.median,
|
| 113 |
+
avg=self.avg,
|
| 114 |
+
global_avg=self.global_avg,
|
| 115 |
+
max=self.max,
|
| 116 |
+
value=self.value,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class MetricLogger:
|
| 121 |
+
def __init__(self, delimiter="\t"):
|
| 122 |
+
self.meters = defaultdict(SmoothedValue)
|
| 123 |
+
self.delimiter = delimiter
|
| 124 |
+
|
| 125 |
+
def update(self, **kwargs):
|
| 126 |
+
for k, v in kwargs.items():
|
| 127 |
+
if v is None:
|
| 128 |
+
continue
|
| 129 |
+
if isinstance(v, (torch.Tensor, np.generic)):
|
| 130 |
+
v = v.item()
|
| 131 |
+
assert isinstance(v, (float, int))
|
| 132 |
+
self.meters[k].update(v)
|
| 133 |
+
|
| 134 |
+
def __getattr__(self, attr):
|
| 135 |
+
if attr in self.meters:
|
| 136 |
+
return self.meters[attr]
|
| 137 |
+
if attr in self.__dict__:
|
| 138 |
+
return self.__dict__[attr]
|
| 139 |
+
raise AttributeError("'{}' object has no attribute '{}'".format(type(self).__name__, attr))
|
| 140 |
+
|
| 141 |
+
def __str__(self):
|
| 142 |
+
loss_str = []
|
| 143 |
+
for name, meter in self.meters.items():
|
| 144 |
+
loss_str.append("{}: {}".format(name, str(meter)))
|
| 145 |
+
return self.delimiter.join(loss_str)
|
| 146 |
+
|
| 147 |
+
def synchronize_between_processes(self):
|
| 148 |
+
for meter in self.meters.values():
|
| 149 |
+
meter.synchronize_between_processes()
|
| 150 |
+
|
| 151 |
+
def add_meter(self, name, meter):
|
| 152 |
+
self.meters[name] = meter
|
| 153 |
+
|
| 154 |
+
def log_every(self, iterable, print_freq, header=None, total_steps=None):
|
| 155 |
+
i = 0
|
| 156 |
+
total_steps = total_steps or len(iterable)
|
| 157 |
+
if not header:
|
| 158 |
+
header = ""
|
| 159 |
+
start_time = time.time()
|
| 160 |
+
end = time.time()
|
| 161 |
+
iter_time = SmoothedValue(fmt="{avg:.4f}")
|
| 162 |
+
data_time = SmoothedValue(fmt="{avg:.4f}")
|
| 163 |
+
space_fmt = ":" + str(len(str(total_steps))) + "d"
|
| 164 |
+
log_msg = [
|
| 165 |
+
header,
|
| 166 |
+
"[{0" + space_fmt + "}/{1}]",
|
| 167 |
+
"eta: {eta}",
|
| 168 |
+
"{meters}",
|
| 169 |
+
"time: {time}",
|
| 170 |
+
"data: {data}",
|
| 171 |
+
]
|
| 172 |
+
if torch.cuda.is_available():
|
| 173 |
+
log_msg.append("max mem: {memory:.0f}")
|
| 174 |
+
log_msg = self.delimiter.join(log_msg)
|
| 175 |
+
MB = 1024.0 * 1024.0
|
| 176 |
+
for obj in iterable:
|
| 177 |
+
if i >= total_steps:
|
| 178 |
+
break
|
| 179 |
+
data_time.update(time.time() - end)
|
| 180 |
+
yield obj
|
| 181 |
+
iter_time.update(time.time() - end)
|
| 182 |
+
if i % print_freq == 0 or i == total_steps - 1:
|
| 183 |
+
eta_seconds = iter_time.global_avg * (total_steps - i)
|
| 184 |
+
eta_string = str(datetime.timedelta(seconds=int(eta_seconds)))
|
| 185 |
+
if torch.cuda.is_available():
|
| 186 |
+
print(
|
| 187 |
+
log_msg.format(
|
| 188 |
+
i,
|
| 189 |
+
total_steps,
|
| 190 |
+
eta=eta_string,
|
| 191 |
+
meters=str(self),
|
| 192 |
+
time=str(iter_time),
|
| 193 |
+
data=str(data_time),
|
| 194 |
+
memory=torch.cuda.max_memory_allocated() / MB,
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
else:
|
| 199 |
+
print(
|
| 200 |
+
log_msg.format(
|
| 201 |
+
i,
|
| 202 |
+
total_steps,
|
| 203 |
+
eta=eta_string,
|
| 204 |
+
meters=str(self),
|
| 205 |
+
time=str(iter_time),
|
| 206 |
+
data=str(data_time),
|
| 207 |
+
)
|
| 208 |
+
)
|
| 209 |
+
i += 1
|
| 210 |
+
end = time.time()
|
| 211 |
+
total_time = time.time() - start_time
|
| 212 |
+
total_time_str = str(datetime.timedelta(seconds=int(total_time)))
|
| 213 |
+
print(
|
| 214 |
+
"{} Total time: {} ({:.4f} s / it)".format(
|
| 215 |
+
header, total_time_str, total_time / total_steps
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def setup_for_distributed(log_path=None):
|
| 221 |
+
"""
|
| 222 |
+
This function disables printing when not in master process
|
| 223 |
+
"""
|
| 224 |
+
import builtins as __builtin__
|
| 225 |
+
|
| 226 |
+
builtin_print = __builtin__.print
|
| 227 |
+
|
| 228 |
+
is_master = is_main_process()
|
| 229 |
+
|
| 230 |
+
def print(*args, **kwargs):
|
| 231 |
+
force = kwargs.pop("force", False)
|
| 232 |
+
if is_master or force:
|
| 233 |
+
builtin_print(*args, **kwargs)
|
| 234 |
+
# tee to log file
|
| 235 |
+
if log_path and "file" not in kwargs:
|
| 236 |
+
with open(log_path, "a") as f:
|
| 237 |
+
builtin_print(*args, file=f, **kwargs)
|
| 238 |
+
|
| 239 |
+
__builtin__.print = print
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def is_dist_avail_and_initialized():
|
| 243 |
+
if not dist.is_available():
|
| 244 |
+
return False
|
| 245 |
+
if not dist.is_initialized():
|
| 246 |
+
return False
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def get_world_size():
|
| 251 |
+
if not is_dist_avail_and_initialized():
|
| 252 |
+
return 1
|
| 253 |
+
return dist.get_world_size()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_rank():
|
| 257 |
+
if not is_dist_avail_and_initialized():
|
| 258 |
+
return 0
|
| 259 |
+
return dist.get_rank()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def is_main_process():
|
| 263 |
+
return get_rank() == 0
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def save_on_master(obj, path, *args, **kwargs):
|
| 267 |
+
if is_main_process():
|
| 268 |
+
path = Path(path)
|
| 269 |
+
tmp_path = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
| 270 |
+
try:
|
| 271 |
+
torch.save(obj, tmp_path, *args, **kwargs)
|
| 272 |
+
os.replace(tmp_path, path)
|
| 273 |
+
except Exception:
|
| 274 |
+
tmp_path.unlink(missing_ok=True)
|
| 275 |
+
raise
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def init_distributed_mode(args):
|
| 279 |
+
# removed slurm block, can add if we use slurm
|
| 280 |
+
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
| 281 |
+
args.rank = int(os.environ["RANK"])
|
| 282 |
+
args.world_size = int(os.environ["WORLD_SIZE"])
|
| 283 |
+
args.gpu = int(os.environ["LOCAL_RANK"])
|
| 284 |
+
else:
|
| 285 |
+
args.distributed = False
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
args.distributed = True
|
| 289 |
+
|
| 290 |
+
torch.cuda.set_device(args.gpu)
|
| 291 |
+
args.dist_backend = "nccl"
|
| 292 |
+
print(f"| distributed init (rank {args.rank})")
|
| 293 |
+
torch.distributed.init_process_group(
|
| 294 |
+
backend=args.dist_backend,
|
| 295 |
+
world_size=args.world_size,
|
| 296 |
+
rank=args.rank,
|
| 297 |
+
device_id=args.gpu,
|
| 298 |
+
)
|
| 299 |
+
torch.distributed.barrier()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# checkpoint saving utils adapted from beit3
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def capture_rng_state() -> dict:
|
| 306 |
+
state = {"torch": torch.get_rng_state()}
|
| 307 |
+
if torch.cuda.is_available():
|
| 308 |
+
state["cuda"] = torch.cuda.get_rng_state()
|
| 309 |
+
return state
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def restore_rng_state(state: dict) -> None:
|
| 313 |
+
torch.set_rng_state(state["torch"])
|
| 314 |
+
if "cuda" in state and torch.cuda.is_available():
|
| 315 |
+
torch.cuda.set_rng_state(state["cuda"])
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _all_rank_rng_states() -> list[dict]:
|
| 319 |
+
local_state = capture_rng_state()
|
| 320 |
+
if not is_dist_avail_and_initialized():
|
| 321 |
+
return [local_state]
|
| 322 |
+
states = [None] * get_world_size()
|
| 323 |
+
dist.all_gather_object(states, local_state)
|
| 324 |
+
return states
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def save_model(args, epoch, model_without_ddp, optimizer, loss_scaler):
|
| 328 |
+
output_dir = Path(args.output_dir)
|
| 329 |
+
checkpoint_path = output_dir / f"checkpoint-{epoch:05d}.pth"
|
| 330 |
+
last_checkpoint_path = output_dir / "checkpoint-last.pth"
|
| 331 |
+
if epoch % args.checkpoint_period != 0 and epoch != args.epochs - 1:
|
| 332 |
+
return
|
| 333 |
+
|
| 334 |
+
to_save = {
|
| 335 |
+
"model": model_without_ddp.state_dict(),
|
| 336 |
+
"optimizer": optimizer.state_dict(),
|
| 337 |
+
"epoch": epoch,
|
| 338 |
+
"scaler": None if loss_scaler is None else loss_scaler.state_dict(),
|
| 339 |
+
"args": OmegaConf.to_container(args),
|
| 340 |
+
"rng_states": _all_rank_rng_states(),
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
print(f"saving checkpoint {last_checkpoint_path}")
|
| 344 |
+
save_on_master(to_save, last_checkpoint_path)
|
| 345 |
+
print(f"saving checkpoint {checkpoint_path}")
|
| 346 |
+
save_on_master(to_save, checkpoint_path)
|
| 347 |
+
|
| 348 |
+
if args.max_checkpoints and is_main_process():
|
| 349 |
+
all_checkpoints = sorted(output_dir.glob("checkpoint-[0-9]*.pth"))
|
| 350 |
+
del_count = max(0, len(all_checkpoints) - args.max_checkpoints)
|
| 351 |
+
for checkpoint_path in all_checkpoints[:del_count]:
|
| 352 |
+
print(f"removing checkpoint {checkpoint_path}")
|
| 353 |
+
checkpoint_path.unlink()
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def load_model(args, model_without_ddp, optimizer, loss_scaler):
|
| 357 |
+
auto_resume = getattr(args, "auto_resume", True)
|
| 358 |
+
output_dir = Path(args.output_dir)
|
| 359 |
+
|
| 360 |
+
last_checkpoint_path = output_dir / "checkpoint-last.pth"
|
| 361 |
+
if auto_resume and last_checkpoint_path.exists():
|
| 362 |
+
args.ckpt = str(last_checkpoint_path)
|
| 363 |
+
args.resume = True
|
| 364 |
+
|
| 365 |
+
if args.ckpt:
|
| 366 |
+
ckpt = torch.load(args.ckpt, map_location="cpu", weights_only=True)
|
| 367 |
+
model_without_ddp.load_state_dict(ckpt["model"])
|
| 368 |
+
print(f"loaded model state from checkpoint {args.ckpt}")
|
| 369 |
+
|
| 370 |
+
if args.resume:
|
| 371 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 372 |
+
if loss_scaler is not None:
|
| 373 |
+
loss_scaler.load_state_dict(ckpt["scaler"])
|
| 374 |
+
args.start_epoch = ckpt["epoch"] + 1
|
| 375 |
+
rng_states = ckpt.get("rng_states")
|
| 376 |
+
if rng_states is not None:
|
| 377 |
+
if len(rng_states) != get_world_size():
|
| 378 |
+
raise ValueError(
|
| 379 |
+
"checkpoint RNG state world size does not match current world size: "
|
| 380 |
+
f"{len(rng_states)} != {get_world_size()}"
|
| 381 |
+
)
|
| 382 |
+
restore_rng_state(rng_states[get_rank()])
|
| 383 |
+
print(f"restored RNG state for rank {get_rank()}")
|
| 384 |
+
print(f"loaded optimizer state, resuming training from {args.start_epoch}")
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# optimization utils
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# from capi
|
| 391 |
+
class WarmupThenCosine:
|
| 392 |
+
def __init__(
|
| 393 |
+
self,
|
| 394 |
+
base_value: float,
|
| 395 |
+
final_value: float,
|
| 396 |
+
total_iters: int,
|
| 397 |
+
warmup_iters: int = 0,
|
| 398 |
+
start_warmup_value: float = 0.0,
|
| 399 |
+
freeze_iters: int = 0,
|
| 400 |
+
truncate_cos: float = 1.0,
|
| 401 |
+
):
|
| 402 |
+
super().__init__()
|
| 403 |
+
self.final_value = final_value
|
| 404 |
+
self.total_iters = total_iters
|
| 405 |
+
|
| 406 |
+
freeze_schedule = np.zeros(freeze_iters)
|
| 407 |
+
|
| 408 |
+
warmup_schedule = np.linspace(start_warmup_value, base_value, warmup_iters)
|
| 409 |
+
|
| 410 |
+
iters = np.arange(total_iters - warmup_iters - freeze_iters)
|
| 411 |
+
schedule = final_value + 0.5 * (base_value - final_value) * (
|
| 412 |
+
1 + np.cos(np.pi * truncate_cos * iters / len(iters))
|
| 413 |
+
)
|
| 414 |
+
self.schedule = np.concatenate((freeze_schedule, warmup_schedule, schedule))
|
| 415 |
+
assert len(self.schedule) == self.total_iters
|
| 416 |
+
|
| 417 |
+
def __getitem__(self, it: int) -> float:
|
| 418 |
+
if it >= self.total_iters:
|
| 419 |
+
return self.final_value
|
| 420 |
+
# cast to float or else it can corrupt the checkpoint
|
| 421 |
+
return float(self.schedule[it])
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# adapted from timm backward logic
|
| 425 |
+
# https://github.com/huggingface/pytorch-image-models/blob/main/timm/utils/cuda.py
|
| 426 |
+
def backward_step(
|
| 427 |
+
loss: Tensor,
|
| 428 |
+
optimizer: Optimizer,
|
| 429 |
+
scaler: GradScaler = None,
|
| 430 |
+
need_update: bool = True,
|
| 431 |
+
max_norm: float | None = None,
|
| 432 |
+
) -> Tensor | None:
|
| 433 |
+
if scaler is not None:
|
| 434 |
+
scaler.scale(loss).backward()
|
| 435 |
+
else:
|
| 436 |
+
loss.backward()
|
| 437 |
+
|
| 438 |
+
if need_update:
|
| 439 |
+
if scaler is not None:
|
| 440 |
+
scaler.unscale_(optimizer)
|
| 441 |
+
|
| 442 |
+
total_norm = clip_grad(optimizer, max_norm)
|
| 443 |
+
|
| 444 |
+
if scaler is not None:
|
| 445 |
+
scaler.step(optimizer)
|
| 446 |
+
scaler.update()
|
| 447 |
+
else:
|
| 448 |
+
optimizer.step()
|
| 449 |
+
optimizer.zero_grad()
|
| 450 |
+
else:
|
| 451 |
+
total_norm = None
|
| 452 |
+
return total_norm
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def clip_grad(optimizer: Optimizer, max_norm: float | None = None) -> Tensor:
|
| 456 |
+
params = [p for group in optimizer.param_groups for p in group["params"]]
|
| 457 |
+
if max_norm:
|
| 458 |
+
total_norm = nn.utils.clip_grad_norm_(params, max_norm, error_if_nonfinite=False)
|
| 459 |
+
else:
|
| 460 |
+
grads = [p.grad for p in params if p.grad is not None]
|
| 461 |
+
total_norm = nn.utils.get_total_norm(grads, error_if_nonfinite=False)
|
| 462 |
+
torch._assert_async(torch.isfinite(total_norm), "non-finite gradient norm")
|
| 463 |
+
return total_norm
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# from dinov2 with some minor changes
|
| 467 |
+
def get_param_groups(model, patch_embed_lr_mult=1.0):
|
| 468 |
+
# no lr decay, we could add this later if needed
|
| 469 |
+
all_params = []
|
| 470 |
+
|
| 471 |
+
for name, param in model.named_parameters():
|
| 472 |
+
if not param.requires_grad:
|
| 473 |
+
continue
|
| 474 |
+
d = {"param": param, "lr_multiplier": 1.0, "wd_multiplier": 1.0, "name": name}
|
| 475 |
+
|
| 476 |
+
if name.endswith(".bias") or "norm" in name or "gamma" in name:
|
| 477 |
+
d["wd_multiplier"] = 0.0
|
| 478 |
+
|
| 479 |
+
if "patch_embed" in name:
|
| 480 |
+
d["lr_multiplier"] = d["lr_multiplier"] * patch_embed_lr_mult
|
| 481 |
+
|
| 482 |
+
all_params.append(d)
|
| 483 |
+
|
| 484 |
+
param_groups = _fuse_param_groups(all_params)
|
| 485 |
+
return param_groups
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _fuse_param_groups(all_param_groups):
|
| 489 |
+
fused_param_groups = defaultdict(lambda: {"params": []})
|
| 490 |
+
for d in all_param_groups:
|
| 491 |
+
keys = sorted(set(d.keys()) - {"param", "name"})
|
| 492 |
+
identifier = "_".join(f"{k}{d[k]}" for k in keys)
|
| 493 |
+
for k in keys:
|
| 494 |
+
fused_param_groups[identifier][k] = d[k]
|
| 495 |
+
fused_param_groups[identifier]["params"].append(d["param"])
|
| 496 |
+
|
| 497 |
+
param_groups = list(fused_param_groups.values())
|
| 498 |
+
return param_groups
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def update_lr(param_groups, lr: float):
|
| 502 |
+
for group in param_groups:
|
| 503 |
+
group["lr"] = lr * group["lr_multiplier"]
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def update_wd(param_groups, weight_decay: float | None = None):
|
| 507 |
+
for group in param_groups:
|
| 508 |
+
group["weight_decay"] = weight_decay * group["wd_multiplier"]
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# moving data to cuda utils copied from capi
|
| 512 |
+
# added device argument
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def send_data(x, device=None, dtype_map=None):
|
| 516 |
+
if device is None:
|
| 517 |
+
device = torch.device("cuda")
|
| 518 |
+
else:
|
| 519 |
+
device = torch.device(device)
|
| 520 |
+
|
| 521 |
+
if isinstance(x, torch.Tensor):
|
| 522 |
+
dtype = dtype_map.get(x.dtype) if dtype_map else None
|
| 523 |
+
return x.to(device=device, dtype=dtype, non_blocking=True)
|
| 524 |
+
if isinstance(x, dict):
|
| 525 |
+
return {k: send_data(v, device=device, dtype_map=dtype_map) for k, v in x.items()}
|
| 526 |
+
if isinstance(x, list):
|
| 527 |
+
return [send_data(v, device=device, dtype_map=dtype_map) for v in x]
|
| 528 |
+
return x
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def pre_send_to_cuda_wrapper(generator, device=None, dtype_map=None):
|
| 532 |
+
"""From apex"""
|
| 533 |
+
data = None
|
| 534 |
+
stream = torch.cuda.Stream(device)
|
| 535 |
+
for next_data in generator:
|
| 536 |
+
with torch.cuda.stream(stream):
|
| 537 |
+
next_data = send_data(next_data, device=device, dtype_map=dtype_map)
|
| 538 |
+
if data is not None:
|
| 539 |
+
yield data
|
| 540 |
+
torch.cuda.current_stream(device).wait_stream(stream)
|
| 541 |
+
data = next_data
|
| 542 |
+
if data is not None:
|
| 543 |
+
yield data
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
# other misc utils
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
# from dino
|
| 550 |
+
def get_sha():
|
| 551 |
+
cwd = os.path.dirname(os.path.abspath(__file__))
|
| 552 |
+
|
| 553 |
+
def _run(command):
|
| 554 |
+
return subprocess.check_output(command, cwd=cwd).decode("ascii").strip()
|
| 555 |
+
|
| 556 |
+
sha = "N/A"
|
| 557 |
+
diff = "clean"
|
| 558 |
+
branch = "N/A"
|
| 559 |
+
try:
|
| 560 |
+
sha = _run(["git", "rev-parse", "HEAD"])
|
| 561 |
+
diff = _run(["git", "diff-index", "HEAD"])
|
| 562 |
+
diff = "has uncommitted changes" if diff else "clean"
|
| 563 |
+
branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
| 564 |
+
except Exception:
|
| 565 |
+
pass
|
| 566 |
+
message = f"sha: {sha}, status: {diff}, branch: {branch}"
|
| 567 |
+
return message
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
# from timm
|
| 571 |
+
def random_seed(seed=42, rank=0):
|
| 572 |
+
torch.manual_seed(seed + rank)
|
| 573 |
+
np.random.seed(seed + rank)
|
| 574 |
+
random.seed(seed + rank)
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# mine :)
|
| 578 |
+
def filter_kwargs(func, kwargs):
|
| 579 |
+
sigature = inspect.signature(func)
|
| 580 |
+
kwargs = {k: v for k, v in kwargs.items() if k in sigature.parameters}
|
| 581 |
+
return kwargs
|
fomo_tune_baseline/output/task1/build/smri_mae/visualization.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Mapping
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from matplotlib import patches
|
| 7 |
+
from matplotlib import pyplot as plt
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
VIEW_NAMES = {
|
| 12 |
+
"sagittal": "Sagittal",
|
| 13 |
+
"saggital": "Sagittal",
|
| 14 |
+
"axial": "Axial",
|
| 15 |
+
"coronal": "Coronal",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def fig2pil(fig) -> Image.Image:
|
| 20 |
+
buffer = BytesIO()
|
| 21 |
+
fig.savefig(buffer, format="png", dpi=fig.dpi, facecolor=fig.get_facecolor())
|
| 22 |
+
buffer.seek(0)
|
| 23 |
+
image = Image.open(buffer).convert("RGB")
|
| 24 |
+
buffer.close()
|
| 25 |
+
return image
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def raw_stats_from_batch(batch: dict) -> tuple[Tensor | None, Tensor | None]:
|
| 29 |
+
metas = batch.get("meta")
|
| 30 |
+
if not metas:
|
| 31 |
+
return None, None
|
| 32 |
+
|
| 33 |
+
means = []
|
| 34 |
+
stds = []
|
| 35 |
+
for meta in metas:
|
| 36 |
+
try:
|
| 37 |
+
mean = meta["raw_mean"]
|
| 38 |
+
std = meta["raw_std"]
|
| 39 |
+
except (KeyError, TypeError):
|
| 40 |
+
return None, None
|
| 41 |
+
if mean in ("", None) or std in ("", None):
|
| 42 |
+
return None, None
|
| 43 |
+
means.append(float(mean))
|
| 44 |
+
stds.append(float(std))
|
| 45 |
+
return torch.tensor(means), torch.tensor(stds)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def plot_mask_pred(
|
| 49 |
+
target: Tensor,
|
| 50 |
+
pred: Tensor,
|
| 51 |
+
pred_mask: Tensor | None = None,
|
| 52 |
+
img_mask: Tensor | None = None,
|
| 53 |
+
sample_idx: int = 0,
|
| 54 |
+
channel_idx: int = 0,
|
| 55 |
+
slice_idx: int | Mapping[str, int] | None = None,
|
| 56 |
+
patch_size: int | tuple[int, int, int] = 16,
|
| 57 |
+
views: tuple[str, ...] = ("sagittal", "axial", "coronal"),
|
| 58 |
+
cmap: str = "gray",
|
| 59 |
+
figsize: tuple[float, float] | None = None,
|
| 60 |
+
mask_style: str = "blank",
|
| 61 |
+
raw_mean: float | Tensor | None = None,
|
| 62 |
+
raw_std: float | Tensor | None = None,
|
| 63 |
+
):
|
| 64 |
+
target_vol = _select_volume(target, sample_idx=sample_idx, channel_idx=channel_idx)
|
| 65 |
+
pred_vol = _select_volume(pred, sample_idx=sample_idx, channel_idx=channel_idx)
|
| 66 |
+
if raw_mean is not None and raw_std is not None:
|
| 67 |
+
raw_mean = _select_scalar(raw_mean, sample_idx=sample_idx)
|
| 68 |
+
raw_std = _select_scalar(raw_std, sample_idx=sample_idx)
|
| 69 |
+
target_vol = target_vol * raw_std + raw_mean
|
| 70 |
+
pred_vol = pred_vol * raw_std + raw_mean
|
| 71 |
+
pred_mask_vol = (
|
| 72 |
+
torch.zeros_like(target_vol)
|
| 73 |
+
if pred_mask is None
|
| 74 |
+
else _select_volume(pred_mask, sample_idx=sample_idx, channel_idx=channel_idx) > 0
|
| 75 |
+
)
|
| 76 |
+
img_mask_vol = None
|
| 77 |
+
if img_mask is not None:
|
| 78 |
+
img_mask_vol = _select_volume(img_mask, sample_idx=sample_idx, channel_idx=channel_idx) > 0
|
| 79 |
+
|
| 80 |
+
composite_vol = _prediction_composite(target_vol, pred_vol, pred_mask_vol)
|
| 81 |
+
vmin, vmax = _intensity_limits(target_vol, img_mask_vol)
|
| 82 |
+
|
| 83 |
+
patch_size = _as_3tuple(patch_size)
|
| 84 |
+
view_items = []
|
| 85 |
+
for view in views:
|
| 86 |
+
view_key = view.lower()
|
| 87 |
+
if view_key not in VIEW_NAMES:
|
| 88 |
+
raise ValueError(f"unknown MRI view {view!r}; expected one of {tuple(VIEW_NAMES)}")
|
| 89 |
+
target_slice = _extract_view_slice(target_vol, view_key, slice_idx)
|
| 90 |
+
composite_slice = _extract_view_slice(composite_vol, view_key, slice_idx)
|
| 91 |
+
mask_slice = _extract_view_slice(pred_mask_vol.float(), view_key, slice_idx) > 0
|
| 92 |
+
img_mask_slice = None
|
| 93 |
+
if img_mask_vol is not None:
|
| 94 |
+
img_mask_slice = _extract_view_slice(img_mask_vol.float(), view_key, slice_idx) > 0
|
| 95 |
+
view_items.append(
|
| 96 |
+
{
|
| 97 |
+
"key": view_key,
|
| 98 |
+
"title": VIEW_NAMES[view_key],
|
| 99 |
+
"target": _masked_input_display(target_slice, mask_slice, img_mask_slice, vmin),
|
| 100 |
+
"composite": _apply_display_mask(composite_slice, img_mask_slice, vmin),
|
| 101 |
+
"actual": _apply_display_mask(target_slice, img_mask_slice, vmin),
|
| 102 |
+
"mask": mask_slice,
|
| 103 |
+
"img_mask": img_mask_slice,
|
| 104 |
+
"patch_rc": _view_patch_size(view_key, patch_size),
|
| 105 |
+
}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
_crop_view_items(view_items)
|
| 109 |
+
|
| 110 |
+
fig, axes, layout = _make_figure_canvas(view_items, figsize=figsize)
|
| 111 |
+
for item, x in zip(view_items, layout["col_centers"]):
|
| 112 |
+
fig.text(
|
| 113 |
+
x,
|
| 114 |
+
layout["title_y"],
|
| 115 |
+
item["title"],
|
| 116 |
+
ha="center",
|
| 117 |
+
va="center",
|
| 118 |
+
color="#f8fafc",
|
| 119 |
+
fontsize=7,
|
| 120 |
+
)
|
| 121 |
+
for label, y in zip(("Masked", "Pred", "Actual"), layout["row_centers"]):
|
| 122 |
+
fig.text(layout["label_x"], y, label, ha="right", va="center", color="#cbd5e1", fontsize=6)
|
| 123 |
+
|
| 124 |
+
for item, top_ax, middle_ax, bottom_ax in zip(view_items, axes[0], axes[1], axes[2]):
|
| 125 |
+
top_ax.imshow(
|
| 126 |
+
item["target"],
|
| 127 |
+
cmap=cmap,
|
| 128 |
+
vmin=vmin,
|
| 129 |
+
vmax=vmax,
|
| 130 |
+
interpolation="nearest",
|
| 131 |
+
origin="upper",
|
| 132 |
+
)
|
| 133 |
+
if mask_style == "boxes":
|
| 134 |
+
_draw_patch_boxes(top_ax, item["mask"], item["patch_rc"])
|
| 135 |
+
elif mask_style != "blank":
|
| 136 |
+
raise ValueError("mask_style must be 'blank' or 'boxes'")
|
| 137 |
+
_style_axis(top_ax)
|
| 138 |
+
|
| 139 |
+
middle_ax.imshow(
|
| 140 |
+
item["composite"],
|
| 141 |
+
cmap=cmap,
|
| 142 |
+
vmin=vmin,
|
| 143 |
+
vmax=vmax,
|
| 144 |
+
interpolation="nearest",
|
| 145 |
+
origin="upper",
|
| 146 |
+
)
|
| 147 |
+
_style_axis(middle_ax)
|
| 148 |
+
|
| 149 |
+
bottom_ax.imshow(
|
| 150 |
+
item["actual"],
|
| 151 |
+
cmap=cmap,
|
| 152 |
+
vmin=vmin,
|
| 153 |
+
vmax=vmax,
|
| 154 |
+
interpolation="nearest",
|
| 155 |
+
origin="upper",
|
| 156 |
+
)
|
| 157 |
+
_style_axis(bottom_ax)
|
| 158 |
+
return fig
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _select_volume(
|
| 162 |
+
x: Tensor,
|
| 163 |
+
sample_idx: int = 0,
|
| 164 |
+
channel_idx: int = 0,
|
| 165 |
+
) -> Tensor:
|
| 166 |
+
x = x.detach().float().cpu()
|
| 167 |
+
if x.ndim == 5:
|
| 168 |
+
return x[sample_idx, channel_idx]
|
| 169 |
+
if x.ndim == 4:
|
| 170 |
+
return x[sample_idx]
|
| 171 |
+
if x.ndim == 3:
|
| 172 |
+
return x
|
| 173 |
+
raise ValueError(f"expected a 3D volume tensor, got shape {tuple(x.shape)}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _select_scalar(value: float | Tensor, sample_idx: int = 0) -> float:
|
| 177 |
+
if isinstance(value, Tensor):
|
| 178 |
+
value = value.detach().float().cpu()
|
| 179 |
+
if value.ndim > 0:
|
| 180 |
+
value = value.reshape(-1)[sample_idx]
|
| 181 |
+
return float(value)
|
| 182 |
+
return float(value)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _prediction_composite(target: Tensor, pred: Tensor, pred_mask: Tensor) -> Tensor:
|
| 186 |
+
pred_mask = pred_mask.to(dtype=target.dtype)
|
| 187 |
+
return target * (1 - pred_mask) + pred * pred_mask
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _extract_view_slice(
|
| 191 |
+
volume: Tensor,
|
| 192 |
+
view: str,
|
| 193 |
+
slice_idx: int | Mapping[str, int] | None = None,
|
| 194 |
+
) -> Tensor:
|
| 195 |
+
if isinstance(slice_idx, Mapping):
|
| 196 |
+
slice_idx = slice_idx.get(view)
|
| 197 |
+
|
| 198 |
+
if view in ("sagittal", "saggital"):
|
| 199 |
+
idx = _resolve_slice_idx(volume.shape[0], slice_idx)
|
| 200 |
+
return volume[idx, :, :].transpose(0, 1).flip(0)
|
| 201 |
+
if view == "axial":
|
| 202 |
+
idx = _resolve_slice_idx(volume.shape[2], slice_idx)
|
| 203 |
+
return volume[:, :, idx].transpose(0, 1).flip(0)
|
| 204 |
+
if view == "coronal":
|
| 205 |
+
idx = _resolve_slice_idx(volume.shape[1], slice_idx)
|
| 206 |
+
return volume[:, idx, :].transpose(0, 1).flip(0)
|
| 207 |
+
raise ValueError(f"unknown MRI view {view!r}")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _resolve_slice_idx(size: int, slice_idx: int | None) -> int:
|
| 211 |
+
idx = size // 2 if slice_idx is None else int(slice_idx)
|
| 212 |
+
if idx < 0:
|
| 213 |
+
idx += size
|
| 214 |
+
if idx < 0 or idx >= size:
|
| 215 |
+
raise IndexError(f"slice index {idx} is out of bounds for axis with size {size}")
|
| 216 |
+
return idx
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _intensity_limits(volume: Tensor, mask: Tensor | None = None) -> tuple[float, float]:
|
| 220 |
+
values = volume[mask] if mask is not None and mask.any() else volume.flatten()
|
| 221 |
+
values = values[torch.isfinite(values)]
|
| 222 |
+
if values.numel() == 0:
|
| 223 |
+
return 0.0, 1.0
|
| 224 |
+
if values.numel() < 32:
|
| 225 |
+
vmin = values.min()
|
| 226 |
+
vmax = values.max()
|
| 227 |
+
else:
|
| 228 |
+
vmin, vmax = torch.quantile(values, torch.tensor([0.005, 0.995]))
|
| 229 |
+
if torch.isclose(vmin, vmax):
|
| 230 |
+
delta = max(abs(float(vmin)) * 0.05, 1.0)
|
| 231 |
+
return float(vmin) - delta, float(vmax) + delta
|
| 232 |
+
return float(vmin), float(vmax)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _apply_display_mask(image: Tensor, mask: Tensor | None, fill_value: float) -> Tensor:
|
| 236 |
+
if mask is None:
|
| 237 |
+
return image
|
| 238 |
+
return torch.where(mask, image, torch.full_like(image, fill_value))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _masked_input_display(
|
| 242 |
+
image: Tensor,
|
| 243 |
+
pred_mask: Tensor,
|
| 244 |
+
img_mask: Tensor | None,
|
| 245 |
+
fill_value: float,
|
| 246 |
+
) -> Tensor:
|
| 247 |
+
display = torch.where(pred_mask, torch.full_like(image, fill_value), image)
|
| 248 |
+
return _apply_display_mask(display, img_mask, fill_value)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _crop_view_items(view_items: list[dict]) -> None:
|
| 252 |
+
for item in view_items:
|
| 253 |
+
mask = item["img_mask"]
|
| 254 |
+
if mask is None:
|
| 255 |
+
mask = item["actual"] != item["actual"].min()
|
| 256 |
+
row_slice, col_slice = _content_crop(mask, item["patch_rc"])
|
| 257 |
+
for key in ("target", "composite", "actual", "mask"):
|
| 258 |
+
item[key] = item[key][row_slice, col_slice]
|
| 259 |
+
if item["img_mask"] is not None:
|
| 260 |
+
item["img_mask"] = item["img_mask"][row_slice, col_slice]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _content_crop(mask: Tensor, patch_size: tuple[int, int]) -> tuple[slice, slice]:
|
| 264 |
+
mask = mask.detach().cpu() > 0
|
| 265 |
+
if not mask.any():
|
| 266 |
+
return slice(None), slice(None)
|
| 267 |
+
|
| 268 |
+
rows, cols = mask.nonzero(as_tuple=True)
|
| 269 |
+
patch_h, patch_w = patch_size
|
| 270 |
+
height, width = mask.shape
|
| 271 |
+
row0 = max((int(rows.min()) // patch_h - 1) * patch_h, 0)
|
| 272 |
+
row1 = min((int(rows.max()) // patch_h + 2) * patch_h, height)
|
| 273 |
+
col0 = max((int(cols.min()) // patch_w - 1) * patch_w, 0)
|
| 274 |
+
col1 = min((int(cols.max()) // patch_w + 2) * patch_w, width)
|
| 275 |
+
return slice(row0, row1), slice(col0, col1)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _as_3tuple(value: int | tuple[int, int, int]) -> tuple[int, int, int]:
|
| 279 |
+
if isinstance(value, int):
|
| 280 |
+
return (value, value, value)
|
| 281 |
+
if len(value) != 3:
|
| 282 |
+
raise ValueError(f"expected a 3-tuple patch size, got {value!r}")
|
| 283 |
+
return tuple(int(v) for v in value)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _view_patch_size(view: str, patch_size: tuple[int, int, int]) -> tuple[int, int]:
|
| 287 |
+
p_x, p_y, p_z = patch_size
|
| 288 |
+
if view in ("sagittal", "saggital"):
|
| 289 |
+
return p_z, p_y
|
| 290 |
+
if view == "axial":
|
| 291 |
+
return p_y, p_x
|
| 292 |
+
if view == "coronal":
|
| 293 |
+
return p_z, p_x
|
| 294 |
+
raise ValueError(f"unknown MRI view {view!r}")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _make_figure_canvas(
|
| 298 |
+
view_items: list[dict[str, Tensor | str | tuple[int, int]]],
|
| 299 |
+
figsize: tuple[float, float] | None = None,
|
| 300 |
+
):
|
| 301 |
+
dpi = 160
|
| 302 |
+
left = 58
|
| 303 |
+
right = 6
|
| 304 |
+
top = 18
|
| 305 |
+
bottom = 8
|
| 306 |
+
row_gap = 14
|
| 307 |
+
col_gap = 8
|
| 308 |
+
widths = [int(item["target"].shape[1]) for item in view_items]
|
| 309 |
+
heights = [int(item["target"].shape[0]) for item in view_items]
|
| 310 |
+
row_h = max(heights)
|
| 311 |
+
num_rows = 3
|
| 312 |
+
fig_w = left + right + sum(widths) + col_gap * (len(widths) - 1)
|
| 313 |
+
fig_h = top + bottom + row_h * num_rows + row_gap * (num_rows - 1)
|
| 314 |
+
|
| 315 |
+
scale = 1.35
|
| 316 |
+
if figsize is not None:
|
| 317 |
+
requested_w = figsize[0] * dpi
|
| 318 |
+
requested_h = figsize[1] * dpi
|
| 319 |
+
scale = max(requested_w / fig_w, requested_h / fig_h)
|
| 320 |
+
figsize = (fig_w * scale / dpi, fig_h * scale / dpi)
|
| 321 |
+
fig = plt.figure(figsize=figsize, dpi=dpi, facecolor="#0b0f14")
|
| 322 |
+
|
| 323 |
+
axes = [[] for _ in range(num_rows)]
|
| 324 |
+
col_centers = []
|
| 325 |
+
x = left
|
| 326 |
+
for width, height in zip(widths, heights):
|
| 327 |
+
col_centers.append((x + width / 2) / fig_w)
|
| 328 |
+
ys = [
|
| 329 |
+
bottom + (num_rows - row - 1) * (row_h + row_gap) + (row_h - height) / 2
|
| 330 |
+
for row in range(num_rows)
|
| 331 |
+
]
|
| 332 |
+
for row, y in enumerate(ys):
|
| 333 |
+
axes[row].append(
|
| 334 |
+
fig.add_axes(
|
| 335 |
+
[
|
| 336 |
+
x / fig_w,
|
| 337 |
+
y / fig_h,
|
| 338 |
+
width / fig_w,
|
| 339 |
+
height / fig_h,
|
| 340 |
+
],
|
| 341 |
+
facecolor="black",
|
| 342 |
+
)
|
| 343 |
+
)
|
| 344 |
+
x += width + col_gap
|
| 345 |
+
row_centers = [
|
| 346 |
+
(bottom + (num_rows - row - 1) * (row_h + row_gap) + row_h / 2) / fig_h
|
| 347 |
+
for row in range(num_rows)
|
| 348 |
+
]
|
| 349 |
+
layout = {
|
| 350 |
+
"col_centers": col_centers,
|
| 351 |
+
"row_centers": row_centers,
|
| 352 |
+
"label_x": (left - 8) / fig_w,
|
| 353 |
+
"title_y": (fig_h - top / 2) / fig_h,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
return fig, axes, layout
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _style_axis(ax) -> None:
|
| 360 |
+
ax.set_xticks([])
|
| 361 |
+
ax.set_yticks([])
|
| 362 |
+
for spine in ax.spines.values():
|
| 363 |
+
spine.set_visible(False)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _draw_patch_boxes(
|
| 367 |
+
ax,
|
| 368 |
+
mask: Tensor,
|
| 369 |
+
patch_size: tuple[int, int],
|
| 370 |
+
color: str = "#facc15",
|
| 371 |
+
) -> None:
|
| 372 |
+
for col, row, width, height in _patch_rectangles(mask, patch_size):
|
| 373 |
+
ax.add_patch(
|
| 374 |
+
patches.Rectangle(
|
| 375 |
+
(col - 0.5, row - 0.5),
|
| 376 |
+
width,
|
| 377 |
+
height,
|
| 378 |
+
fill=False,
|
| 379 |
+
edgecolor=color,
|
| 380 |
+
linewidth=0.75,
|
| 381 |
+
alpha=0.95,
|
| 382 |
+
)
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _patch_rectangles(
|
| 387 |
+
mask: Tensor,
|
| 388 |
+
patch_size: tuple[int, int],
|
| 389 |
+
) -> list[tuple[int, int, int, int]]:
|
| 390 |
+
mask = mask.detach().cpu() > 0
|
| 391 |
+
patch_h, patch_w = patch_size
|
| 392 |
+
height, width = mask.shape
|
| 393 |
+
rectangles = []
|
| 394 |
+
for row in range(0, height, patch_h):
|
| 395 |
+
box_h = min(patch_h, height - row)
|
| 396 |
+
for col in range(0, width, patch_w):
|
| 397 |
+
box_w = min(patch_w, width - col)
|
| 398 |
+
if mask[row : row + box_h, col : col + box_w].any():
|
| 399 |
+
rectangles.append((col, row, box_w, box_h))
|
| 400 |
+
return rectangles
|
fomo_tune_baseline/output/task1/config.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: task1
|
| 2 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 3 |
+
modalities:
|
| 4 |
+
- dwi_b1000
|
| 5 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 6 |
+
name: task1
|
| 7 |
+
device: cuda
|
| 8 |
+
seed: 4466
|
fomo_tune_baseline/output/task1/log.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
16:31:38 run task1 (git 1df2e5d)
|
| 2 |
+
16:31:38 config:
|
| 3 |
+
task: task1
|
| 4 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 5 |
+
modalities:
|
| 6 |
+
- dwi_b1000
|
| 7 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 8 |
+
name: task1
|
| 9 |
+
device: cuda
|
| 10 |
+
seed: 4466
|
| 11 |
+
16:31:40 dataset: 21 subjects, 13 positive
|
| 12 |
+
16:31:48 fold 1/21 sub-01 y=1 p=0.512 (4s)
|
| 13 |
+
16:31:49 fold 2/21 sub-02 y=1 p=0.514 (4s)
|
| 14 |
+
16:31:49 fold 3/21 sub-03 y=0 p=0.486 (5s)
|
| 15 |
+
16:31:49 fold 4/21 sub-04 y=1 p=0.499 (5s)
|
| 16 |
+
16:31:50 fold 5/21 sub-05 y=1 p=0.507 (5s)
|
| 17 |
+
16:31:50 fold 6/21 sub-06 y=1 p=0.493 (6s)
|
| 18 |
+
16:31:50 fold 7/21 sub-07 y=0 p=0.489 (6s)
|
| 19 |
+
16:31:51 fold 8/21 sub-08 y=0 p=0.490 (6s)
|
| 20 |
+
16:31:51 fold 9/21 sub-09 y=1 p=0.523 (7s)
|
| 21 |
+
16:31:52 fold 10/21 sub-10 y=1 p=0.525 (7s)
|
| 22 |
+
16:31:52 fold 11/21 sub-11 y=0 p=0.479 (7s)
|
| 23 |
+
16:31:52 fold 12/21 sub-12 y=0 p=0.474 (8s)
|
| 24 |
+
16:31:53 fold 13/21 sub-13 y=1 p=0.516 (8s)
|
| 25 |
+
16:31:53 fold 14/21 sub-14 y=1 p=0.536 (9s)
|
| 26 |
+
16:31:53 fold 15/21 sub-15 y=0 p=0.486 (9s)
|
| 27 |
+
16:31:54 fold 16/21 sub-16 y=0 p=0.482 (9s)
|
| 28 |
+
16:31:54 fold 17/21 sub-17 y=1 p=0.520 (10s)
|
| 29 |
+
16:31:54 fold 18/21 sub-18 y=1 p=0.518 (10s)
|
| 30 |
+
16:31:55 fold 19/21 sub-19 y=1 p=0.520 (10s)
|
| 31 |
+
16:31:55 fold 20/21 sub-20 y=1 p=0.519 (11s)
|
| 32 |
+
16:31:56 fold 21/21 sub-21 y=0 p=0.498 (11s)
|
| 33 |
+
16:31:57 result: auroc=0.9904 auroc_ci_low=0.9444 auroc_ci_high=1.0000 (11s)
|
fomo_tune_baseline/output/task1/metrics.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"name": "task1", "auroc": 0.9903846153846154, "auroc_ci_low": 0.9444444444444445, "auroc_ci_high": 1.0, "run_time": 11.2}
|
fomo_tune_baseline/output/task1/model/config.yaml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: task1
|
| 2 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 3 |
+
modalities:
|
| 4 |
+
- dwi_b1000
|
| 5 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 6 |
+
name: task1
|
| 7 |
+
device: cuda
|
| 8 |
+
seed: 4466
|
fomo_tune_baseline/output/task1/model/head.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f4a4826f5ea9f3f74279b798caf6a5970a3c63b02b70c4d0828ee5325d29e0dd
|
| 3 |
+
size 445215
|
fomo_tune_baseline/output/task1/task1.sif
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0d9c2a0d53bf894c9788fe0fd45a6bb0e5a5f7c4c7554fd4b1c488397d35fd93
|
| 3 |
+
size 5329231872
|
fomo_tune_baseline/output/task3/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 "$@"
|
fomo_tune_baseline/output/task3/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.
|
fomo_tune_baseline/output/task3/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
|
fomo_tune_baseline/output/task3/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
|
fomo_tune_baseline/output/task3/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()
|
fomo_tune_baseline/output/task3/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()
|
fomo_tune_baseline/output/task3/build/fomo_tune/main_task5.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FOMO task 5: polymicrogyria classification, scored by AUROC as the challenge scores it.
|
| 2 |
+
|
| 3 |
+
`Task5Method` 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 48 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 probability out. Both go through `Task5Method.predict`, so every fold exercises
|
| 9 |
+
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
|
| 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.model_selection import KFold
|
| 27 |
+
from sklearn.pipeline import make_pipeline
|
| 28 |
+
from sklearn.preprocessing import StandardScaler
|
| 29 |
+
|
| 30 |
+
from fomo_tune.backbone import load_backbone
|
| 31 |
+
from fomo_tune.utils import git_sha, set_seed, setup_logging
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("fomo_tune")
|
| 34 |
+
|
| 35 |
+
Images = dict[str, nib.Nifti1Image]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Config:
|
| 40 |
+
task: str = "task5"
|
| 41 |
+
ckpt_path: str = (
|
| 42 |
+
"/data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth"
|
| 43 |
+
)
|
| 44 |
+
output_root: str = "output/fomo_tune"
|
| 45 |
+
name: str = "task5"
|
| 46 |
+
device: str = "cuda"
|
| 47 |
+
seed: int = 4466
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---- method: the part we tune -----------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Task5Method:
|
| 54 |
+
"""Frozen sMRI MAE, mean-pooled tokens over the t1w, 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.cache: dict[str, np.ndarray] = {}
|
| 62 |
+
self.head = None
|
| 63 |
+
|
| 64 |
+
@torch.inference_mode()
|
| 65 |
+
def features(self, images: Images) -> np.ndarray:
|
| 66 |
+
"""(D,) per subject. A pure function of the images, so training and inference agree."""
|
| 67 |
+
sample = self.transform(images["t1w"])
|
| 68 |
+
batch = {key: value[None].to(self.device) for key, value in sample.items()}
|
| 69 |
+
|
| 70 |
+
with torch.autocast("cuda", torch.bfloat16, enabled=self.device.type == "cuda"):
|
| 71 |
+
out = self.backbone(batch)
|
| 72 |
+
|
| 73 |
+
patch_embeds = out["patch_embeds"]
|
| 74 |
+
token_mask = out["token_mask"].bool().unsqueeze(-1)
|
| 75 |
+
embed = (patch_embeds * token_mask).sum(dim=1) / token_mask.sum(dim=1)
|
| 76 |
+
return embed[0].float().cpu().numpy()
|
| 77 |
+
|
| 78 |
+
def cached_features(self, row: dict) -> np.ndarray:
|
| 79 |
+
if row["subject"] not in self.cache:
|
| 80 |
+
self.cache[row["subject"]] = self.features(row)
|
| 81 |
+
return self.cache[row["subject"]]
|
| 82 |
+
|
| 83 |
+
def fit(self, rows: list[dict]) -> None:
|
| 84 |
+
X = np.stack([self.cached_features(row) for row in rows])
|
| 85 |
+
y = np.array([row["label"] for row in rows])
|
| 86 |
+
|
| 87 |
+
clf = LogisticRegressionCV(
|
| 88 |
+
Cs=10,
|
| 89 |
+
class_weight="balanced",
|
| 90 |
+
scoring="roc_auc",
|
| 91 |
+
max_iter=1000,
|
| 92 |
+
l1_ratios=(0,),
|
| 93 |
+
use_legacy_attributes=False,
|
| 94 |
+
)
|
| 95 |
+
self.head = make_pipeline(StandardScaler(), clf)
|
| 96 |
+
self.head.fit(X, y)
|
| 97 |
+
self.positive = list(self.head.classes_).index(1)
|
| 98 |
+
|
| 99 |
+
def predict(self, images: Images) -> float:
|
| 100 |
+
"""Positive-class probability. Indexes `classes_` rather than assuming column 1, which
|
| 101 |
+
would silently score the wrong class if the label order differed."""
|
| 102 |
+
X = self.features(images)[None]
|
| 103 |
+
probs = self.head.predict_proba(X)[0]
|
| 104 |
+
return float(probs[self.positive])
|
| 105 |
+
|
| 106 |
+
def save(self, model_dir: Path) -> None:
|
| 107 |
+
"""Everything `load` needs but the backbone weights, which stay wherever `ckpt_path`
|
| 108 |
+
points -- a few hundred KB, so a run saves one without copying a 3.7G checkpoint."""
|
| 109 |
+
model_dir.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
OmegaConf.save(self.cfg, model_dir / "config.yaml")
|
| 111 |
+
joblib.dump({"head": self.head, "positive": self.positive}, model_dir / "head.joblib")
|
| 112 |
+
|
| 113 |
+
@classmethod
|
| 114 |
+
def load(cls, model_dir: Path, **overrides) -> "Task5Method":
|
| 115 |
+
"""Rebuild a fitted method from `save`. Overrides are Config fields, for what differs
|
| 116 |
+
between here and the container -- the backbone path, the device."""
|
| 117 |
+
cfg = OmegaConf.merge(
|
| 118 |
+
OmegaConf.structured(Config), OmegaConf.load(model_dir / "config.yaml"), overrides
|
| 119 |
+
)
|
| 120 |
+
method = cls(cfg)
|
| 121 |
+
state = joblib.load(model_dir / "head.joblib")
|
| 122 |
+
method.head, method.positive = state["head"], state["positive"]
|
| 123 |
+
return method
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---- protocol: the part we hold fixed ---------------------------------------------------
|
| 127 |
+
|
| 128 |
+
# Every image the task ships. The method picks which of them it wants, as at inference, where the
|
| 129 |
+
# challenge hands over the modalities whether or not a model uses them.
|
| 130 |
+
IMAGE_COLS = ("t1w",)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def cross_validate(
|
| 134 |
+
rows: list[dict], method: Task5Method, seed: int = 0, n_folds: int = 20
|
| 135 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 136 |
+
"""Out-of-fold score for every subject, each predicted by a head fit on the other folds."""
|
| 137 |
+
y = np.array([row["label"] for row in rows])
|
| 138 |
+
oof = np.zeros(len(rows), dtype=float)
|
| 139 |
+
folds = KFold(n_splits=n_folds, shuffle=True, random_state=seed)
|
| 140 |
+
start = time.perf_counter()
|
| 141 |
+
for fold, (train, test) in enumerate(folds.split(rows)):
|
| 142 |
+
method.fit([rows[i] for i in train])
|
| 143 |
+
for i in test:
|
| 144 |
+
oof[i] = method.predict({key: rows[i][key] for key in IMAGE_COLS})
|
| 145 |
+
logger.info(
|
| 146 |
+
f"fold {fold + 1}/{n_folds} n={len(test)} y={y[test]} "
|
| 147 |
+
f"p={np.round(oof[test], 3)} ({time.perf_counter() - start:.0f}s)"
|
| 148 |
+
)
|
| 149 |
+
return y, oof
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def score(
|
| 153 |
+
y: np.ndarray, oof: np.ndarray, seed: int = 0, n_boot: int = 2000, alpha: float = 0.05
|
| 154 |
+
) -> dict:
|
| 155 |
+
"""AUROC, the challenge metric, plus a percentile CI resampling subjects with replacement."""
|
| 156 |
+
rng = np.random.default_rng(seed)
|
| 157 |
+
samples = []
|
| 158 |
+
for _ in range(n_boot):
|
| 159 |
+
rows = rng.integers(0, len(y), size=len(y))
|
| 160 |
+
if len(np.unique(y[rows])) < 2:
|
| 161 |
+
continue
|
| 162 |
+
samples.append(roc_auc_score(y[rows], oof[rows]))
|
| 163 |
+
|
| 164 |
+
low, high = np.percentile(samples, [100 * alpha / 2, 100 * (1 - alpha / 2)])
|
| 165 |
+
return {
|
| 166 |
+
"auroc": float(roc_auc_score(y, oof)),
|
| 167 |
+
"auroc_ci_low": float(low),
|
| 168 |
+
"auroc_ci_high": float(high),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ---- entrypoints ------------------------------------------------------------------------
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def train(args: argparse.Namespace) -> None:
|
| 176 |
+
# imported here, not at the top, so the container needs no dataset stack to run `predict`
|
| 177 |
+
from fomo_tune.datasets import load_fomo_task5
|
| 178 |
+
|
| 179 |
+
cfg = OmegaConf.merge(OmegaConf.structured(Config), OmegaConf.from_dotlist(args.overrides))
|
| 180 |
+
run_dir = Path(cfg.output_root) / cfg.name
|
| 181 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 182 |
+
|
| 183 |
+
setup_logging(run_dir)
|
| 184 |
+
set_seed(cfg.seed)
|
| 185 |
+
logger.info(f"run {cfg.name} (git {git_sha()})")
|
| 186 |
+
logger.info(f"config:\n{OmegaConf.to_yaml(cfg).rstrip()}")
|
| 187 |
+
OmegaConf.save(cfg, run_dir / "config.yaml")
|
| 188 |
+
|
| 189 |
+
rows = list(load_fomo_task5())
|
| 190 |
+
logger.info(f"dataset: {len(rows)} subjects, {sum(r['label'] for r in rows)} positive")
|
| 191 |
+
|
| 192 |
+
method = Task5Method(cfg)
|
| 193 |
+
start = time.perf_counter()
|
| 194 |
+
y, oof = cross_validate(rows, method)
|
| 195 |
+
run_time = time.perf_counter() - start
|
| 196 |
+
summary = score(y, oof)
|
| 197 |
+
|
| 198 |
+
# the shipped head sees all n subjects, so it is not any of the models scored above
|
| 199 |
+
method.fit(rows)
|
| 200 |
+
method.save(run_dir / "model")
|
| 201 |
+
|
| 202 |
+
record = {"name": cfg.name, **summary, "run_time": round(run_time, 1)}
|
| 203 |
+
(run_dir / "metrics.json").write_text(json.dumps(record) + "\n")
|
| 204 |
+
scores = " ".join(f"{k}={v:.4f}" for k, v in summary.items())
|
| 205 |
+
logger.info(f"result: {scores} ({run_time:.0f}s)")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def predict(args: argparse.Namespace) -> None:
|
| 209 |
+
"""The challenge contract: a t1 path in, one probability written to `--output`.
|
| 210 |
+
|
| 211 |
+
`/app/predict.py` in the container is a shim over this, so what scores the submission is the
|
| 212 |
+
code cross-validation already ran, not something generated at build time.
|
| 213 |
+
"""
|
| 214 |
+
overrides = {"device": args.device}
|
| 215 |
+
if args.ckpt_path:
|
| 216 |
+
overrides["ckpt_path"] = args.ckpt_path
|
| 217 |
+
method = Task5Method.load(args.model_dir, **overrides)
|
| 218 |
+
|
| 219 |
+
probability = method.predict({"t1w": nib.load(args.t1)})
|
| 220 |
+
|
| 221 |
+
args.output.write_text(f"{probability:.6f}\n")
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def main() -> None:
|
| 225 |
+
parser = argparse.ArgumentParser()
|
| 226 |
+
modes = parser.add_subparsers(required=True)
|
| 227 |
+
|
| 228 |
+
train_parser = modes.add_parser("train", help="cross-validate over the task, then fit and save")
|
| 229 |
+
train_parser.add_argument("overrides", nargs="*", help="config overrides, e.g. device=cpu")
|
| 230 |
+
train_parser.set_defaults(run=train)
|
| 231 |
+
|
| 232 |
+
predict_parser = modes.add_parser("predict", help="one subject, one probability")
|
| 233 |
+
predict_parser.add_argument("--t1", type=Path, required=True)
|
| 234 |
+
predict_parser.add_argument("--output", type=Path, required=True)
|
| 235 |
+
predict_parser.add_argument("--model-dir", type=Path, default=Path("/app/model"))
|
| 236 |
+
predict_parser.add_argument("--ckpt-path", help="overrides the trained config's backbone path")
|
| 237 |
+
predict_parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
|
| 238 |
+
predict_parser.set_defaults(run=predict)
|
| 239 |
+
|
| 240 |
+
args = parser.parse_args()
|
| 241 |
+
args.run(args)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
if __name__ == "__main__":
|
| 245 |
+
main()
|
fomo_tune_baseline/output/task3/build/fomo_tune/utils.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import random
|
| 3 |
+
import subprocess
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("fomo_tune")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def set_seed(seed: int) -> None:
|
| 14 |
+
random.seed(seed)
|
| 15 |
+
np.random.seed(seed)
|
| 16 |
+
torch.manual_seed(seed)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def git_sha() -> str:
|
| 20 |
+
kwargs = dict(cwd=Path(__file__).parent, capture_output=True, text=True, check=True)
|
| 21 |
+
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], **kwargs).stdout.strip()
|
| 22 |
+
dirty = subprocess.run(["git", "status", "--porcelain", "-uno"], **kwargs).stdout.strip()
|
| 23 |
+
return f"{sha}-dirty" if dirty else sha
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def setup_logging(run_dir: Path) -> None:
|
| 27 |
+
handlers = [logging.StreamHandler(sys.stdout), logging.FileHandler(run_dir / "log.txt")]
|
| 28 |
+
logger.setLevel(logging.INFO)
|
| 29 |
+
logger.handlers.clear()
|
| 30 |
+
for handler in handlers:
|
| 31 |
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S"))
|
| 32 |
+
logger.addHandler(handler)
|
| 33 |
+
logger.propagate = False
|
fomo_tune_baseline/output/task3/build/model/backbone.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:aacf2582e9464e7fd4bd3d5ed41a5b9aa770e5b04a24b2a82c1c4ba5fb5f6e7a
|
| 3 |
+
size 1389681124
|
fomo_tune_baseline/output/task3/build/model/config.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: task3
|
| 2 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 3 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 4 |
+
name: task3
|
| 5 |
+
device: cuda
|
| 6 |
+
seed: 4466
|
fomo_tune_baseline/output/task3/build/model/head.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e263a95e347fbaf86b4edc44717e55891711bf37a7a1309c7f04753af46a4dbf
|
| 3 |
+
size 29906
|
fomo_tune_baseline/output/task3/build/predict.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
from fomo_tune.main_task3 import main
|
| 5 |
+
|
| 6 |
+
sys.argv = [
|
| 7 |
+
sys.argv[0],
|
| 8 |
+
"predict",
|
| 9 |
+
*sys.argv[1:],
|
| 10 |
+
"--model-dir",
|
| 11 |
+
"/app/model",
|
| 12 |
+
"--ckpt-path",
|
| 13 |
+
"/app/model/backbone.pth",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
main()
|
fomo_tune_baseline/output/task3/build/smri_mae/config/default_pretrain.yaml
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Name of the run. Used for output directory suffix and wandb.
|
| 2 |
+
name: pretrain
|
| 3 |
+
|
| 4 |
+
# Description of the run. Goes in wandb notes.
|
| 5 |
+
notes: null
|
| 6 |
+
|
| 7 |
+
# Root output directory.
|
| 8 |
+
# The run writes to checkpoints/<name> when name is set.
|
| 9 |
+
output_dir: checkpoints
|
| 10 |
+
|
| 11 |
+
# Standard 3D structural MRI volume size.
|
| 12 |
+
img_size: [208, 240, 208]
|
| 13 |
+
patch_size: 8
|
| 14 |
+
|
| 15 |
+
# Masking.
|
| 16 |
+
mask_ratio: 0.80
|
| 17 |
+
pred_mask_ratio: null
|
| 18 |
+
pad_to_multiple: 32
|
| 19 |
+
|
| 20 |
+
# Model.
|
| 21 |
+
model: mae_vit_large
|
| 22 |
+
model_kwargs:
|
| 23 |
+
# target normalization: null/none, global, slice, or patch.
|
| 24 |
+
target_norm: none
|
| 25 |
+
|
| 26 |
+
no_decode_pos: false
|
| 27 |
+
mask_drop_scale: false
|
| 28 |
+
|
| 29 |
+
class_token: true
|
| 30 |
+
reg_tokens: 0
|
| 31 |
+
no_embed_class: false
|
| 32 |
+
|
| 33 |
+
decoder_depth: 4
|
| 34 |
+
drop_path_rate: 0.0
|
| 35 |
+
|
| 36 |
+
# Datasets.
|
| 37 |
+
datasets:
|
| 38 |
+
fomo_train:
|
| 39 |
+
url: datasets/FOMO_with_dwi/shard.{000000..001620}.tar
|
| 40 |
+
samples_per_epoch: 243200
|
| 41 |
+
shuffle: true
|
| 42 |
+
buffer_size: 8000
|
| 43 |
+
drop_last: true
|
| 44 |
+
|
| 45 |
+
fomo_val:
|
| 46 |
+
url: datasets/FOMO_with_dwi/shard.{001621..001800}.tar
|
| 47 |
+
samples_per_epoch: 26880
|
| 48 |
+
shuffle: false
|
| 49 |
+
buffer_size: 1000
|
| 50 |
+
drop_last: true
|
| 51 |
+
|
| 52 |
+
train_dataset: fomo_train
|
| 53 |
+
eval_datasets:
|
| 54 |
+
- fomo_val
|
| 55 |
+
|
| 56 |
+
# Data loader.
|
| 57 |
+
num_workers: 4
|
| 58 |
+
prefetch_factor: 2
|
| 59 |
+
presend_cuda: true
|
| 60 |
+
|
| 61 |
+
# Optimization.
|
| 62 |
+
epochs: 100
|
| 63 |
+
batch_size: 64
|
| 64 |
+
accum_iter: 1
|
| 65 |
+
|
| 66 |
+
base_lr: 0.001
|
| 67 |
+
min_lr: 1e-6
|
| 68 |
+
warmup_epochs: 10
|
| 69 |
+
weight_decay: 0.05
|
| 70 |
+
betas: [0.9, 0.95]
|
| 71 |
+
clip_grad: 1.0
|
| 72 |
+
|
| 73 |
+
amp: true
|
| 74 |
+
amp_dtype: bfloat16
|
| 75 |
+
|
| 76 |
+
# Checkpointing.
|
| 77 |
+
ckpt: null
|
| 78 |
+
resume: false
|
| 79 |
+
auto_resume: true
|
| 80 |
+
start_epoch: 0
|
| 81 |
+
checkpoint_period: 10
|
| 82 |
+
max_checkpoints: 5
|
| 83 |
+
|
| 84 |
+
# Evaluation.
|
| 85 |
+
eval_period: 10
|
| 86 |
+
|
| 87 |
+
# Sync checkpoints to an R2 bucket using the AWS CLI. Set to a URL to enable.
|
| 88 |
+
r2_sync: null
|
| 89 |
+
|
| 90 |
+
device: cuda
|
| 91 |
+
distributed: false
|
| 92 |
+
seed: 7338
|
| 93 |
+
eval_seed: 7338
|
| 94 |
+
debug: false
|
| 95 |
+
|
| 96 |
+
wandb: false
|
| 97 |
+
wandb_entity: null
|
| 98 |
+
wandb_project: smri-fm
|
fomo_tune_baseline/output/task3/build/smri_mae/main_pretrain.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# deit: https://github.com/facebookresearch/deit/blob/main/main.py
|
| 6 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/train_capi.py
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import datetime
|
| 10 |
+
import json
|
| 11 |
+
import math
|
| 12 |
+
import random
|
| 13 |
+
import subprocess
|
| 14 |
+
import time
|
| 15 |
+
from contextlib import nullcontext
|
| 16 |
+
from functools import partial
|
| 17 |
+
from itertools import islice
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Iterable, Sequence
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn as nn
|
| 23 |
+
import wandb
|
| 24 |
+
import webdataset as wds
|
| 25 |
+
from omegaconf import DictConfig, OmegaConf
|
| 26 |
+
from PIL import Image
|
| 27 |
+
|
| 28 |
+
from matplotlib import pyplot as plt
|
| 29 |
+
from torch import Tensor
|
| 30 |
+
|
| 31 |
+
import data.mri_data as mri_data
|
| 32 |
+
import smri_mae.model_mae as models_mae
|
| 33 |
+
import smri_mae.utils as ut
|
| 34 |
+
import smri_mae.visualization as vis
|
| 35 |
+
|
| 36 |
+
DEFAULT_CONFIG = Path(__file__).parent / "config/default_pretrain.yaml"
|
| 37 |
+
|
| 38 |
+
MODELS_DICT = models_mae.__dict__
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main(args: DictConfig):
|
| 42 |
+
# setup
|
| 43 |
+
ut.init_distributed_mode(args)
|
| 44 |
+
global_rank = ut.get_rank()
|
| 45 |
+
is_master = global_rank == 0
|
| 46 |
+
world_size = ut.get_world_size()
|
| 47 |
+
device = torch.device(args.device)
|
| 48 |
+
ut.configure_flash_sdpa()
|
| 49 |
+
ut.random_seed(args.seed, rank=global_rank)
|
| 50 |
+
|
| 51 |
+
if args.name and not args.output_dir.endswith(args.name):
|
| 52 |
+
args.output_dir = f"{args.output_dir}/{args.name}"
|
| 53 |
+
output_dir = Path(args.output_dir)
|
| 54 |
+
|
| 55 |
+
if is_master:
|
| 56 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
out_cfg_path = output_dir / "config.yaml"
|
| 58 |
+
if out_cfg_path.exists():
|
| 59 |
+
prev_cfg = OmegaConf.load(out_cfg_path)
|
| 60 |
+
assert args == prev_cfg, "current config doesn't match previous config"
|
| 61 |
+
else:
|
| 62 |
+
OmegaConf.save(args, out_cfg_path)
|
| 63 |
+
|
| 64 |
+
if args.wandb:
|
| 65 |
+
wandb.init(
|
| 66 |
+
entity=args.wandb_entity,
|
| 67 |
+
project=args.wandb_project,
|
| 68 |
+
name=args.name,
|
| 69 |
+
notes=args.notes,
|
| 70 |
+
config=OmegaConf.to_container(args),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
ut.setup_for_distributed(log_path=output_dir / "log.txt")
|
| 74 |
+
|
| 75 |
+
print("pretraining 3D ViTMAE")
|
| 76 |
+
print(f"start: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 77 |
+
print(f"cwd: {Path.cwd()}")
|
| 78 |
+
print(ut.get_sha())
|
| 79 |
+
print("config:", OmegaConf.to_yaml(args), sep="\n")
|
| 80 |
+
|
| 81 |
+
# data loaders
|
| 82 |
+
train_loader, eval_loaders = create_data_loaders(args)
|
| 83 |
+
|
| 84 |
+
# model
|
| 85 |
+
model = MODELS_DICT[args.model](
|
| 86 |
+
img_size=args.img_size,
|
| 87 |
+
in_chans=args.get("in_chans", 1),
|
| 88 |
+
patch_size=args.patch_size,
|
| 89 |
+
**(args.get("model_kwargs") or {}),
|
| 90 |
+
)
|
| 91 |
+
model.to(device)
|
| 92 |
+
print("model:", model, sep="\n")
|
| 93 |
+
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 94 |
+
print(f"num params: {num_params / 1e6:.1f}M")
|
| 95 |
+
|
| 96 |
+
model_without_ddp = model
|
| 97 |
+
if args.distributed:
|
| 98 |
+
model = torch.nn.parallel.DistributedDataParallel(
|
| 99 |
+
model,
|
| 100 |
+
device_ids=[args.gpu],
|
| 101 |
+
gradient_as_bucket_view=True,
|
| 102 |
+
)
|
| 103 |
+
model_without_ddp = model.module
|
| 104 |
+
|
| 105 |
+
# optimizer
|
| 106 |
+
total_batch_size = args.batch_size * args.accum_iter * world_size
|
| 107 |
+
print(
|
| 108 |
+
f"total batch size: {total_batch_size} = "
|
| 109 |
+
f"{args.batch_size} bs per gpu x {args.accum_iter} accum x {world_size} gpus"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if not args.get("lr"):
|
| 113 |
+
args.lr = args.base_lr * total_batch_size / 256
|
| 114 |
+
print(f"lr: {args.lr:.2e} = {args.base_lr:.2e} x {total_batch_size} / 256")
|
| 115 |
+
else:
|
| 116 |
+
print(f"lr: {args.lr:.2e}")
|
| 117 |
+
|
| 118 |
+
param_groups = ut.get_param_groups(model)
|
| 119 |
+
ut.update_lr(param_groups, args.lr)
|
| 120 |
+
ut.update_wd(param_groups, args.weight_decay)
|
| 121 |
+
# cast or else it corrupts the checkpoint
|
| 122 |
+
betas = tuple(args.betas) if args.betas is not None else None
|
| 123 |
+
optimizer = torch.optim.AdamW(param_groups, betas=betas, fused=True)
|
| 124 |
+
|
| 125 |
+
epoch_num_batches = len(train_loader)
|
| 126 |
+
steps_per_epoch = math.ceil(epoch_num_batches / args.accum_iter)
|
| 127 |
+
total_steps = args.epochs * steps_per_epoch
|
| 128 |
+
warmup_steps = args.warmup_epochs * steps_per_epoch
|
| 129 |
+
lr_schedule = ut.WarmupThenCosine(
|
| 130 |
+
base_value=args.lr,
|
| 131 |
+
final_value=args.min_lr,
|
| 132 |
+
total_iters=total_steps,
|
| 133 |
+
warmup_iters=warmup_steps,
|
| 134 |
+
)
|
| 135 |
+
print(f"full schedule: epochs = {args.epochs} (steps = {total_steps})")
|
| 136 |
+
print(f"warmup: epochs = {args.warmup_epochs} (steps = {warmup_steps})")
|
| 137 |
+
|
| 138 |
+
# loss scaling not needed for bfloat16 (according to timm)
|
| 139 |
+
if args.amp and args.amp_dtype != "bfloat16":
|
| 140 |
+
loss_scaler = torch.GradScaler(device.type)
|
| 141 |
+
else:
|
| 142 |
+
loss_scaler = None
|
| 143 |
+
|
| 144 |
+
# load checkpoint/resume training
|
| 145 |
+
ut.load_model(args, model_without_ddp, optimizer, loss_scaler)
|
| 146 |
+
|
| 147 |
+
print(f"start training for {args.epochs} epochs")
|
| 148 |
+
start_time = time.monotonic()
|
| 149 |
+
for epoch in range(args.start_epoch, args.epochs):
|
| 150 |
+
train_stats = train_one_epoch(
|
| 151 |
+
args,
|
| 152 |
+
model,
|
| 153 |
+
train_loader,
|
| 154 |
+
optimizer,
|
| 155 |
+
loss_scaler,
|
| 156 |
+
lr_schedule,
|
| 157 |
+
epoch,
|
| 158 |
+
device,
|
| 159 |
+
)
|
| 160 |
+
eval_stats = {}
|
| 161 |
+
eval_plots = {}
|
| 162 |
+
eval_period = args.get("eval_period", 1)
|
| 163 |
+
if eval_period and (epoch % eval_period == 0 or epoch == args.epochs - 1):
|
| 164 |
+
for name, loader in eval_loaders.items():
|
| 165 |
+
stats, plots = evaluate(
|
| 166 |
+
args,
|
| 167 |
+
model,
|
| 168 |
+
loader,
|
| 169 |
+
epoch,
|
| 170 |
+
device,
|
| 171 |
+
eval_name=name,
|
| 172 |
+
)
|
| 173 |
+
eval_stats.update(stats)
|
| 174 |
+
eval_plots.update(plots)
|
| 175 |
+
|
| 176 |
+
merged_stats = {"epoch": epoch, **train_stats, **eval_stats}
|
| 177 |
+
if is_master:
|
| 178 |
+
with (output_dir / "log.json").open("a") as f:
|
| 179 |
+
print(json.dumps(merged_stats), file=f)
|
| 180 |
+
|
| 181 |
+
for plot_name, img in eval_plots.items():
|
| 182 |
+
plot_name = plot_name.replace("/", "__")
|
| 183 |
+
img.save(output_dir / f"{plot_name}__{epoch:05d}.png")
|
| 184 |
+
|
| 185 |
+
ut.save_model(args, epoch, model_without_ddp, optimizer, loss_scaler)
|
| 186 |
+
sync_checkpoints_to_r2(args, output_dir)
|
| 187 |
+
|
| 188 |
+
if args.distributed:
|
| 189 |
+
torch.distributed.destroy_process_group()
|
| 190 |
+
|
| 191 |
+
total_time = time.monotonic() - start_time
|
| 192 |
+
print(f"done! training time: {datetime.timedelta(seconds=int(total_time))}")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def create_data_loaders(args: DictConfig):
|
| 196 |
+
data_loaders = {}
|
| 197 |
+
dataset_names = [args.train_dataset] + args.eval_datasets
|
| 198 |
+
|
| 199 |
+
for dataset_name in dataset_names:
|
| 200 |
+
dataset_config = args.datasets[dataset_name].copy()
|
| 201 |
+
drop_last = dataset_config.pop("drop_last")
|
| 202 |
+
is_train = dataset_name == args.train_dataset
|
| 203 |
+
|
| 204 |
+
print(f"loading dataset: {dataset_name}\n\n{OmegaConf.to_yaml(dataset_config)}")
|
| 205 |
+
shuffle = dataset_config["shuffle"]
|
| 206 |
+
samples_per_epoch = dataset_config.pop("samples_per_epoch")
|
| 207 |
+
dataset = mri_data.make_sparse_wds_dataset(
|
| 208 |
+
dataset_config["url"],
|
| 209 |
+
shuffle=shuffle,
|
| 210 |
+
buffer_size=dataset_config["buffer_size"],
|
| 211 |
+
)
|
| 212 |
+
num_workers = int(args.num_workers)
|
| 213 |
+
loader_kwargs = {
|
| 214 |
+
"batch_size": args.batch_size,
|
| 215 |
+
"collate_fn": partial(mri_data.collate, include_meta=not is_train),
|
| 216 |
+
"shuffle": False,
|
| 217 |
+
"num_workers": num_workers,
|
| 218 |
+
"persistent_workers": num_workers > 0,
|
| 219 |
+
"pin_memory": True,
|
| 220 |
+
"drop_last": drop_last,
|
| 221 |
+
"prefetch_factor": args.prefetch_factor,
|
| 222 |
+
}
|
| 223 |
+
loader = wds.WebLoader(dataset, **loader_kwargs)
|
| 224 |
+
num_batches = samples_per_epoch // (ut.get_world_size() * args.batch_size)
|
| 225 |
+
loader = loader.with_epoch(num_batches)
|
| 226 |
+
loader = loader.with_length(num_batches, silent=True)
|
| 227 |
+
|
| 228 |
+
data_loaders[dataset_name] = loader
|
| 229 |
+
|
| 230 |
+
train_loader = data_loaders.pop(args.train_dataset)
|
| 231 |
+
return train_loader, data_loaders
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def sync_checkpoints_to_r2(args: DictConfig, output_dir: Path) -> None:
|
| 235 |
+
r2_sync_url = args.get("r2_sync")
|
| 236 |
+
if not r2_sync_url or not ut.is_main_process():
|
| 237 |
+
return
|
| 238 |
+
|
| 239 |
+
cmd = ["aws", "s3", "sync", str(output_dir), str(r2_sync_url), "--profile", "r2"]
|
| 240 |
+
print(f"syncing checkpoints to R2: {output_dir} -> {r2_sync_url}")
|
| 241 |
+
subprocess.run(cmd, check=True)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def train_one_epoch(
|
| 245 |
+
args: DictConfig,
|
| 246 |
+
model: nn.Module,
|
| 247 |
+
data_loader: Iterable,
|
| 248 |
+
optimizer: torch.optim.Optimizer,
|
| 249 |
+
loss_scaler: torch.GradScaler | None,
|
| 250 |
+
lr_schedule: Sequence[float],
|
| 251 |
+
epoch: int,
|
| 252 |
+
device: torch.device,
|
| 253 |
+
):
|
| 254 |
+
model.train()
|
| 255 |
+
|
| 256 |
+
metric_logger = ut.MetricLogger(delimiter=" ")
|
| 257 |
+
metric_logger.add_meter("lr", ut.SmoothedValue(window_size=1, fmt="{value:.6f}"))
|
| 258 |
+
metric_logger.add_meter("grad", ut.SmoothedValue())
|
| 259 |
+
header = f"Train: [{epoch}]"
|
| 260 |
+
log_wandb = args.wandb and ut.is_main_process()
|
| 261 |
+
|
| 262 |
+
epoch_num_batches = len(data_loader)
|
| 263 |
+
steps_per_epoch = math.ceil(epoch_num_batches / args.accum_iter)
|
| 264 |
+
|
| 265 |
+
print_freq = args.get("print_freq", 100) if not args.debug else 1
|
| 266 |
+
num_batches = epoch_num_batches if not args.debug else 10
|
| 267 |
+
amp_dtype = getattr(torch, args.amp_dtype)
|
| 268 |
+
use_cuda = device.type == "cuda"
|
| 269 |
+
if use_cuda and args.presend_cuda:
|
| 270 |
+
data_loader = ut.pre_send_to_cuda_wrapper(
|
| 271 |
+
data_loader, device, dtype_map={torch.float16: amp_dtype}
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
optimizer.zero_grad()
|
| 275 |
+
|
| 276 |
+
for batch_idx, batch in enumerate(
|
| 277 |
+
metric_logger.log_every(data_loader, print_freq, header, total_steps=num_batches)
|
| 278 |
+
):
|
| 279 |
+
if use_cuda and not args.presend_cuda:
|
| 280 |
+
batch = ut.send_data(batch, device, dtype_map={torch.float16: amp_dtype})
|
| 281 |
+
|
| 282 |
+
batch_step = batch_idx + 1
|
| 283 |
+
log_step = batch_step % print_freq == 0 or batch_step == num_batches
|
| 284 |
+
update_in_epoch = batch_idx // args.accum_iter
|
| 285 |
+
group_size = min(args.accum_iter, num_batches - update_in_epoch * args.accum_iter)
|
| 286 |
+
need_update = batch_step % args.accum_iter == 0 or batch_step == num_batches
|
| 287 |
+
global_step = epoch * steps_per_epoch + update_in_epoch
|
| 288 |
+
lr = lr_schedule[global_step]
|
| 289 |
+
if need_update:
|
| 290 |
+
ut.update_lr(optimizer.param_groups, lr)
|
| 291 |
+
|
| 292 |
+
images, img_mask = mri_data.densify_sparse_image_batch(
|
| 293 |
+
batch["image_values"],
|
| 294 |
+
batch["img_mask"],
|
| 295 |
+
(int(args.get("in_chans", 1)), *args.img_size),
|
| 296 |
+
dtype=amp_dtype,
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
sync_context = model.no_sync() if args.distributed and not need_update else nullcontext()
|
| 300 |
+
with sync_context:
|
| 301 |
+
with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=args.amp):
|
| 302 |
+
loss = model(
|
| 303 |
+
images,
|
| 304 |
+
img_mask=img_mask,
|
| 305 |
+
mask_ratio=args.mask_ratio,
|
| 306 |
+
pred_mask_ratio=args.pred_mask_ratio,
|
| 307 |
+
pad_to_multiple=args.pad_to_multiple,
|
| 308 |
+
with_state=False,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
loss_for_log = loss.detach()
|
| 312 |
+
torch._assert_async(torch.isfinite(loss_for_log), "non-finite loss")
|
| 313 |
+
|
| 314 |
+
grad_norm = ut.backward_step(
|
| 315 |
+
loss / group_size,
|
| 316 |
+
optimizer,
|
| 317 |
+
scaler=loss_scaler,
|
| 318 |
+
need_update=need_update,
|
| 319 |
+
max_norm=args.clip_grad,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
if need_update and log_step:
|
| 323 |
+
loss_value = loss_for_log.item()
|
| 324 |
+
grad_norm_value = grad_norm.item()
|
| 325 |
+
metric_logger.update(loss=loss_value, lr=lr, grad=grad_norm_value)
|
| 326 |
+
if log_wandb:
|
| 327 |
+
wandb.log(
|
| 328 |
+
{
|
| 329 |
+
"train/loss": loss_value,
|
| 330 |
+
"train/lr": lr,
|
| 331 |
+
"train/grad": grad_norm_value,
|
| 332 |
+
},
|
| 333 |
+
step=int(1000 * (epoch + batch_step / epoch_num_batches)),
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
# gather the stats from all processes
|
| 337 |
+
metric_logger.synchronize_between_processes()
|
| 338 |
+
print("Averaged stats:", metric_logger)
|
| 339 |
+
return {f"train/{k}": meter.global_avg for k, meter in metric_logger.meters.items()}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@torch.inference_mode()
|
| 343 |
+
def evaluate(
|
| 344 |
+
args: DictConfig,
|
| 345 |
+
model: nn.Module,
|
| 346 |
+
data_loader: Iterable,
|
| 347 |
+
epoch: int,
|
| 348 |
+
device: torch.device,
|
| 349 |
+
eval_name: str,
|
| 350 |
+
):
|
| 351 |
+
model.eval()
|
| 352 |
+
|
| 353 |
+
metric_logger = ut.MetricLogger(delimiter=" ")
|
| 354 |
+
header = f"Eval ({eval_name}): [{epoch}]"
|
| 355 |
+
is_master = ut.is_main_process()
|
| 356 |
+
log_wandb = args.wandb and is_master
|
| 357 |
+
|
| 358 |
+
epoch_num_batches = len(data_loader)
|
| 359 |
+
if epoch_num_batches <= 0:
|
| 360 |
+
raise ValueError(f"eval loader {eval_name!r} has zero batches")
|
| 361 |
+
|
| 362 |
+
print_freq = args.get("print_freq", 100) if not args.debug else 1
|
| 363 |
+
num_batches = epoch_num_batches if not args.debug else 10
|
| 364 |
+
num_batches = min(num_batches, epoch_num_batches)
|
| 365 |
+
eval_seed = int(args.get("eval_seed", args.seed)) + ut.get_rank()
|
| 366 |
+
example_step = random.Random(eval_seed).randint(1, num_batches)
|
| 367 |
+
amp_dtype = getattr(torch, args.amp_dtype)
|
| 368 |
+
use_cuda = device.type == "cuda"
|
| 369 |
+
rng_state = ut.capture_rng_state()
|
| 370 |
+
torch.set_rng_state(torch.Generator().manual_seed(eval_seed).get_state())
|
| 371 |
+
if use_cuda:
|
| 372 |
+
torch.cuda.manual_seed(eval_seed)
|
| 373 |
+
if use_cuda and args.presend_cuda:
|
| 374 |
+
data_loader = ut.pre_send_to_cuda_wrapper(
|
| 375 |
+
data_loader, device, dtype_map={torch.float16: amp_dtype}
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
eval_batches = islice(data_loader, num_batches)
|
| 379 |
+
for batch_idx, batch in enumerate(
|
| 380 |
+
metric_logger.log_every(eval_batches, print_freq, header, total_steps=num_batches)
|
| 381 |
+
):
|
| 382 |
+
if use_cuda and not args.presend_cuda:
|
| 383 |
+
batch = ut.send_data(batch, device, dtype_map={torch.float16: amp_dtype})
|
| 384 |
+
|
| 385 |
+
batch_step = batch_idx + 1
|
| 386 |
+
|
| 387 |
+
images, img_mask = mri_data.densify_sparse_image_batch(
|
| 388 |
+
batch["image_values"],
|
| 389 |
+
batch["img_mask"],
|
| 390 |
+
(int(args.get("in_chans", 1)), *args.img_size),
|
| 391 |
+
dtype=amp_dtype,
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
with torch.autocast(device_type=device.type, dtype=amp_dtype, enabled=args.amp):
|
| 395 |
+
loss, state = model(
|
| 396 |
+
images,
|
| 397 |
+
img_mask=img_mask,
|
| 398 |
+
mask_ratio=args.mask_ratio,
|
| 399 |
+
pred_mask_ratio=args.pred_mask_ratio,
|
| 400 |
+
pad_to_multiple=args.pad_to_multiple,
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
loss_value = loss.detach().float().item()
|
| 404 |
+
finite = torch.tensor(int(math.isfinite(loss_value)), dtype=torch.int32, device=device)
|
| 405 |
+
if args.distributed:
|
| 406 |
+
torch.distributed.all_reduce(finite, op=torch.distributed.ReduceOp.MIN)
|
| 407 |
+
if not finite.item():
|
| 408 |
+
raise RuntimeError("non-finite validation loss detected")
|
| 409 |
+
metric_logger.meters["loss"].update(loss_value, n=int(batch["img_mask"].shape[0]))
|
| 410 |
+
|
| 411 |
+
if is_master and batch_step == example_step:
|
| 412 |
+
example_batch = {"image": images[:1], "img_mask": img_mask[:1]}
|
| 413 |
+
if "meta" in batch:
|
| 414 |
+
example_batch["meta"] = batch["meta"][:1]
|
| 415 |
+
example_state = {
|
| 416 |
+
"pred_images": state["pred_images"][:1],
|
| 417 |
+
"pred_mask": state["pred_mask"][:1],
|
| 418 |
+
}
|
| 419 |
+
example_data = {
|
| 420 |
+
"batch": ut.send_data(example_batch, "cpu"),
|
| 421 |
+
"state": ut.send_data(example_state, "cpu"),
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
# gather the stats from all processes
|
| 425 |
+
metric_logger.synchronize_between_processes()
|
| 426 |
+
print(f"Averaged stats ({eval_name}):", metric_logger)
|
| 427 |
+
stats = {f"eval/{eval_name}/{k}": meter.global_avg for k, meter in metric_logger.meters.items()}
|
| 428 |
+
|
| 429 |
+
plots = {}
|
| 430 |
+
if is_master:
|
| 431 |
+
print(f"Making plots ({eval_name}): example={example_step}")
|
| 432 |
+
plots = make_plots(args, **example_data)
|
| 433 |
+
plots = {f"eval/{eval_name}/{k}": img for k, img in plots.items()}
|
| 434 |
+
|
| 435 |
+
if log_wandb:
|
| 436 |
+
wandb.log(stats, step=1000 * (epoch + 1))
|
| 437 |
+
wandb.log(
|
| 438 |
+
{k: wandb.Image(img, caption=f"example={example_step}") for k, img in plots.items()},
|
| 439 |
+
step=1000 * (epoch + 1),
|
| 440 |
+
)
|
| 441 |
+
ut.restore_rng_state(rng_state)
|
| 442 |
+
return stats, plots
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def make_plots(
|
| 446 |
+
args: DictConfig,
|
| 447 |
+
batch: dict[str, Tensor],
|
| 448 |
+
state: dict[str, Tensor],
|
| 449 |
+
) -> dict[str, Image.Image]:
|
| 450 |
+
fig_kwargs = args.get("fig_kwargs", {})
|
| 451 |
+
|
| 452 |
+
images = batch["image"]
|
| 453 |
+
img_mask = batch.get("img_mask")
|
| 454 |
+
if img_mask is not None:
|
| 455 |
+
img_mask = img_mask.expand_as(images)
|
| 456 |
+
|
| 457 |
+
raw_mean, raw_std = vis.raw_stats_from_batch(batch)
|
| 458 |
+
|
| 459 |
+
plots = {}
|
| 460 |
+
mask_pred_fig = vis.plot_mask_pred(
|
| 461 |
+
target=images,
|
| 462 |
+
pred=state["pred_images"],
|
| 463 |
+
pred_mask=state["pred_mask"],
|
| 464 |
+
img_mask=img_mask,
|
| 465 |
+
patch_size=args.patch_size,
|
| 466 |
+
raw_mean=raw_mean,
|
| 467 |
+
raw_std=raw_std,
|
| 468 |
+
**ut.filter_kwargs(vis.plot_mask_pred, fig_kwargs),
|
| 469 |
+
)
|
| 470 |
+
plots["mask_pred"] = vis.fig2pil(mask_pred_fig)
|
| 471 |
+
plt.close(mask_pred_fig)
|
| 472 |
+
|
| 473 |
+
return plots
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
if __name__ == "__main__":
|
| 477 |
+
parser = argparse.ArgumentParser()
|
| 478 |
+
parser.add_argument("--cfg-path", type=str, default=None)
|
| 479 |
+
parser.add_argument("--overrides", type=str, default=None, nargs="+")
|
| 480 |
+
args = parser.parse_args()
|
| 481 |
+
cfg = OmegaConf.load(DEFAULT_CONFIG)
|
| 482 |
+
if args.cfg_path:
|
| 483 |
+
cfg = OmegaConf.unsafe_merge(cfg, OmegaConf.load(args.cfg_path))
|
| 484 |
+
if args.overrides:
|
| 485 |
+
cfg = OmegaConf.unsafe_merge(cfg, OmegaConf.from_dotlist(args.overrides))
|
| 486 |
+
main(cfg)
|
fomo_tune_baseline/output/task3/build/smri_mae/masking.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from jaxtyping import Float, Int
|
| 3 |
+
from torch import Tensor
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def pad_patch_mask(
|
| 7 |
+
patch_mask: Float[Tensor, "B N"],
|
| 8 |
+
mask_ratio: float,
|
| 9 |
+
shuffle: bool = False,
|
| 10 |
+
generator: torch.Generator | None = None,
|
| 11 |
+
pad_to_multiple: int | None = None,
|
| 12 |
+
) -> tuple[Float[Tensor, "B N"], Int[Tensor, "B L"], Tensor]:
|
| 13 |
+
"""
|
| 14 |
+
Select each row's own mask-ratio count, then pad ids to the batch max length.
|
| 15 |
+
|
| 16 |
+
Returns:
|
| 17 |
+
- selected patch mask [B, N]
|
| 18 |
+
- padded selected patch ids [B, Lpad]
|
| 19 |
+
- token mask [B, Lpad], true for real ids and false for padding
|
| 20 |
+
"""
|
| 21 |
+
if not 0.0 <= mask_ratio <= 1.0:
|
| 22 |
+
raise ValueError(f"mask_ratio must be in [0, 1], got {mask_ratio}")
|
| 23 |
+
|
| 24 |
+
B, N = patch_mask.shape
|
| 25 |
+
device = patch_mask.device
|
| 26 |
+
patch_mask = patch_mask.to(dtype=torch.bool)
|
| 27 |
+
|
| 28 |
+
valid_counts = patch_mask.sum(dim=1)
|
| 29 |
+
num_keep = torch.floor(valid_counts.to(torch.float64) * (1.0 - mask_ratio)).to(torch.long)
|
| 30 |
+
if not shuffle:
|
| 31 |
+
selected = patch_mask & (patch_mask.cumsum(dim=1) <= num_keep.unsqueeze(1))
|
| 32 |
+
padded_ids, token_mask = patch_ids_from_mask(
|
| 33 |
+
selected,
|
| 34 |
+
pad_to_multiple=pad_to_multiple,
|
| 35 |
+
)
|
| 36 |
+
return selected, padded_ids, token_mask
|
| 37 |
+
|
| 38 |
+
# One masked sort directly produces random valid IDs. The previous
|
| 39 |
+
# shuffle/select/inverse-shuffle path required two full argsorts plus a
|
| 40 |
+
# dynamic nonzero/scatter solely to recover the same selected set.
|
| 41 |
+
noise = torch.rand(B, N, generator=generator, device=device)
|
| 42 |
+
noise.masked_fill_(~patch_mask, torch.inf)
|
| 43 |
+
shuffled_ids = torch.argsort(noise, dim=1)
|
| 44 |
+
|
| 45 |
+
max_count = int(num_keep.max().item())
|
| 46 |
+
if pad_to_multiple is not None:
|
| 47 |
+
if pad_to_multiple <= 0:
|
| 48 |
+
raise ValueError(f"pad_to_multiple must be positive, got {pad_to_multiple}")
|
| 49 |
+
max_count = (max_count + pad_to_multiple - 1) // pad_to_multiple * pad_to_multiple
|
| 50 |
+
padded_ids = shuffled_ids[:, :max_count]
|
| 51 |
+
token_mask = torch.arange(max_count, device=device).unsqueeze(0) < num_keep.unsqueeze(1)
|
| 52 |
+
selected = torch.zeros_like(patch_mask).scatter_(1, padded_ids, token_mask)
|
| 53 |
+
return selected, padded_ids, token_mask
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def patch_ids_from_mask(
|
| 57 |
+
patch_mask: Tensor,
|
| 58 |
+
pad_to_multiple: int | None = None,
|
| 59 |
+
) -> tuple[Int[Tensor, "B L"], Tensor]:
|
| 60 |
+
"""Return optionally rounded patch IDs and their token-validity mask."""
|
| 61 |
+
if pad_to_multiple is not None and pad_to_multiple <= 0:
|
| 62 |
+
raise ValueError(f"pad_to_multiple must be positive, got {pad_to_multiple}")
|
| 63 |
+
|
| 64 |
+
patch_mask = patch_mask.to(dtype=torch.bool)
|
| 65 |
+
B, N = patch_mask.shape
|
| 66 |
+
device = patch_mask.device
|
| 67 |
+
counts = patch_mask.sum(dim=1)
|
| 68 |
+
max_count = int(counts.max().item())
|
| 69 |
+
if pad_to_multiple is not None:
|
| 70 |
+
max_count = (max_count + pad_to_multiple - 1) // pad_to_multiple * pad_to_multiple
|
| 71 |
+
|
| 72 |
+
patch_ids = torch.zeros((B, max_count), dtype=torch.long, device=device)
|
| 73 |
+
token_mask = torch.arange(max_count, device=device).unsqueeze(0) < counts.unsqueeze(1)
|
| 74 |
+
if max_count == 0:
|
| 75 |
+
return patch_ids, token_mask
|
| 76 |
+
|
| 77 |
+
batch_ids, selected_ids = patch_mask.nonzero(as_tuple=True)
|
| 78 |
+
slot_ids = patch_mask.cumsum(dim=1)[batch_ids, selected_ids].to(torch.long) - 1
|
| 79 |
+
patch_ids[batch_ids, slot_ids] = selected_ids
|
| 80 |
+
return patch_ids, token_mask
|
fomo_tune_baseline/output/task3/build/smri_mae/model_mae.py
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/model.py
|
| 6 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/v1.0.20/timm/models/vision_transformer.py
|
| 7 |
+
|
| 8 |
+
"""
|
| 9 |
+
From-scratch re-implementation of the original MAE model.
|
| 10 |
+
|
| 11 |
+
MaskedEncoder: standard ViT with masking
|
| 12 |
+
MaskedDecoder: standard self-attention MAE decoder
|
| 13 |
+
MaskedAutoEncoderViT: full MAE model for 3D structural MRI volumes
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from collections.abc import Sequence
|
| 17 |
+
from typing import Literal
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
from torch import Tensor
|
| 23 |
+
from torch.utils.checkpoint import checkpoint
|
| 24 |
+
from huggingface_hub import PyTorchModelHubMixin
|
| 25 |
+
from jaxtyping import Float, Int
|
| 26 |
+
|
| 27 |
+
from .modules import (
|
| 28 |
+
AbsolutePosEmbed,
|
| 29 |
+
Block,
|
| 30 |
+
LayerNorm,
|
| 31 |
+
Normalize,
|
| 32 |
+
JaggedBatch,
|
| 33 |
+
Patchify3D,
|
| 34 |
+
SeparablePosEmbed,
|
| 35 |
+
SinCosPosEmbed3D,
|
| 36 |
+
unpack_tokens,
|
| 37 |
+
)
|
| 38 |
+
from .masking import pad_patch_mask
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class MaskedEncoder(nn.Module):
|
| 42 |
+
"""
|
| 43 |
+
Masked transformer encoder.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
patchify: nn.Module,
|
| 49 |
+
patch_embed: nn.Module,
|
| 50 |
+
pos_embed: nn.Module,
|
| 51 |
+
depth: int = 12,
|
| 52 |
+
embed_dim: int = 768,
|
| 53 |
+
num_heads: int = 12,
|
| 54 |
+
qkv_bias: bool = True,
|
| 55 |
+
proj_bias: bool = True,
|
| 56 |
+
mlp_ratio: int | float = 4,
|
| 57 |
+
class_token: bool = True,
|
| 58 |
+
reg_tokens: int = 0,
|
| 59 |
+
no_embed_class: bool = False,
|
| 60 |
+
final_norm: bool = True,
|
| 61 |
+
drop_path_rate: float = 0.0,
|
| 62 |
+
mask_drop_scale: bool = False,
|
| 63 |
+
):
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.num_prefix_tokens = int(class_token) + reg_tokens
|
| 66 |
+
self.num_reg_tokens = reg_tokens
|
| 67 |
+
self.has_class_token = class_token
|
| 68 |
+
self.no_embed_class = no_embed_class
|
| 69 |
+
|
| 70 |
+
# scale inputs by 1 / observed rate (like dropout)
|
| 71 |
+
self.mask_drop_scale = mask_drop_scale
|
| 72 |
+
|
| 73 |
+
# inject tokenization modules, so that the encoder doesn't specifically need to
|
| 74 |
+
# know how the data are tokenized, while still implementing a complete
|
| 75 |
+
# self-contained model.
|
| 76 |
+
self.patchify = patchify
|
| 77 |
+
self.patch_embed = patch_embed
|
| 78 |
+
self.pos_embed = pos_embed
|
| 79 |
+
|
| 80 |
+
R = reg_tokens
|
| 81 |
+
self.cls_token = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 82 |
+
self.reg_token = nn.Parameter(torch.empty(1, R, embed_dim)) if reg_tokens else None
|
| 83 |
+
|
| 84 |
+
if not no_embed_class:
|
| 85 |
+
self.cls_token_pos = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 86 |
+
self.reg_token_pos = nn.Parameter(torch.empty(1, R, embed_dim)) if reg_tokens else None
|
| 87 |
+
else:
|
| 88 |
+
self.cls_token_pos = self.reg_token_pos = None
|
| 89 |
+
|
| 90 |
+
# stochastic depth decay rule
|
| 91 |
+
dpr = [x.item() for x in torch.linspace(0, drop_path_rate, depth)]
|
| 92 |
+
|
| 93 |
+
self.blocks = nn.ModuleList(
|
| 94 |
+
[
|
| 95 |
+
Block(
|
| 96 |
+
dim=embed_dim,
|
| 97 |
+
num_heads=num_heads,
|
| 98 |
+
qkv_bias=qkv_bias,
|
| 99 |
+
proj_bias=proj_bias,
|
| 100 |
+
mlp_ratio=mlp_ratio,
|
| 101 |
+
drop_path=dpr[ii],
|
| 102 |
+
)
|
| 103 |
+
for ii in range(depth)
|
| 104 |
+
]
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
self.norm = LayerNorm(embed_dim) if final_norm else nn.Identity()
|
| 108 |
+
|
| 109 |
+
self.reset_parameters()
|
| 110 |
+
|
| 111 |
+
def extra_repr(self):
|
| 112 |
+
return (
|
| 113 |
+
f"class_token={self.has_class_token}, reg_tokens={self.num_reg_tokens}, "
|
| 114 |
+
f"no_embed_class={self.no_embed_class}, mask_drop_scale={self.mask_drop_scale}"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def reset_parameters(self) -> None:
|
| 118 |
+
for p in [self.cls_token, self.cls_token_pos, self.reg_token, self.reg_token_pos]:
|
| 119 |
+
if p is not None:
|
| 120 |
+
nn.init.trunc_normal_(p, std=0.02)
|
| 121 |
+
|
| 122 |
+
def cat_tokens(self, x: Tensor) -> Tensor:
|
| 123 |
+
# prepend cls and reg tokens with optional learned position embedding
|
| 124 |
+
# the cls and reg pos embedding is ofc redundant, but included in many other
|
| 125 |
+
# implementations.
|
| 126 |
+
B, _, _ = x.shape
|
| 127 |
+
|
| 128 |
+
to_cat = []
|
| 129 |
+
if self.has_class_token:
|
| 130 |
+
cls_token = self.cls_token
|
| 131 |
+
if not self.no_embed_class:
|
| 132 |
+
cls_token = cls_token + self.cls_token_pos
|
| 133 |
+
to_cat.append(cls_token.expand(B, -1, -1))
|
| 134 |
+
|
| 135 |
+
if self.num_reg_tokens:
|
| 136 |
+
reg_token = self.reg_token
|
| 137 |
+
if not self.no_embed_class:
|
| 138 |
+
reg_token = reg_token + self.reg_token_pos
|
| 139 |
+
to_cat.append(reg_token.expand(B, -1, -1))
|
| 140 |
+
|
| 141 |
+
if to_cat:
|
| 142 |
+
x = torch.cat(to_cat + [x], dim=1)
|
| 143 |
+
return x
|
| 144 |
+
|
| 145 |
+
def cat_token_mask(self, token_mask: Tensor, batch_size: int) -> Tensor:
|
| 146 |
+
if self.num_prefix_tokens:
|
| 147 |
+
prefix_mask = torch.ones(
|
| 148 |
+
(batch_size, self.num_prefix_tokens),
|
| 149 |
+
dtype=torch.bool,
|
| 150 |
+
device=token_mask.device,
|
| 151 |
+
)
|
| 152 |
+
token_mask = torch.cat([prefix_mask, token_mask], dim=1)
|
| 153 |
+
return token_mask
|
| 154 |
+
|
| 155 |
+
def chunk_tokens(self, x: Tensor) -> tuple[Tensor | None, Tensor | None, Tensor]:
|
| 156 |
+
cls_offset = int(self.has_class_token)
|
| 157 |
+
cls = x[:, :cls_offset] if self.has_class_token else None
|
| 158 |
+
if self.num_reg_tokens:
|
| 159 |
+
reg = x[:, cls_offset : self.num_prefix_tokens, :]
|
| 160 |
+
else:
|
| 161 |
+
reg = None
|
| 162 |
+
patch = x[:, self.num_prefix_tokens :, :]
|
| 163 |
+
return cls, reg, patch
|
| 164 |
+
|
| 165 |
+
def forward(
|
| 166 |
+
self,
|
| 167 |
+
x: Tensor,
|
| 168 |
+
mask: Tensor | None = None,
|
| 169 |
+
mask_ratio: float | None = None,
|
| 170 |
+
pad_to_multiple: int | None = None,
|
| 171 |
+
) -> tuple[
|
| 172 |
+
Float[Tensor, "B 1 D"] | None,
|
| 173 |
+
Float[Tensor, "B R D"] | None,
|
| 174 |
+
Float[Tensor, "B L D"],
|
| 175 |
+
Tensor | None,
|
| 176 |
+
Int[Tensor, "B L"] | None,
|
| 177 |
+
Tensor | None,
|
| 178 |
+
]:
|
| 179 |
+
"""
|
| 180 |
+
x: input data shape [B, C, D, H, W]
|
| 181 |
+
mask: visible mask, 1 = visible, 0 = invisible. broadcastable shape
|
| 182 |
+
mask_ratio: mask ratio for uniform random masking
|
| 183 |
+
|
| 184 |
+
returns:
|
| 185 |
+
- cls_embeds: [B, 1, D]
|
| 186 |
+
- reg_embeds: [B, R, D]
|
| 187 |
+
- patch_embeds: [B, L, D], where L is the number of visible patches
|
| 188 |
+
- mask: observed mask, 1 = observed, 0 = unobserved. same shape as input
|
| 189 |
+
- mask_ids: indices of visible patches [B L]
|
| 190 |
+
- token_mask: valid token mask for padded per-sample masking [B L]
|
| 191 |
+
"""
|
| 192 |
+
# apply mask to the input
|
| 193 |
+
if mask is not None:
|
| 194 |
+
mask = mask.to(device=x.device, dtype=torch.bool).expand_as(x)
|
| 195 |
+
x = x.masked_fill(~mask, 0)
|
| 196 |
+
|
| 197 |
+
# patchify input
|
| 198 |
+
x = self.patchify(x)
|
| 199 |
+
B, N, P = x.shape
|
| 200 |
+
|
| 201 |
+
# patchify mask and apply dropout style scaling
|
| 202 |
+
if mask is not None:
|
| 203 |
+
mask_patches = self.patchify(mask)
|
| 204 |
+
patch_num_obs = mask_patches.sum(dim=-1)
|
| 205 |
+
patch_mask = patch_num_obs > 0
|
| 206 |
+
if self.mask_drop_scale:
|
| 207 |
+
patch_num_obs = patch_num_obs.to(x.dtype)
|
| 208 |
+
x = x * (P / patch_num_obs.unsqueeze(-1).clamp(min=1.0))
|
| 209 |
+
elif mask_ratio is not None:
|
| 210 |
+
patch_mask = torch.ones((B, N), dtype=torch.bool, device=x.device)
|
| 211 |
+
mask_patches = patch_mask.unsqueeze(-1).expand(-1, -1, P)
|
| 212 |
+
else:
|
| 213 |
+
patch_mask = mask_patches = None
|
| 214 |
+
|
| 215 |
+
# patch and position embed
|
| 216 |
+
x = self.patch_embed(x)
|
| 217 |
+
x = self.pos_embed(x)
|
| 218 |
+
|
| 219 |
+
if mask is not None or mask_ratio is not None:
|
| 220 |
+
mask_ratio = 0.0 if mask_ratio is None else mask_ratio
|
| 221 |
+
patch_mask, mask_ids, token_mask = pad_patch_mask(
|
| 222 |
+
patch_mask,
|
| 223 |
+
mask_ratio=mask_ratio,
|
| 224 |
+
shuffle=mask_ratio > 0,
|
| 225 |
+
pad_to_multiple=pad_to_multiple,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
mask_patches = mask_patches & patch_mask.unsqueeze(-1)
|
| 229 |
+
mask = self.patchify.unpatchify(mask_patches)
|
| 230 |
+
x = x.gather(1, mask_ids.unsqueeze(-1).expand(-1, -1, x.shape[-1]))
|
| 231 |
+
else:
|
| 232 |
+
mask_ids = None
|
| 233 |
+
token_mask = None
|
| 234 |
+
|
| 235 |
+
cls_embeds, reg_embeds, patch_embeds = self.forward_patch_embeds(
|
| 236 |
+
x,
|
| 237 |
+
token_mask=token_mask,
|
| 238 |
+
)
|
| 239 |
+
return cls_embeds, reg_embeds, patch_embeds, mask, mask_ids, token_mask
|
| 240 |
+
|
| 241 |
+
def forward_patch_embeds(
|
| 242 |
+
self,
|
| 243 |
+
x: Float[Tensor, "B L D"],
|
| 244 |
+
token_mask: Tensor | None = None,
|
| 245 |
+
) -> tuple[
|
| 246 |
+
Float[Tensor, "B 1 D"] | None,
|
| 247 |
+
Float[Tensor, "B R D"] | None,
|
| 248 |
+
Float[Tensor, "B L D"],
|
| 249 |
+
]:
|
| 250 |
+
B = x.shape[0]
|
| 251 |
+
if token_mask is None:
|
| 252 |
+
token_mask = torch.ones(x.shape[:2], dtype=torch.bool, device=x.device)
|
| 253 |
+
x = self.cat_tokens(x)
|
| 254 |
+
token_mask = self.cat_token_mask(token_mask, B)
|
| 255 |
+
jagged_batch = JaggedBatch.from_mask(token_mask)
|
| 256 |
+
x = x[token_mask]
|
| 257 |
+
for block in self.blocks:
|
| 258 |
+
x = block(x, jagged_batch=jagged_batch)
|
| 259 |
+
x = self.norm(x)
|
| 260 |
+
x = unpack_tokens(x, token_mask)
|
| 261 |
+
|
| 262 |
+
cls_embeds, reg_embeds, patch_embeds = self.chunk_tokens(x)
|
| 263 |
+
return cls_embeds, reg_embeds, patch_embeds
|
| 264 |
+
|
| 265 |
+
def forward_visible_ids(
|
| 266 |
+
self,
|
| 267 |
+
x: Tensor,
|
| 268 |
+
visible_ids: Int[Tensor, "B L"],
|
| 269 |
+
img_mask: Tensor | None = None,
|
| 270 |
+
) -> tuple[
|
| 271 |
+
Float[Tensor, "B 1 D"] | None,
|
| 272 |
+
Float[Tensor, "B R D"] | None,
|
| 273 |
+
Float[Tensor, "B L D"],
|
| 274 |
+
]:
|
| 275 |
+
if img_mask is not None:
|
| 276 |
+
img_mask = img_mask.to(device=x.device, dtype=torch.bool).expand_as(x)
|
| 277 |
+
x = x.masked_fill(~img_mask, 0)
|
| 278 |
+
|
| 279 |
+
x = self.patchify(x)
|
| 280 |
+
if self.mask_drop_scale and img_mask is not None:
|
| 281 |
+
mask_patches = self.patchify(img_mask)
|
| 282 |
+
patch_num_obs = mask_patches.sum(dim=-1).to(x.dtype)
|
| 283 |
+
x = x * (self.patchify.patch_dim / patch_num_obs.unsqueeze(-1).clamp(min=1.0))
|
| 284 |
+
x = self.patch_embed(x)
|
| 285 |
+
x = self.pos_embed(x)
|
| 286 |
+
visible_ids = visible_ids.to(device=x.device)
|
| 287 |
+
x = x.gather(1, visible_ids.unsqueeze(-1).expand(-1, -1, x.shape[-1]))
|
| 288 |
+
return self.forward_patch_embeds(x)
|
| 289 |
+
|
| 290 |
+
def forward_embedding(
|
| 291 |
+
self,
|
| 292 |
+
x: Tensor,
|
| 293 |
+
mask: Tensor | None = None,
|
| 294 |
+
mask_ratio: float | None = None,
|
| 295 |
+
):
|
| 296 |
+
cls_embeds, reg_embeds, patch_embeds, *_ = self.forward(
|
| 297 |
+
x,
|
| 298 |
+
mask=mask,
|
| 299 |
+
mask_ratio=mask_ratio,
|
| 300 |
+
)
|
| 301 |
+
return cls_embeds, reg_embeds, patch_embeds
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class MaskedDecoder(nn.Module):
|
| 305 |
+
"""Self-attention MAE decoder supporting sparse subset decoding via pred_ids."""
|
| 306 |
+
|
| 307 |
+
def __init__(
|
| 308 |
+
self,
|
| 309 |
+
pos_embed: nn.Module,
|
| 310 |
+
head: nn.Module | None = None,
|
| 311 |
+
input_dim: int | None = None,
|
| 312 |
+
depth: int = 12,
|
| 313 |
+
embed_dim: int = 768,
|
| 314 |
+
num_heads: int = 12,
|
| 315 |
+
qkv_bias: bool = True,
|
| 316 |
+
proj_bias: bool = True,
|
| 317 |
+
mlp_ratio: int | float = 4,
|
| 318 |
+
class_token: bool = True,
|
| 319 |
+
no_embed_class: bool = False,
|
| 320 |
+
final_norm: bool = True,
|
| 321 |
+
):
|
| 322 |
+
super().__init__()
|
| 323 |
+
input_dim = embed_dim if input_dim is None else input_dim
|
| 324 |
+
self.has_class_token = class_token
|
| 325 |
+
self.no_embed_class = no_embed_class
|
| 326 |
+
|
| 327 |
+
self.cls_token = nn.Parameter(torch.empty(1, 1, embed_dim)) if class_token else None
|
| 328 |
+
self.cls_token_pos = (
|
| 329 |
+
nn.Parameter(torch.empty(1, 1, embed_dim))
|
| 330 |
+
if class_token and not no_embed_class
|
| 331 |
+
else None
|
| 332 |
+
)
|
| 333 |
+
self.mask_token = nn.Parameter(torch.empty(1, 1, embed_dim))
|
| 334 |
+
|
| 335 |
+
# decoder position embedding, encodes query position information into masks
|
| 336 |
+
self.pos_embed = pos_embed
|
| 337 |
+
|
| 338 |
+
self.proj = nn.Identity() if input_dim == embed_dim else nn.Linear(input_dim, embed_dim)
|
| 339 |
+
|
| 340 |
+
self.blocks = nn.ModuleList(
|
| 341 |
+
[
|
| 342 |
+
Block(
|
| 343 |
+
dim=embed_dim,
|
| 344 |
+
num_heads=num_heads,
|
| 345 |
+
qkv_bias=qkv_bias,
|
| 346 |
+
proj_bias=proj_bias,
|
| 347 |
+
mlp_ratio=mlp_ratio,
|
| 348 |
+
)
|
| 349 |
+
for _ in range(depth)
|
| 350 |
+
]
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
self.norm = LayerNorm(embed_dim) if final_norm else nn.Identity()
|
| 354 |
+
|
| 355 |
+
# optional injected prediction head
|
| 356 |
+
self.head = nn.Identity() if head is None else head
|
| 357 |
+
|
| 358 |
+
self.reset_parameters()
|
| 359 |
+
|
| 360 |
+
def extra_repr(self):
|
| 361 |
+
return f"class_token={self.has_class_token}, no_embed_class={self.no_embed_class}"
|
| 362 |
+
|
| 363 |
+
def reset_parameters(self) -> None:
|
| 364 |
+
# official mae initializes decoder cls token to zeros
|
| 365 |
+
# although perhaps this was an oversight
|
| 366 |
+
if self.cls_token is not None:
|
| 367 |
+
nn.init.zeros_(self.cls_token)
|
| 368 |
+
if self.cls_token_pos is not None:
|
| 369 |
+
nn.init.trunc_normal_(self.cls_token_pos, std=0.02)
|
| 370 |
+
nn.init.trunc_normal_(self.mask_token, std=0.02)
|
| 371 |
+
|
| 372 |
+
def cat_tokens(self, x: Tensor) -> Tensor:
|
| 373 |
+
if not self.has_class_token:
|
| 374 |
+
return x
|
| 375 |
+
cls_token = self.cls_token
|
| 376 |
+
if not self.no_embed_class:
|
| 377 |
+
cls_token = cls_token + self.cls_token_pos
|
| 378 |
+
return torch.cat([cls_token.expand(x.shape[0], -1, -1), x], dim=1)
|
| 379 |
+
|
| 380 |
+
def cat_token_mask(self, token_mask: Tensor, batch_size: int) -> Tensor:
|
| 381 |
+
if self.has_class_token:
|
| 382 |
+
cls_mask = torch.ones(
|
| 383 |
+
(batch_size, 1),
|
| 384 |
+
dtype=torch.bool,
|
| 385 |
+
device=token_mask.device,
|
| 386 |
+
)
|
| 387 |
+
token_mask = torch.cat([cls_mask, token_mask], dim=1)
|
| 388 |
+
return token_mask
|
| 389 |
+
|
| 390 |
+
def chunk_tokens(self, x: Tensor) -> tuple[Tensor | None, Tensor]:
|
| 391 |
+
cls_offset = int(self.has_class_token)
|
| 392 |
+
cls = x[:, :cls_offset] if self.has_class_token else None
|
| 393 |
+
patch = x[:, cls_offset:, :]
|
| 394 |
+
return cls, patch
|
| 395 |
+
|
| 396 |
+
def forward(
|
| 397 |
+
self,
|
| 398 |
+
embeds: Float[Tensor, "B L D"],
|
| 399 |
+
embed_ids: Int[Tensor, "B L"] | None = None,
|
| 400 |
+
pred_ids: Int[Tensor, "B Q"] | None = None,
|
| 401 |
+
embed_token_mask: Tensor | None = None,
|
| 402 |
+
pred_token_mask: Tensor | None = None,
|
| 403 |
+
packed_output: bool = False,
|
| 404 |
+
) -> Float[Tensor, "B Q P"] | Float[Tensor, "T P"]:
|
| 405 |
+
"""
|
| 406 |
+
embeds: input patch embeddings.
|
| 407 |
+
embed_ids: optional patch indices for input embeddings. If not provided, no
|
| 408 |
+
position will be added to the embeddings.
|
| 409 |
+
pred_ids: patch indices of query mask positions. If None, decode *all* patches.
|
| 410 |
+
|
| 411 |
+
returns:
|
| 412 |
+
- pred [B, Q, P] where Q is the number of prediction patches and P is the output
|
| 413 |
+
dimension
|
| 414 |
+
"""
|
| 415 |
+
B, L, _ = embeds.shape
|
| 416 |
+
|
| 417 |
+
Q = self.pos_embed.num_patches if pred_ids is None else pred_ids.shape[1]
|
| 418 |
+
mask = self.mask_token.expand(B, Q, -1)
|
| 419 |
+
mask = self.pos_embed(mask, pos_ids=pred_ids)
|
| 420 |
+
|
| 421 |
+
embeds = self.proj(embeds)
|
| 422 |
+
|
| 423 |
+
if embed_ids is not None:
|
| 424 |
+
embeds = self.pos_embed(embeds, pos_ids=embed_ids)
|
| 425 |
+
if embed_token_mask is None:
|
| 426 |
+
embed_token_mask = torch.ones((B, L), dtype=torch.bool, device=embeds.device)
|
| 427 |
+
if pred_token_mask is None:
|
| 428 |
+
pred_token_mask = torch.ones((B, Q), dtype=torch.bool, device=embeds.device)
|
| 429 |
+
x = torch.cat([embeds, mask], dim=1)
|
| 430 |
+
token_mask = torch.cat([embed_token_mask, pred_token_mask], dim=1)
|
| 431 |
+
|
| 432 |
+
x = self.cat_tokens(x)
|
| 433 |
+
token_mask = self.cat_token_mask(token_mask, B)
|
| 434 |
+
jagged_batch = JaggedBatch.from_mask(token_mask)
|
| 435 |
+
x = x[token_mask]
|
| 436 |
+
# Keep headroom for rare maximum-length PSP batches.
|
| 437 |
+
checkpoint_start = max(0, len(self.blocks) - 2)
|
| 438 |
+
for block_index, block in enumerate(self.blocks):
|
| 439 |
+
if self.training and torch.is_grad_enabled() and block_index >= checkpoint_start:
|
| 440 |
+
x = checkpoint(block, x, jagged_batch, use_reentrant=False)
|
| 441 |
+
else:
|
| 442 |
+
x = block(x, jagged_batch=jagged_batch)
|
| 443 |
+
|
| 444 |
+
x = self.norm(x)
|
| 445 |
+
if packed_output:
|
| 446 |
+
pred_offset = int(self.has_class_token) + L
|
| 447 |
+
prediction_mask = F.pad(pred_token_mask, (pred_offset, 0))
|
| 448 |
+
return self.head(x[prediction_mask[token_mask]])
|
| 449 |
+
|
| 450 |
+
x = unpack_tokens(x, token_mask)
|
| 451 |
+
_, x = self.chunk_tokens(x)
|
| 452 |
+
|
| 453 |
+
pred = x[:, L:]
|
| 454 |
+
pred = pred.masked_fill(~pred_token_mask.unsqueeze(-1), 0)
|
| 455 |
+
pred = self.head(pred)
|
| 456 |
+
return pred
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
class MaskedAutoencoderViT(nn.Module, PyTorchModelHubMixin):
|
| 460 |
+
def __init__(
|
| 461 |
+
self,
|
| 462 |
+
img_size: int | tuple[int, int, int] = (208, 240, 208),
|
| 463 |
+
patch_size: int | tuple[int, int, int] = (16, 16, 16),
|
| 464 |
+
in_chans: int = 1,
|
| 465 |
+
depth: int = 12,
|
| 466 |
+
embed_dim: int = 768,
|
| 467 |
+
num_heads: int = 12,
|
| 468 |
+
decoder_depth: int = 4,
|
| 469 |
+
decoder_embed_dim: int | None = 512,
|
| 470 |
+
decoder_num_heads: int | None = 16, # default from mae, head dim = 32
|
| 471 |
+
qkv_bias: bool = True,
|
| 472 |
+
proj_bias: bool = True,
|
| 473 |
+
mlp_ratio: int | float = 4,
|
| 474 |
+
class_token: bool = True,
|
| 475 |
+
reg_tokens: int = 0,
|
| 476 |
+
no_embed_class: bool = False,
|
| 477 |
+
drop_path_rate: float = 0.0,
|
| 478 |
+
mask_drop_scale: bool = False,
|
| 479 |
+
no_decode_pos: bool = False,
|
| 480 |
+
pos_embed: Literal["abs", "sep", "sincos"] = "sincos",
|
| 481 |
+
target_norm: Literal["none", "global", "slice", "patch"] | None = None,
|
| 482 |
+
):
|
| 483 |
+
super().__init__()
|
| 484 |
+
img_size = _to_3d_tuple(img_size, "img_size")
|
| 485 |
+
patch_size = _to_3d_tuple(patch_size, "patch_size")
|
| 486 |
+
|
| 487 |
+
self.no_decode_pos = no_decode_pos # don't pos encode embeddings in decoder
|
| 488 |
+
|
| 489 |
+
# patchify reshapes input into sequence of flattened patches, shape [B, N, P]
|
| 490 |
+
ndim = 3
|
| 491 |
+
patchify = Patchify3D(img_size, patch_size, in_chans=in_chans)
|
| 492 |
+
|
| 493 |
+
# linear patch embedding P -> D
|
| 494 |
+
patch_embed = nn.Linear(patchify.patch_dim, embed_dim)
|
| 495 |
+
|
| 496 |
+
# position embedding
|
| 497 |
+
# separable position embedding decouples the first spatial axis from the
|
| 498 |
+
# others. Fixed sin/cos embeddings are the default for sMRI volumes.
|
| 499 |
+
if pos_embed == "sincos":
|
| 500 |
+
pos_embed_layer = SinCosPosEmbed3D
|
| 501 |
+
else:
|
| 502 |
+
pos_embed_layer = {"abs": AbsolutePosEmbed, "sep": SeparablePosEmbed}[pos_embed]
|
| 503 |
+
pos_embed = pos_embed_layer(embed_dim, patchify.grid_size)
|
| 504 |
+
|
| 505 |
+
# encoder. for inference, this model can be extracted and used like a regular vit
|
| 506 |
+
self.encoder = MaskedEncoder(
|
| 507 |
+
patchify=patchify,
|
| 508 |
+
patch_embed=patch_embed,
|
| 509 |
+
pos_embed=pos_embed,
|
| 510 |
+
depth=depth,
|
| 511 |
+
embed_dim=embed_dim,
|
| 512 |
+
num_heads=num_heads,
|
| 513 |
+
qkv_bias=qkv_bias,
|
| 514 |
+
proj_bias=proj_bias,
|
| 515 |
+
mlp_ratio=mlp_ratio,
|
| 516 |
+
class_token=class_token,
|
| 517 |
+
reg_tokens=reg_tokens,
|
| 518 |
+
no_embed_class=no_embed_class,
|
| 519 |
+
drop_path_rate=drop_path_rate,
|
| 520 |
+
mask_drop_scale=mask_drop_scale,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
self.pred_patchify = patchify
|
| 524 |
+
|
| 525 |
+
# fall back to encoder architecture width
|
| 526 |
+
decoder_embed_dim = decoder_embed_dim or embed_dim
|
| 527 |
+
decoder_num_heads = decoder_num_heads or num_heads
|
| 528 |
+
|
| 529 |
+
decoder_pos_embed = pos_embed_layer(decoder_embed_dim, self.pred_patchify.grid_size)
|
| 530 |
+
# we might want to try tying the weights of the prediction head to the patch
|
| 531 |
+
# embedding at some point.
|
| 532 |
+
decoder_head = nn.Linear(decoder_embed_dim, self.pred_patchify.patch_dim)
|
| 533 |
+
|
| 534 |
+
self.decoder = MaskedDecoder(
|
| 535 |
+
pos_embed=decoder_pos_embed,
|
| 536 |
+
head=decoder_head,
|
| 537 |
+
input_dim=embed_dim,
|
| 538 |
+
depth=decoder_depth,
|
| 539 |
+
embed_dim=decoder_embed_dim,
|
| 540 |
+
num_heads=decoder_num_heads,
|
| 541 |
+
qkv_bias=qkv_bias,
|
| 542 |
+
proj_bias=proj_bias,
|
| 543 |
+
mlp_ratio=mlp_ratio,
|
| 544 |
+
class_token=class_token,
|
| 545 |
+
no_embed_class=no_embed_class,
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
# mae style target normalization
|
| 549 |
+
# dim is relative to an unflattened embedding tensor of shape [B, *grid_size, D]
|
| 550 |
+
if target_norm not in {"none", None}:
|
| 551 |
+
norm_dim = {
|
| 552 |
+
"global": tuple(range(1, ndim + 2)), # full sequence
|
| 553 |
+
"slice": tuple(range(2, ndim + 2)), # each depth slice along first dim
|
| 554 |
+
"patch": -1, # normalize each patch independently (mae pix norm loss)
|
| 555 |
+
}[target_norm]
|
| 556 |
+
self.target_norm = Normalize(self.pred_patchify.grid_size, dim=norm_dim)
|
| 557 |
+
else:
|
| 558 |
+
self.target_norm = None
|
| 559 |
+
|
| 560 |
+
self.init_weights()
|
| 561 |
+
|
| 562 |
+
def extra_repr(self):
|
| 563 |
+
return f"no_decode_pos={self.no_decode_pos}"
|
| 564 |
+
|
| 565 |
+
def init_weights(self):
|
| 566 |
+
self.apply(_init_weights)
|
| 567 |
+
|
| 568 |
+
def prepare_targets(self, images: Tensor, img_mask: Tensor | None):
|
| 569 |
+
"""
|
| 570 |
+
images: [B, C, D, H, W]
|
| 571 |
+
img_mask: mask of valid data. only used for computing correct normalization
|
| 572 |
+
stats. same shape as images.
|
| 573 |
+
"""
|
| 574 |
+
targets_patches = self.pred_patchify(images) # [B, N, P]
|
| 575 |
+
|
| 576 |
+
# target normalization
|
| 577 |
+
if self.target_norm is not None:
|
| 578 |
+
# full image data mask used for normalization stats only
|
| 579 |
+
if img_mask is not None:
|
| 580 |
+
img_mask_patches = self.pred_patchify(img_mask)
|
| 581 |
+
else:
|
| 582 |
+
img_mask_patches = None
|
| 583 |
+
targets_patches, *targets_stats = self.target_norm(targets_patches, img_mask_patches)
|
| 584 |
+
else:
|
| 585 |
+
targets_stats = None
|
| 586 |
+
|
| 587 |
+
return targets_patches, targets_stats
|
| 588 |
+
|
| 589 |
+
def prepare_masks(
|
| 590 |
+
self,
|
| 591 |
+
img_mask: Tensor,
|
| 592 |
+
visible_mask: Tensor | None,
|
| 593 |
+
pred_mask: Tensor | None,
|
| 594 |
+
device: torch.device,
|
| 595 |
+
):
|
| 596 |
+
img_mask = img_mask.to(device=device, dtype=torch.bool)
|
| 597 |
+
|
| 598 |
+
if visible_mask is None:
|
| 599 |
+
visible_mask = img_mask
|
| 600 |
+
else:
|
| 601 |
+
visible_mask = img_mask & visible_mask.to(device=device, dtype=torch.bool)
|
| 602 |
+
|
| 603 |
+
if pred_mask is None:
|
| 604 |
+
pred_mask = img_mask
|
| 605 |
+
else:
|
| 606 |
+
pred_mask = img_mask & pred_mask.to(device=device, dtype=torch.bool)
|
| 607 |
+
|
| 608 |
+
return img_mask, visible_mask, pred_mask
|
| 609 |
+
|
| 610 |
+
def prepare_pred_mask(
|
| 611 |
+
self,
|
| 612 |
+
visible_mask: Tensor,
|
| 613 |
+
pred_mask: Tensor | None = None,
|
| 614 |
+
pred_mask_ratio: float | None = None,
|
| 615 |
+
pad_to_multiple: int | None = None,
|
| 616 |
+
):
|
| 617 |
+
"""
|
| 618 |
+
prepare prediction mask by removing visible content
|
| 619 |
+
visible_mask: [B, C, D, H, W], 1 = visible, 0 = invisible
|
| 620 |
+
pred_mask: same shape, 1 = predict, 0 = don't predict
|
| 621 |
+
"""
|
| 622 |
+
if pred_mask is None:
|
| 623 |
+
pred_mask = torch.ones_like(visible_mask)
|
| 624 |
+
|
| 625 |
+
pred_mask = pred_mask & ~visible_mask
|
| 626 |
+
|
| 627 |
+
pred_mask_patches = self.pred_patchify(pred_mask)
|
| 628 |
+
pred_patch_mask = pred_mask_patches.any(dim=-1)
|
| 629 |
+
# Optionally subsample the prediction candidates.
|
| 630 |
+
mask_ratio = 0.0 if pred_mask_ratio is None else pred_mask_ratio
|
| 631 |
+
pred_patch_mask, pred_ids, pred_token_mask = pad_patch_mask(
|
| 632 |
+
pred_patch_mask,
|
| 633 |
+
mask_ratio=mask_ratio,
|
| 634 |
+
# With per-sample padding every candidate is retained when the ratio
|
| 635 |
+
# is zero, so randomizing their order is pure overhead.
|
| 636 |
+
shuffle=mask_ratio > 0,
|
| 637 |
+
pad_to_multiple=pad_to_multiple,
|
| 638 |
+
)
|
| 639 |
+
pred_mask_patches = pred_mask_patches & pred_patch_mask.unsqueeze(-1)
|
| 640 |
+
return pred_mask_patches, pred_ids, pred_token_mask
|
| 641 |
+
|
| 642 |
+
def forward_decoder(
|
| 643 |
+
self,
|
| 644 |
+
patch_embeds: Float[Tensor, "B L D"],
|
| 645 |
+
visible_ids: Int[Tensor, "B L"],
|
| 646 |
+
pred_ids: Int[Tensor, "B Q"] | None,
|
| 647 |
+
visible_token_mask: Tensor | None = None,
|
| 648 |
+
pred_token_mask: Tensor | None = None,
|
| 649 |
+
packed_output: bool = False,
|
| 650 |
+
) -> Float[Tensor, "B Q P"] | Float[Tensor, "T P"]:
|
| 651 |
+
return self.decoder(
|
| 652 |
+
patch_embeds,
|
| 653 |
+
embed_ids=None if self.no_decode_pos else visible_ids,
|
| 654 |
+
pred_ids=pred_ids,
|
| 655 |
+
embed_token_mask=visible_token_mask,
|
| 656 |
+
pred_token_mask=pred_token_mask,
|
| 657 |
+
packed_output=packed_output,
|
| 658 |
+
)
|
| 659 |
+
|
| 660 |
+
def forward_loss(
|
| 661 |
+
self,
|
| 662 |
+
preds: Float[Tensor, "T P"],
|
| 663 |
+
targets_patches: Float[Tensor, "B N P"],
|
| 664 |
+
pred_mask_patches: Float[Tensor, "B N P"],
|
| 665 |
+
pred_ids: Int[Tensor, "B Q"],
|
| 666 |
+
pred_token_mask: Tensor,
|
| 667 |
+
) -> Tensor:
|
| 668 |
+
"""Average valid-voxel MSE within each scan, then average across scans."""
|
| 669 |
+
batch_ids, slot_ids = pred_token_mask.nonzero(as_tuple=True)
|
| 670 |
+
patch_ids = pred_ids[batch_ids, slot_ids]
|
| 671 |
+
targets = targets_patches[batch_ids, patch_ids]
|
| 672 |
+
voxel_mask = pred_mask_patches[batch_ids, patch_ids]
|
| 673 |
+
|
| 674 |
+
patch_errors = ((preds - targets) ** 2 * voxel_mask).sum(dim=1)
|
| 675 |
+
patch_voxels = voxel_mask.sum(dim=1).to(dtype=patch_errors.dtype)
|
| 676 |
+
batch_size = targets_patches.shape[0]
|
| 677 |
+
scan_errors = patch_errors.new_zeros(batch_size).scatter_add_(0, batch_ids, patch_errors)
|
| 678 |
+
scan_voxels = patch_voxels.new_zeros(batch_size).scatter_add_(0, batch_ids, patch_voxels)
|
| 679 |
+
return (scan_errors / scan_voxels).mean()
|
| 680 |
+
|
| 681 |
+
@torch.no_grad()
|
| 682 |
+
def forward_pred_images(
|
| 683 |
+
self,
|
| 684 |
+
preds: Float[Tensor, "B Q P"],
|
| 685 |
+
pred_ids: Int[Tensor, "B Q"],
|
| 686 |
+
pred_token_mask: Tensor | None = None,
|
| 687 |
+
img_mask: Tensor | None = None,
|
| 688 |
+
targets_stats: tuple[Tensor, Tensor] | None = None,
|
| 689 |
+
) -> Tensor:
|
| 690 |
+
B, _, P = preds.shape
|
| 691 |
+
N = self.pred_patchify.num_patches
|
| 692 |
+
if pred_token_mask is not None:
|
| 693 |
+
preds = preds.masked_fill(~pred_token_mask.unsqueeze(-1), 0)
|
| 694 |
+
|
| 695 |
+
preds = torch.zeros((B, N, P), dtype=preds.dtype, device=preds.device).scatter_add_(
|
| 696 |
+
1, pred_ids.unsqueeze(-1).expand(-1, -1, P), preds
|
| 697 |
+
)
|
| 698 |
+
|
| 699 |
+
if targets_stats is not None:
|
| 700 |
+
targets_mean, targets_std = targets_stats
|
| 701 |
+
preds = preds * targets_std + targets_mean
|
| 702 |
+
|
| 703 |
+
pred_images = self.pred_patchify.unpatchify(preds)
|
| 704 |
+
if img_mask is not None:
|
| 705 |
+
pred_images = pred_images.masked_fill(~img_mask, 0)
|
| 706 |
+
return pred_images
|
| 707 |
+
|
| 708 |
+
def forward(
|
| 709 |
+
self,
|
| 710 |
+
images: Tensor,
|
| 711 |
+
img_mask: Tensor,
|
| 712 |
+
mask_ratio: float,
|
| 713 |
+
pred_mask_ratio: float | None = None,
|
| 714 |
+
pad_to_multiple: int | None = None,
|
| 715 |
+
with_state: bool = True,
|
| 716 |
+
) -> Tensor | tuple[Tensor, dict]:
|
| 717 |
+
img_mask, visible_mask, pred_mask = self.prepare_masks(
|
| 718 |
+
img_mask,
|
| 719 |
+
None,
|
| 720 |
+
None,
|
| 721 |
+
device=images.device,
|
| 722 |
+
)
|
| 723 |
+
targets_patches, targets_stats = self.prepare_targets(images, img_mask)
|
| 724 |
+
|
| 725 |
+
(
|
| 726 |
+
cls_embeds,
|
| 727 |
+
reg_embeds,
|
| 728 |
+
patch_embeds,
|
| 729 |
+
visible_mask,
|
| 730 |
+
visible_ids,
|
| 731 |
+
visible_token_mask,
|
| 732 |
+
) = self.encoder(
|
| 733 |
+
images,
|
| 734 |
+
mask=visible_mask,
|
| 735 |
+
mask_ratio=mask_ratio,
|
| 736 |
+
pad_to_multiple=pad_to_multiple,
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
pred_mask_patches, pred_ids, pred_token_mask = self.prepare_pred_mask(
|
| 740 |
+
visible_mask,
|
| 741 |
+
pred_mask=pred_mask,
|
| 742 |
+
pred_mask_ratio=pred_mask_ratio,
|
| 743 |
+
pad_to_multiple=pad_to_multiple,
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
preds = self.forward_decoder(
|
| 747 |
+
patch_embeds,
|
| 748 |
+
visible_ids,
|
| 749 |
+
pred_ids,
|
| 750 |
+
visible_token_mask=visible_token_mask,
|
| 751 |
+
pred_token_mask=pred_token_mask,
|
| 752 |
+
packed_output=not with_state,
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
loss_preds = preds if not with_state else preds[pred_token_mask]
|
| 756 |
+
loss = self.forward_loss(
|
| 757 |
+
loss_preds,
|
| 758 |
+
targets_patches,
|
| 759 |
+
pred_mask_patches,
|
| 760 |
+
pred_ids,
|
| 761 |
+
pred_token_mask,
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
if not with_state:
|
| 765 |
+
return loss
|
| 766 |
+
|
| 767 |
+
pred_mask = self.pred_patchify.unpatchify(pred_mask_patches)
|
| 768 |
+
pred_images = self.forward_pred_images(
|
| 769 |
+
preds,
|
| 770 |
+
pred_ids,
|
| 771 |
+
pred_token_mask=pred_token_mask,
|
| 772 |
+
img_mask=img_mask,
|
| 773 |
+
targets_stats=targets_stats,
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
state = {
|
| 777 |
+
"targets_patches": targets_patches,
|
| 778 |
+
"targets_stats": targets_stats,
|
| 779 |
+
"patch_embeds": patch_embeds,
|
| 780 |
+
"cls_embeds": cls_embeds,
|
| 781 |
+
"reg_embeds": reg_embeds,
|
| 782 |
+
"visible_mask": visible_mask,
|
| 783 |
+
"visible_ids": visible_ids,
|
| 784 |
+
"visible_token_mask": visible_token_mask,
|
| 785 |
+
"pred_mask": pred_mask,
|
| 786 |
+
"pred_ids": pred_ids,
|
| 787 |
+
"pred_token_mask": pred_token_mask,
|
| 788 |
+
"preds": preds,
|
| 789 |
+
"pred_images": pred_images,
|
| 790 |
+
}
|
| 791 |
+
return loss, state
|
| 792 |
+
|
| 793 |
+
def forward_embedding(
|
| 794 |
+
self,
|
| 795 |
+
x: Tensor,
|
| 796 |
+
mask: Tensor | None = None,
|
| 797 |
+
mask_ratio: float | None = None,
|
| 798 |
+
):
|
| 799 |
+
return self.encoder.forward_embedding(x, mask=mask, mask_ratio=mask_ratio)
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
class MaskedViT(MaskedEncoder, PyTorchModelHubMixin):
|
| 803 |
+
def __init__(
|
| 804 |
+
self,
|
| 805 |
+
img_size: int | tuple[int, int, int] = (208, 240, 208),
|
| 806 |
+
in_chans: int = 1,
|
| 807 |
+
patch_size: int | tuple[int, int, int] = (16, 16, 16),
|
| 808 |
+
depth: int = 12,
|
| 809 |
+
embed_dim: int = 768,
|
| 810 |
+
num_heads: int = 12,
|
| 811 |
+
qkv_bias: bool = True,
|
| 812 |
+
proj_bias: bool = True,
|
| 813 |
+
mlp_ratio: int | float = 4,
|
| 814 |
+
class_token: bool = True,
|
| 815 |
+
reg_tokens: int = 0,
|
| 816 |
+
no_embed_class: bool = False,
|
| 817 |
+
final_norm: bool = True,
|
| 818 |
+
drop_path_rate: float = 0.0,
|
| 819 |
+
mask_drop_scale: bool = False,
|
| 820 |
+
pos_embed: Literal["abs", "sep", "sincos"] = "sincos",
|
| 821 |
+
):
|
| 822 |
+
img_size = _to_3d_tuple(img_size, "img_size")
|
| 823 |
+
patch_size = _to_3d_tuple(patch_size, "patch_size")
|
| 824 |
+
|
| 825 |
+
patchify = Patchify3D(img_size, patch_size, in_chans=in_chans)
|
| 826 |
+
patch_embed = nn.Linear(patchify.patch_dim, embed_dim)
|
| 827 |
+
if pos_embed == "sincos":
|
| 828 |
+
pos_embed_layer = SinCosPosEmbed3D
|
| 829 |
+
else:
|
| 830 |
+
pos_embed_layer = {"abs": AbsolutePosEmbed, "sep": SeparablePosEmbed}[pos_embed]
|
| 831 |
+
pos_embed = pos_embed_layer(embed_dim, patchify.grid_size)
|
| 832 |
+
|
| 833 |
+
super().__init__(
|
| 834 |
+
patchify=patchify,
|
| 835 |
+
patch_embed=patch_embed,
|
| 836 |
+
pos_embed=pos_embed,
|
| 837 |
+
depth=depth,
|
| 838 |
+
embed_dim=embed_dim,
|
| 839 |
+
num_heads=num_heads,
|
| 840 |
+
qkv_bias=qkv_bias,
|
| 841 |
+
proj_bias=proj_bias,
|
| 842 |
+
mlp_ratio=mlp_ratio,
|
| 843 |
+
class_token=class_token,
|
| 844 |
+
reg_tokens=reg_tokens,
|
| 845 |
+
no_embed_class=no_embed_class,
|
| 846 |
+
final_norm=final_norm,
|
| 847 |
+
drop_path_rate=drop_path_rate,
|
| 848 |
+
mask_drop_scale=mask_drop_scale,
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
self.init_weights()
|
| 852 |
+
|
| 853 |
+
def init_weights(self):
|
| 854 |
+
self.apply(_init_weights)
|
| 855 |
+
|
| 856 |
+
|
| 857 |
+
def _to_3d_tuple(value: int | Sequence[int], name: str) -> tuple[int, int, int]:
|
| 858 |
+
if isinstance(value, int):
|
| 859 |
+
return (value, value, value)
|
| 860 |
+
if len(value) != 3:
|
| 861 |
+
raise ValueError(f"{name} must have exactly 3 spatial dimensions, got {tuple(value)}")
|
| 862 |
+
return tuple(int(item) for item in value)
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
# JAX ViT xavier uniform init
|
| 866 |
+
# https://github.com/facebookresearch/capi/blob/main/model.py
|
| 867 |
+
def _init_weights(m: nn.Module) -> None:
|
| 868 |
+
if isinstance(m, nn.Linear):
|
| 869 |
+
nn.init.xavier_uniform_(m.weight)
|
| 870 |
+
if m.bias is not None:
|
| 871 |
+
nn.init.constant_(m.bias, 0)
|
| 872 |
+
elif isinstance(m, nn.LayerNorm) and m.elementwise_affine:
|
| 873 |
+
nn.init.constant_(m.weight, 1.0)
|
| 874 |
+
if m.bias is not None:
|
| 875 |
+
nn.init.constant_(m.bias, 0)
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
def _create_vit(**kwargs):
|
| 879 |
+
model = MaskedViT(**kwargs)
|
| 880 |
+
return model
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def _create_mae_vit(**kwargs):
|
| 884 |
+
model = MaskedAutoencoderViT(**kwargs)
|
| 885 |
+
return model
|
| 886 |
+
|
| 887 |
+
|
| 888 |
+
def mae_vit_small(**kwargs):
|
| 889 |
+
model_args = dict(embed_dim=384, depth=12, num_heads=6)
|
| 890 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 891 |
+
|
| 892 |
+
|
| 893 |
+
def mae_vit_base(**kwargs):
|
| 894 |
+
model_args = dict(embed_dim=768, depth=12, num_heads=12)
|
| 895 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
def mae_vit_large(**kwargs):
|
| 899 |
+
model_args = dict(embed_dim=1024, depth=24, num_heads=16)
|
| 900 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 901 |
+
|
| 902 |
+
|
| 903 |
+
def mae_vit_huge(**kwargs):
|
| 904 |
+
model_args = dict(embed_dim=1280, depth=32, num_heads=16)
|
| 905 |
+
return _create_mae_vit(**model_args, **kwargs)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
# "patch embed" baseline model, depth 0 ViT (hah)
|
| 909 |
+
def patch_embed_small(**kwargs):
|
| 910 |
+
model_args = dict(embed_dim=384, depth=0)
|
| 911 |
+
return _create_vit(**model_args, **kwargs)
|
| 912 |
+
|
| 913 |
+
|
| 914 |
+
def patch_embed_base(**kwargs):
|
| 915 |
+
model_args = dict(embed_dim=768, depth=0)
|
| 916 |
+
return _create_vit(**model_args, **kwargs)
|
fomo_tune_baseline/output/task3/build/smri_mae/modules.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 2 |
+
#
|
| 3 |
+
# References:
|
| 4 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/model.py
|
| 5 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/v1.0.20/timm/models/vision_transformer.py
|
| 6 |
+
# vjepa2: https://github.com/facebookresearch/vjepa2/blob/main/src/models/utils/pos_embs.py
|
| 7 |
+
|
| 8 |
+
import math
|
| 9 |
+
from functools import partial
|
| 10 |
+
from typing import NamedTuple, Type
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
from torch import Tensor
|
| 17 |
+
from einops import rearrange
|
| 18 |
+
from jaxtyping import Float, Int
|
| 19 |
+
from timm.layers import DropPath, to_3tuple
|
| 20 |
+
|
| 21 |
+
Layer = Type[nn.Module]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class JaggedBatch(NamedTuple):
|
| 25 |
+
"""Sequence boundaries and cached launch metadata for jagged attention."""
|
| 26 |
+
|
| 27 |
+
offsets: Tensor
|
| 28 |
+
max_seqlen: int
|
| 29 |
+
|
| 30 |
+
@classmethod
|
| 31 |
+
def from_mask(cls, mask: Tensor) -> "JaggedBatch":
|
| 32 |
+
mask = mask.to(dtype=torch.bool)
|
| 33 |
+
counts = mask.sum(dim=1)
|
| 34 |
+
return cls(
|
| 35 |
+
offsets=F.pad(counts.cumsum(dim=0), (1, 0)),
|
| 36 |
+
max_seqlen=mask.shape[1],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def as_nested(self, tokens: Tensor) -> Tensor:
|
| 40 |
+
# Cached conservative bounds avoid min/max reductions and GPU-to-CPU
|
| 41 |
+
# synchronization when Flash SDPA inspects the jagged sequence lengths.
|
| 42 |
+
return torch.nested.nested_tensor_from_jagged(
|
| 43 |
+
tokens,
|
| 44 |
+
self.offsets,
|
| 45 |
+
min_seqlen=1,
|
| 46 |
+
max_seqlen=self.max_seqlen,
|
| 47 |
+
).transpose(1, 2)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def unpack_tokens(tokens: Tensor, token_mask: Tensor) -> Tensor:
|
| 51 |
+
"""Restore packed values to a padded batch, filling invalid slots with zero."""
|
| 52 |
+
output = tokens.new_zeros((*token_mask.shape, *tokens.shape[1:]))
|
| 53 |
+
return output.index_put((token_mask,), tokens)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def jagged_scaled_dot_product_attention(
|
| 57 |
+
query: Tensor,
|
| 58 |
+
key: Tensor,
|
| 59 |
+
value: Tensor,
|
| 60 |
+
jagged_batch: JaggedBatch,
|
| 61 |
+
) -> Tensor:
|
| 62 |
+
"""Run SDPA on a packed batch of variable-length sequences."""
|
| 63 |
+
output_jagged = F.scaled_dot_product_attention(
|
| 64 |
+
jagged_batch.as_nested(query),
|
| 65 |
+
jagged_batch.as_nested(key),
|
| 66 |
+
jagged_batch.as_nested(value),
|
| 67 |
+
)
|
| 68 |
+
return output_jagged.transpose(1, 2).values()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Attention(nn.Module):
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
dim: int,
|
| 75 |
+
num_heads: int,
|
| 76 |
+
qkv_bias: bool = False,
|
| 77 |
+
proj_bias: bool = False,
|
| 78 |
+
) -> None:
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.num_heads = num_heads
|
| 81 |
+
self.head_dim = dim // num_heads
|
| 82 |
+
self.qkv = nn.Linear(dim, 3 * dim, bias=qkv_bias)
|
| 83 |
+
self.proj = nn.Linear(dim, dim, bias=proj_bias)
|
| 84 |
+
|
| 85 |
+
def extra_repr(self):
|
| 86 |
+
return f"num_heads={self.num_heads}"
|
| 87 |
+
|
| 88 |
+
def forward(
|
| 89 |
+
self,
|
| 90 |
+
x: Float[Tensor, "L D"],
|
| 91 |
+
jagged_batch: JaggedBatch,
|
| 92 |
+
) -> Float[Tensor, "L D"]:
|
| 93 |
+
L, D = x.shape
|
| 94 |
+
h, dh = self.num_heads, self.head_dim
|
| 95 |
+
|
| 96 |
+
qkv = self.qkv(x).reshape(L, 3, h, dh)
|
| 97 |
+
q, k, v = qkv.unbind(1)
|
| 98 |
+
|
| 99 |
+
x = jagged_scaled_dot_product_attention(
|
| 100 |
+
q,
|
| 101 |
+
k,
|
| 102 |
+
v,
|
| 103 |
+
jagged_batch=jagged_batch,
|
| 104 |
+
)
|
| 105 |
+
x = x.reshape(L, D)
|
| 106 |
+
x = self.proj(x)
|
| 107 |
+
return x
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class Mlp(nn.Module):
|
| 111 |
+
def __init__(
|
| 112 |
+
self,
|
| 113 |
+
dim: int,
|
| 114 |
+
mlp_ratio: int | float = 4,
|
| 115 |
+
bias: bool = False,
|
| 116 |
+
) -> None:
|
| 117 |
+
super().__init__()
|
| 118 |
+
hidden_features = int(dim * mlp_ratio)
|
| 119 |
+
self.fc1 = nn.Linear(dim, hidden_features, bias=bias)
|
| 120 |
+
self.act = nn.GELU()
|
| 121 |
+
self.fc2 = nn.Linear(hidden_features, dim, bias=bias)
|
| 122 |
+
|
| 123 |
+
def forward(self, x: Float[Tensor, "... D"]) -> Float[Tensor, "... D"]:
|
| 124 |
+
x = self.fc1(x)
|
| 125 |
+
x = self.act(x)
|
| 126 |
+
x = self.fc2(x)
|
| 127 |
+
return x
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# timm default eps=1e-6
|
| 131 |
+
LayerNorm = partial(nn.LayerNorm, eps=1e-6)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class Block(nn.Module):
|
| 135 |
+
def __init__(
|
| 136 |
+
self,
|
| 137 |
+
dim: int,
|
| 138 |
+
num_heads: int,
|
| 139 |
+
qkv_bias: bool = False,
|
| 140 |
+
proj_bias: bool = False,
|
| 141 |
+
mlp_ratio: int | float = 4,
|
| 142 |
+
drop_path: float = 0.0,
|
| 143 |
+
norm_layer: Layer = LayerNorm,
|
| 144 |
+
) -> None:
|
| 145 |
+
super().__init__()
|
| 146 |
+
self.norm1 = norm_layer(dim)
|
| 147 |
+
self.attn = Attention(
|
| 148 |
+
dim=dim,
|
| 149 |
+
num_heads=num_heads,
|
| 150 |
+
qkv_bias=qkv_bias,
|
| 151 |
+
proj_bias=proj_bias,
|
| 152 |
+
)
|
| 153 |
+
self.drop_path1 = DropPath(drop_path) if drop_path > 0 else nn.Identity()
|
| 154 |
+
|
| 155 |
+
self.norm2 = norm_layer(dim)
|
| 156 |
+
self.mlp = Mlp(
|
| 157 |
+
dim=dim,
|
| 158 |
+
mlp_ratio=mlp_ratio,
|
| 159 |
+
bias=proj_bias,
|
| 160 |
+
)
|
| 161 |
+
self.drop_path2 = DropPath(drop_path) if drop_path > 0 else nn.Identity()
|
| 162 |
+
|
| 163 |
+
def forward(
|
| 164 |
+
self,
|
| 165 |
+
x: Float[Tensor, "L D"],
|
| 166 |
+
jagged_batch: JaggedBatch,
|
| 167 |
+
) -> Float[Tensor, "L D"]:
|
| 168 |
+
x = x + self.drop_path1(
|
| 169 |
+
self.attn(
|
| 170 |
+
self.norm1(x),
|
| 171 |
+
jagged_batch=jagged_batch,
|
| 172 |
+
)
|
| 173 |
+
)
|
| 174 |
+
x = x + self.drop_path2(self.mlp(self.norm2(x)))
|
| 175 |
+
return x
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# Patching and position embedding modules
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class Patchify3D(nn.Module):
|
| 182 |
+
def __init__(
|
| 183 |
+
self,
|
| 184 |
+
img_size: int | tuple[int, int, int],
|
| 185 |
+
patch_size: int | tuple[int, int, int],
|
| 186 |
+
in_chans: int = 3,
|
| 187 |
+
) -> None:
|
| 188 |
+
super().__init__()
|
| 189 |
+
self.img_size = to_3tuple(img_size)
|
| 190 |
+
self.patch_size = to_3tuple(patch_size)
|
| 191 |
+
self.in_chans = in_chans
|
| 192 |
+
|
| 193 |
+
T, H, W = self.img_size
|
| 194 |
+
p_t, p_h, p_w = self.patch_size
|
| 195 |
+
if T % p_t or H % p_h or W % p_w:
|
| 196 |
+
raise ValueError(
|
| 197 |
+
f"img_size {self.img_size} must be divisible by patch_size {self.patch_size}"
|
| 198 |
+
)
|
| 199 |
+
self.grid_size = (T // p_t, H // p_h, W // p_w)
|
| 200 |
+
self.num_patches = math.prod(self.grid_size)
|
| 201 |
+
self.patch_dim = in_chans * math.prod(self.patch_size)
|
| 202 |
+
|
| 203 |
+
def forward(self, x: Float[Tensor, "B C T H W"]) -> Float[Tensor, "B N P"]:
|
| 204 |
+
x = patchify3d(x, self.patch_size)
|
| 205 |
+
return x
|
| 206 |
+
|
| 207 |
+
def unpatchify(self, x: Float[Tensor, "B N P"]) -> Float[Tensor, "B C T H W"]:
|
| 208 |
+
x = unpatchify3d(x, patch_size=self.patch_size, img_size=self.img_size)
|
| 209 |
+
return x
|
| 210 |
+
|
| 211 |
+
def extra_repr(self):
|
| 212 |
+
return f"{self.img_size}, {self.patch_size}, in_chans={self.in_chans}"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def patchify3d(x: Tensor, patch_size: tuple[int, int, int]) -> Tensor:
|
| 216 |
+
p_t, p_h, p_w = to_3tuple(patch_size)
|
| 217 |
+
B, C, T, H, W = x.shape
|
| 218 |
+
x = rearrange(x, "b c (t u) (h p) (w q) -> b (t h w) (c u p q)", u=p_t, p=p_h, q=p_w)
|
| 219 |
+
return x
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def unpatchify3d(
|
| 223 |
+
x: Tensor,
|
| 224 |
+
patch_size: tuple[int, int, int],
|
| 225 |
+
img_size: tuple[int, int, int],
|
| 226 |
+
) -> Tensor:
|
| 227 |
+
B, N, P = x.shape
|
| 228 |
+
p_t, p_h, p_w = to_3tuple(patch_size)
|
| 229 |
+
T, H, W = to_3tuple(img_size)
|
| 230 |
+
x = rearrange(
|
| 231 |
+
x,
|
| 232 |
+
"b (t h w) (c u p q) -> b c (t u) (h p) (w q)",
|
| 233 |
+
t=T // p_t,
|
| 234 |
+
h=H // p_h,
|
| 235 |
+
w=W // p_w,
|
| 236 |
+
u=p_t,
|
| 237 |
+
p=p_h,
|
| 238 |
+
q=p_w,
|
| 239 |
+
)
|
| 240 |
+
return x
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class AbsolutePosEmbed(nn.Module):
|
| 244 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, ...]) -> None:
|
| 245 |
+
super().__init__()
|
| 246 |
+
self.embed_dim = embed_dim
|
| 247 |
+
self.grid_size = grid_size
|
| 248 |
+
self.num_patches = math.prod(grid_size)
|
| 249 |
+
|
| 250 |
+
self.weight = nn.Parameter(torch.empty(self.num_patches, embed_dim))
|
| 251 |
+
self.reset_parameters()
|
| 252 |
+
|
| 253 |
+
def reset_parameters(self):
|
| 254 |
+
nn.init.trunc_normal_(self.weight, std=0.02)
|
| 255 |
+
|
| 256 |
+
def forward(
|
| 257 |
+
self,
|
| 258 |
+
x: Float[Tensor, "B L D"],
|
| 259 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 260 |
+
) -> Float[Tensor, "B L D"]:
|
| 261 |
+
x = apply_pos_embed(x, self.weight, pos_ids=pos_ids)
|
| 262 |
+
return x
|
| 263 |
+
|
| 264 |
+
def extra_repr(self):
|
| 265 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class SeparablePosEmbed(nn.Module):
|
| 269 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, ...]) -> None:
|
| 270 |
+
super().__init__()
|
| 271 |
+
self.embed_dim = embed_dim
|
| 272 |
+
self.grid_size = grid_size
|
| 273 |
+
self.num_patches = math.prod(grid_size)
|
| 274 |
+
|
| 275 |
+
N_t, *grid_size_spatial = grid_size
|
| 276 |
+
N_s = math.prod(grid_size_spatial)
|
| 277 |
+
self.weight_spatial = nn.Parameter(torch.empty(1, N_s, embed_dim))
|
| 278 |
+
self.weight_temporal = nn.Parameter(torch.empty(N_t, 1, embed_dim))
|
| 279 |
+
self.reset_parameters()
|
| 280 |
+
|
| 281 |
+
def reset_parameters(self):
|
| 282 |
+
nn.init.trunc_normal_(self.weight_spatial, std=0.02)
|
| 283 |
+
nn.init.trunc_normal_(self.weight_temporal, std=0.02)
|
| 284 |
+
|
| 285 |
+
def forward(
|
| 286 |
+
self,
|
| 287 |
+
x: Float[Tensor, "B L D"],
|
| 288 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 289 |
+
) -> Float[Tensor, "B L D"]:
|
| 290 |
+
B, N, D = x.shape
|
| 291 |
+
weight = (self.weight_temporal + self.weight_spatial).flatten(0, 1) # [N, D]
|
| 292 |
+
x = apply_pos_embed(x, weight, pos_ids=pos_ids)
|
| 293 |
+
return x
|
| 294 |
+
|
| 295 |
+
def extra_repr(self):
|
| 296 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class SinCosPosEmbed3D(nn.Module):
|
| 300 |
+
def __init__(self, embed_dim: int, grid_size: tuple[int, int, int]) -> None:
|
| 301 |
+
super().__init__()
|
| 302 |
+
self.embed_dim = embed_dim
|
| 303 |
+
self.grid_size = grid_size
|
| 304 |
+
self.num_patches = math.prod(grid_size)
|
| 305 |
+
|
| 306 |
+
N_t, N_h, N_w = grid_size
|
| 307 |
+
weight = get_3d_sincos_pos_embed(
|
| 308 |
+
embed_dim=embed_dim,
|
| 309 |
+
grid_size=(N_h, N_w),
|
| 310 |
+
grid_depth=N_t,
|
| 311 |
+
uniform_power=True,
|
| 312 |
+
)
|
| 313 |
+
self.weight = nn.Parameter(torch.from_numpy(weight).float(), requires_grad=False)
|
| 314 |
+
|
| 315 |
+
def forward(
|
| 316 |
+
self,
|
| 317 |
+
x: Float[Tensor, "B L D"],
|
| 318 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 319 |
+
) -> Float[Tensor, "B L D"]:
|
| 320 |
+
x = apply_pos_embed(x, self.weight, pos_ids=pos_ids)
|
| 321 |
+
return x
|
| 322 |
+
|
| 323 |
+
def extra_repr(self):
|
| 324 |
+
return f"{self.embed_dim}, {self.grid_size}"
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# sincos pos embed utils from vjepa2, but fixed the confusing meshgrid indexing
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def get_3d_sincos_pos_embed(embed_dim, grid_size, grid_depth, cls_token=False, uniform_power=False):
|
| 331 |
+
"""
|
| 332 |
+
grid_size: tuple of int of the grid height and width
|
| 333 |
+
grid_depth: int of the grid depth
|
| 334 |
+
returns:
|
| 335 |
+
pos_embed: [grid_depth*grid_height*grid_width, embed_dim] (w/o cls_token)
|
| 336 |
+
or [1+grid_depth*grid_height*grid_width, embed_dim] (w/ cls_token)
|
| 337 |
+
"""
|
| 338 |
+
grid_d = np.arange(grid_depth, dtype=float)
|
| 339 |
+
grid_h = np.arange(grid_size[0], dtype=float)
|
| 340 |
+
grid_w = np.arange(grid_size[1], dtype=float)
|
| 341 |
+
grid_d, grid_h, grid_w = np.meshgrid(grid_d, grid_h, grid_w, indexing="ij")
|
| 342 |
+
|
| 343 |
+
if not uniform_power:
|
| 344 |
+
h_embed_dim = embed_dim // 4
|
| 345 |
+
w_embed_dim = embed_dim // 4
|
| 346 |
+
d_embed_dim = embed_dim // 2
|
| 347 |
+
else:
|
| 348 |
+
h_embed_dim = w_embed_dim = d_embed_dim = int(np.ceil(embed_dim / 6) * 2)
|
| 349 |
+
|
| 350 |
+
emb_h = get_1d_sincos_pos_embed_from_grid(h_embed_dim, grid_h) # (T*H*W, D1)
|
| 351 |
+
emb_w = get_1d_sincos_pos_embed_from_grid(w_embed_dim, grid_w) # (T*H*W, D2)
|
| 352 |
+
emb_d = get_1d_sincos_pos_embed_from_grid(d_embed_dim, grid_d) # (T*H*W, D3)
|
| 353 |
+
pos_embed = np.concatenate([emb_d, emb_h, emb_w], axis=1)
|
| 354 |
+
pos_embed = pos_embed[:, :embed_dim]
|
| 355 |
+
if cls_token:
|
| 356 |
+
pos_embed = np.concatenate([np.zeros([1, embed_dim]), pos_embed], axis=0)
|
| 357 |
+
return pos_embed
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def get_1d_sincos_pos_embed_from_grid(embed_dim, pos):
|
| 361 |
+
"""
|
| 362 |
+
embed_dim: output dimension for each position
|
| 363 |
+
pos: a list of positions to be encoded: size (M,)
|
| 364 |
+
returns: (M, D)
|
| 365 |
+
"""
|
| 366 |
+
assert embed_dim % 2 == 0
|
| 367 |
+
omega = np.arange(embed_dim // 2, dtype=float)
|
| 368 |
+
omega /= embed_dim / 2.0
|
| 369 |
+
omega = 1.0 / 10000**omega # (D/2,)
|
| 370 |
+
|
| 371 |
+
pos = pos.reshape(-1) # (M,)
|
| 372 |
+
out = np.einsum("m,d->md", pos, omega) # (M, D/2), outer product
|
| 373 |
+
|
| 374 |
+
emb_sin = np.sin(out) # (M, D/2)
|
| 375 |
+
emb_cos = np.cos(out) # (M, D/2)
|
| 376 |
+
|
| 377 |
+
emb = np.concatenate([emb_sin, emb_cos], axis=1) # (M, D)
|
| 378 |
+
return emb
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def apply_pos_embed(
|
| 382 |
+
x: Float[Tensor, "B L D"],
|
| 383 |
+
weight: Float[Tensor, "N D"],
|
| 384 |
+
pos_ids: Int[Tensor, "B L"] | None = None,
|
| 385 |
+
) -> Float[Tensor, "B L D"]:
|
| 386 |
+
B, L, D = x.shape
|
| 387 |
+
weight = weight.expand(B, -1, -1)
|
| 388 |
+
if pos_ids is not None:
|
| 389 |
+
weight = weight.gather(1, pos_ids.unsqueeze(-1).expand(-1, -1, D))
|
| 390 |
+
x = x + weight
|
| 391 |
+
return x
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# (masked) normalization used for MAE target normalization
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class Normalize(nn.Module):
|
| 398 |
+
def __init__(
|
| 399 |
+
self,
|
| 400 |
+
grid_size: tuple[int, ...],
|
| 401 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 402 |
+
eps: float = 1e-6,
|
| 403 |
+
) -> None:
|
| 404 |
+
super().__init__()
|
| 405 |
+
self.grid_size = grid_size
|
| 406 |
+
self.dim = dim
|
| 407 |
+
self.eps = eps
|
| 408 |
+
|
| 409 |
+
def forward(self, x: Tensor, mask: Tensor | None = None) -> tuple[Tensor, Tensor, Tensor]:
|
| 410 |
+
"""
|
| 411 |
+
Normalize input sequence along dim(s) after reshaping to grid.
|
| 412 |
+
Returns tuple of (x, mean, std).
|
| 413 |
+
"""
|
| 414 |
+
B, N, D = x.shape
|
| 415 |
+
x = x.reshape((B, *self.grid_size, D))
|
| 416 |
+
if mask is not None:
|
| 417 |
+
mask = mask.reshape((B, *self.grid_size, D))
|
| 418 |
+
x, mean, std = masked_normalize(x, mask, dim=self.dim, eps=self.eps)
|
| 419 |
+
else:
|
| 420 |
+
x, mean, std = normalize(x, dim=self.dim, eps=self.eps)
|
| 421 |
+
mean = mean.expand_as(x).reshape(B, N, D)
|
| 422 |
+
std = std.expand_as(x).reshape(B, N, D)
|
| 423 |
+
x = x.reshape(B, N, D)
|
| 424 |
+
return x, mean, std
|
| 425 |
+
|
| 426 |
+
def extra_repr(self):
|
| 427 |
+
return f"{self.grid_size}, dim={self.dim}"
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def masked_normalize(
|
| 431 |
+
x: Tensor,
|
| 432 |
+
mask: Tensor,
|
| 433 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 434 |
+
eps: float = 1e-6,
|
| 435 |
+
) -> tuple[Tensor, Tensor, Tensor]:
|
| 436 |
+
num_obs = mask.sum(dim=dim, keepdim=True).clamp(min=1)
|
| 437 |
+
mean = (mask * x).sum(dim=dim, keepdim=True) / num_obs
|
| 438 |
+
var = (mask * (x - mean) ** 2).sum(dim=dim, keepdim=True) / num_obs
|
| 439 |
+
std = (var + eps) ** 0.5
|
| 440 |
+
x = mask * (x - mean) / std
|
| 441 |
+
return x, mean, std
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def normalize(
|
| 445 |
+
x: Tensor,
|
| 446 |
+
dim: int | tuple[int, ...] | None = -1,
|
| 447 |
+
eps: float = 1e-6,
|
| 448 |
+
) -> tuple[Tensor, Tensor, Tensor]:
|
| 449 |
+
mean = x.mean(dim=dim, keepdim=True)
|
| 450 |
+
var = torch.var(x, dim=dim, keepdim=True, unbiased=False)
|
| 451 |
+
std = (var + eps) ** 0.5
|
| 452 |
+
x = (x - mean) / std
|
| 453 |
+
return x, mean, std
|
fomo_tune_baseline/output/task3/build/smri_mae/utils.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright (c) Sophont, Inc
|
| 2 |
+
# This source code is licensed under the Apache License, Version 2.0
|
| 3 |
+
#
|
| 4 |
+
# References:
|
| 5 |
+
# deit: https://github.com/facebookresearch/deit/blob/main/utils.py
|
| 6 |
+
# beit3: https://github.com/microsoft/unilm/blob/master/beit3/utils.py
|
| 7 |
+
# capi: https://github.com/facebookresearch/capi/blob/main/utils.py
|
| 8 |
+
# dinov2: https://github.com/facebookresearch/dinov2/blob/main/dinov2/utils/param_groups.py
|
| 9 |
+
# timm: https://github.com/huggingface/pytorch-image-models/blob/main/timm/utils/cuda.py
|
| 10 |
+
# dino: https://github.com/facebookresearch/dino/blob/main/utils.py
|
| 11 |
+
|
| 12 |
+
import datetime
|
| 13 |
+
import inspect
|
| 14 |
+
import math
|
| 15 |
+
import os
|
| 16 |
+
import random
|
| 17 |
+
import subprocess
|
| 18 |
+
import time
|
| 19 |
+
from collections import defaultdict, deque
|
| 20 |
+
from omegaconf import OmegaConf
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
import torch.distributed as dist
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
from torch import Tensor
|
| 28 |
+
from torch.amp import GradScaler
|
| 29 |
+
from torch.optim import Optimizer
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# these very useful utils copied from deit with only minor changes
|
| 33 |
+
# thanks to the original authors, wherever you are
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def configure_flash_sdpa() -> None:
|
| 37 |
+
"""Use Flash Attention exclusively for CUDA SDPA."""
|
| 38 |
+
torch.backends.cuda.enable_flash_sdp(True)
|
| 39 |
+
torch.backends.cuda.enable_mem_efficient_sdp(False)
|
| 40 |
+
torch.backends.cuda.enable_math_sdp(False)
|
| 41 |
+
torch.backends.cuda.enable_cudnn_sdp(False)
|
| 42 |
+
print("SDPA backend: flash")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class SmoothedValue:
|
| 46 |
+
"""Track a series of values and provide access to smoothed values over a
|
| 47 |
+
window or the global series average.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self, window_size=20, fmt=None):
|
| 51 |
+
if fmt is None:
|
| 52 |
+
fmt = "{median:.4f} ({global_avg:.4f})"
|
| 53 |
+
self.deque = deque(maxlen=window_size)
|
| 54 |
+
self.total = 0.0
|
| 55 |
+
self.count = 0
|
| 56 |
+
self.fmt = fmt
|
| 57 |
+
|
| 58 |
+
def update(self, value, n=1):
|
| 59 |
+
value = float(value)
|
| 60 |
+
if math.isfinite(value):
|
| 61 |
+
self.deque.append(value)
|
| 62 |
+
self.count += n
|
| 63 |
+
self.total += value * n
|
| 64 |
+
|
| 65 |
+
def synchronize_between_processes(self):
|
| 66 |
+
"""
|
| 67 |
+
Warning: does not synchronize the deque!
|
| 68 |
+
"""
|
| 69 |
+
if not is_dist_avail_and_initialized():
|
| 70 |
+
return
|
| 71 |
+
t = torch.tensor([self.count, self.total], dtype=torch.float64, device="cuda")
|
| 72 |
+
dist.barrier()
|
| 73 |
+
dist.all_reduce(t)
|
| 74 |
+
t = t.tolist()
|
| 75 |
+
self.count = int(t[0])
|
| 76 |
+
self.total = t[1]
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def median(self):
|
| 80 |
+
if not self.count:
|
| 81 |
+
return float("nan")
|
| 82 |
+
d = torch.tensor(list(self.deque))
|
| 83 |
+
return d.median().item()
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def avg(self):
|
| 87 |
+
if not self.count:
|
| 88 |
+
return float("nan")
|
| 89 |
+
d = torch.tensor(list(self.deque), dtype=torch.float32)
|
| 90 |
+
return d.mean().item()
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def global_avg(self):
|
| 94 |
+
if not self.count:
|
| 95 |
+
return float("nan")
|
| 96 |
+
return self.total / self.count
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def max(self):
|
| 100 |
+
if not self.count:
|
| 101 |
+
return float("nan")
|
| 102 |
+
return max(self.deque)
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def value(self):
|
| 106 |
+
if not self.count:
|
| 107 |
+
return float("nan")
|
| 108 |
+
return self.deque[-1]
|
| 109 |
+
|
| 110 |
+
def __str__(self):
|
| 111 |
+
return self.fmt.format(
|
| 112 |
+
median=self.median,
|
| 113 |
+
avg=self.avg,
|
| 114 |
+
global_avg=self.global_avg,
|
| 115 |
+
max=self.max,
|
| 116 |
+
value=self.value,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class MetricLogger:
|
| 121 |
+
def __init__(self, delimiter="\t"):
|
| 122 |
+
self.meters = defaultdict(SmoothedValue)
|
| 123 |
+
self.delimiter = delimiter
|
| 124 |
+
|
| 125 |
+
def update(self, **kwargs):
|
| 126 |
+
for k, v in kwargs.items():
|
| 127 |
+
if v is None:
|
| 128 |
+
continue
|
| 129 |
+
if isinstance(v, (torch.Tensor, np.generic)):
|
| 130 |
+
v = v.item()
|
| 131 |
+
assert isinstance(v, (float, int))
|
| 132 |
+
self.meters[k].update(v)
|
| 133 |
+
|
| 134 |
+
def __getattr__(self, attr):
|
| 135 |
+
if attr in self.meters:
|
| 136 |
+
return self.meters[attr]
|
| 137 |
+
if attr in self.__dict__:
|
| 138 |
+
return self.__dict__[attr]
|
| 139 |
+
raise AttributeError("'{}' object has no attribute '{}'".format(type(self).__name__, attr))
|
| 140 |
+
|
| 141 |
+
def __str__(self):
|
| 142 |
+
loss_str = []
|
| 143 |
+
for name, meter in self.meters.items():
|
| 144 |
+
loss_str.append("{}: {}".format(name, str(meter)))
|
| 145 |
+
return self.delimiter.join(loss_str)
|
| 146 |
+
|
| 147 |
+
def synchronize_between_processes(self):
|
| 148 |
+
for meter in self.meters.values():
|
| 149 |
+
meter.synchronize_between_processes()
|
| 150 |
+
|
| 151 |
+
def add_meter(self, name, meter):
|
| 152 |
+
self.meters[name] = meter
|
| 153 |
+
|
| 154 |
+
def log_every(self, iterable, print_freq, header=None, total_steps=None):
|
| 155 |
+
i = 0
|
| 156 |
+
total_steps = total_steps or len(iterable)
|
| 157 |
+
if not header:
|
| 158 |
+
header = ""
|
| 159 |
+
start_time = time.time()
|
| 160 |
+
end = time.time()
|
| 161 |
+
iter_time = SmoothedValue(fmt="{avg:.4f}")
|
| 162 |
+
data_time = SmoothedValue(fmt="{avg:.4f}")
|
| 163 |
+
space_fmt = ":" + str(len(str(total_steps))) + "d"
|
| 164 |
+
log_msg = [
|
| 165 |
+
header,
|
| 166 |
+
"[{0" + space_fmt + "}/{1}]",
|
| 167 |
+
"eta: {eta}",
|
| 168 |
+
"{meters}",
|
| 169 |
+
"time: {time}",
|
| 170 |
+
"data: {data}",
|
| 171 |
+
]
|
| 172 |
+
if torch.cuda.is_available():
|
| 173 |
+
log_msg.append("max mem: {memory:.0f}")
|
| 174 |
+
log_msg = self.delimiter.join(log_msg)
|
| 175 |
+
MB = 1024.0 * 1024.0
|
| 176 |
+
for obj in iterable:
|
| 177 |
+
if i >= total_steps:
|
| 178 |
+
break
|
| 179 |
+
data_time.update(time.time() - end)
|
| 180 |
+
yield obj
|
| 181 |
+
iter_time.update(time.time() - end)
|
| 182 |
+
if i % print_freq == 0 or i == total_steps - 1:
|
| 183 |
+
eta_seconds = iter_time.global_avg * (total_steps - i)
|
| 184 |
+
eta_string = str(datetime.timedelta(seconds=int(eta_seconds)))
|
| 185 |
+
if torch.cuda.is_available():
|
| 186 |
+
print(
|
| 187 |
+
log_msg.format(
|
| 188 |
+
i,
|
| 189 |
+
total_steps,
|
| 190 |
+
eta=eta_string,
|
| 191 |
+
meters=str(self),
|
| 192 |
+
time=str(iter_time),
|
| 193 |
+
data=str(data_time),
|
| 194 |
+
memory=torch.cuda.max_memory_allocated() / MB,
|
| 195 |
+
)
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
else:
|
| 199 |
+
print(
|
| 200 |
+
log_msg.format(
|
| 201 |
+
i,
|
| 202 |
+
total_steps,
|
| 203 |
+
eta=eta_string,
|
| 204 |
+
meters=str(self),
|
| 205 |
+
time=str(iter_time),
|
| 206 |
+
data=str(data_time),
|
| 207 |
+
)
|
| 208 |
+
)
|
| 209 |
+
i += 1
|
| 210 |
+
end = time.time()
|
| 211 |
+
total_time = time.time() - start_time
|
| 212 |
+
total_time_str = str(datetime.timedelta(seconds=int(total_time)))
|
| 213 |
+
print(
|
| 214 |
+
"{} Total time: {} ({:.4f} s / it)".format(
|
| 215 |
+
header, total_time_str, total_time / total_steps
|
| 216 |
+
)
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def setup_for_distributed(log_path=None):
|
| 221 |
+
"""
|
| 222 |
+
This function disables printing when not in master process
|
| 223 |
+
"""
|
| 224 |
+
import builtins as __builtin__
|
| 225 |
+
|
| 226 |
+
builtin_print = __builtin__.print
|
| 227 |
+
|
| 228 |
+
is_master = is_main_process()
|
| 229 |
+
|
| 230 |
+
def print(*args, **kwargs):
|
| 231 |
+
force = kwargs.pop("force", False)
|
| 232 |
+
if is_master or force:
|
| 233 |
+
builtin_print(*args, **kwargs)
|
| 234 |
+
# tee to log file
|
| 235 |
+
if log_path and "file" not in kwargs:
|
| 236 |
+
with open(log_path, "a") as f:
|
| 237 |
+
builtin_print(*args, file=f, **kwargs)
|
| 238 |
+
|
| 239 |
+
__builtin__.print = print
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def is_dist_avail_and_initialized():
|
| 243 |
+
if not dist.is_available():
|
| 244 |
+
return False
|
| 245 |
+
if not dist.is_initialized():
|
| 246 |
+
return False
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def get_world_size():
|
| 251 |
+
if not is_dist_avail_and_initialized():
|
| 252 |
+
return 1
|
| 253 |
+
return dist.get_world_size()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_rank():
|
| 257 |
+
if not is_dist_avail_and_initialized():
|
| 258 |
+
return 0
|
| 259 |
+
return dist.get_rank()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def is_main_process():
|
| 263 |
+
return get_rank() == 0
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def save_on_master(obj, path, *args, **kwargs):
|
| 267 |
+
if is_main_process():
|
| 268 |
+
path = Path(path)
|
| 269 |
+
tmp_path = path.with_name(f".{path.name}.tmp-{os.getpid()}")
|
| 270 |
+
try:
|
| 271 |
+
torch.save(obj, tmp_path, *args, **kwargs)
|
| 272 |
+
os.replace(tmp_path, path)
|
| 273 |
+
except Exception:
|
| 274 |
+
tmp_path.unlink(missing_ok=True)
|
| 275 |
+
raise
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def init_distributed_mode(args):
|
| 279 |
+
# removed slurm block, can add if we use slurm
|
| 280 |
+
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
| 281 |
+
args.rank = int(os.environ["RANK"])
|
| 282 |
+
args.world_size = int(os.environ["WORLD_SIZE"])
|
| 283 |
+
args.gpu = int(os.environ["LOCAL_RANK"])
|
| 284 |
+
else:
|
| 285 |
+
args.distributed = False
|
| 286 |
+
return
|
| 287 |
+
|
| 288 |
+
args.distributed = True
|
| 289 |
+
|
| 290 |
+
torch.cuda.set_device(args.gpu)
|
| 291 |
+
args.dist_backend = "nccl"
|
| 292 |
+
print(f"| distributed init (rank {args.rank})")
|
| 293 |
+
torch.distributed.init_process_group(
|
| 294 |
+
backend=args.dist_backend,
|
| 295 |
+
world_size=args.world_size,
|
| 296 |
+
rank=args.rank,
|
| 297 |
+
device_id=args.gpu,
|
| 298 |
+
)
|
| 299 |
+
torch.distributed.barrier()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# checkpoint saving utils adapted from beit3
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def capture_rng_state() -> dict:
|
| 306 |
+
state = {"torch": torch.get_rng_state()}
|
| 307 |
+
if torch.cuda.is_available():
|
| 308 |
+
state["cuda"] = torch.cuda.get_rng_state()
|
| 309 |
+
return state
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def restore_rng_state(state: dict) -> None:
|
| 313 |
+
torch.set_rng_state(state["torch"])
|
| 314 |
+
if "cuda" in state and torch.cuda.is_available():
|
| 315 |
+
torch.cuda.set_rng_state(state["cuda"])
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _all_rank_rng_states() -> list[dict]:
|
| 319 |
+
local_state = capture_rng_state()
|
| 320 |
+
if not is_dist_avail_and_initialized():
|
| 321 |
+
return [local_state]
|
| 322 |
+
states = [None] * get_world_size()
|
| 323 |
+
dist.all_gather_object(states, local_state)
|
| 324 |
+
return states
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def save_model(args, epoch, model_without_ddp, optimizer, loss_scaler):
|
| 328 |
+
output_dir = Path(args.output_dir)
|
| 329 |
+
checkpoint_path = output_dir / f"checkpoint-{epoch:05d}.pth"
|
| 330 |
+
last_checkpoint_path = output_dir / "checkpoint-last.pth"
|
| 331 |
+
if epoch % args.checkpoint_period != 0 and epoch != args.epochs - 1:
|
| 332 |
+
return
|
| 333 |
+
|
| 334 |
+
to_save = {
|
| 335 |
+
"model": model_without_ddp.state_dict(),
|
| 336 |
+
"optimizer": optimizer.state_dict(),
|
| 337 |
+
"epoch": epoch,
|
| 338 |
+
"scaler": None if loss_scaler is None else loss_scaler.state_dict(),
|
| 339 |
+
"args": OmegaConf.to_container(args),
|
| 340 |
+
"rng_states": _all_rank_rng_states(),
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
print(f"saving checkpoint {last_checkpoint_path}")
|
| 344 |
+
save_on_master(to_save, last_checkpoint_path)
|
| 345 |
+
print(f"saving checkpoint {checkpoint_path}")
|
| 346 |
+
save_on_master(to_save, checkpoint_path)
|
| 347 |
+
|
| 348 |
+
if args.max_checkpoints and is_main_process():
|
| 349 |
+
all_checkpoints = sorted(output_dir.glob("checkpoint-[0-9]*.pth"))
|
| 350 |
+
del_count = max(0, len(all_checkpoints) - args.max_checkpoints)
|
| 351 |
+
for checkpoint_path in all_checkpoints[:del_count]:
|
| 352 |
+
print(f"removing checkpoint {checkpoint_path}")
|
| 353 |
+
checkpoint_path.unlink()
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def load_model(args, model_without_ddp, optimizer, loss_scaler):
|
| 357 |
+
auto_resume = getattr(args, "auto_resume", True)
|
| 358 |
+
output_dir = Path(args.output_dir)
|
| 359 |
+
|
| 360 |
+
last_checkpoint_path = output_dir / "checkpoint-last.pth"
|
| 361 |
+
if auto_resume and last_checkpoint_path.exists():
|
| 362 |
+
args.ckpt = str(last_checkpoint_path)
|
| 363 |
+
args.resume = True
|
| 364 |
+
|
| 365 |
+
if args.ckpt:
|
| 366 |
+
ckpt = torch.load(args.ckpt, map_location="cpu", weights_only=True)
|
| 367 |
+
model_without_ddp.load_state_dict(ckpt["model"])
|
| 368 |
+
print(f"loaded model state from checkpoint {args.ckpt}")
|
| 369 |
+
|
| 370 |
+
if args.resume:
|
| 371 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 372 |
+
if loss_scaler is not None:
|
| 373 |
+
loss_scaler.load_state_dict(ckpt["scaler"])
|
| 374 |
+
args.start_epoch = ckpt["epoch"] + 1
|
| 375 |
+
rng_states = ckpt.get("rng_states")
|
| 376 |
+
if rng_states is not None:
|
| 377 |
+
if len(rng_states) != get_world_size():
|
| 378 |
+
raise ValueError(
|
| 379 |
+
"checkpoint RNG state world size does not match current world size: "
|
| 380 |
+
f"{len(rng_states)} != {get_world_size()}"
|
| 381 |
+
)
|
| 382 |
+
restore_rng_state(rng_states[get_rank()])
|
| 383 |
+
print(f"restored RNG state for rank {get_rank()}")
|
| 384 |
+
print(f"loaded optimizer state, resuming training from {args.start_epoch}")
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# optimization utils
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# from capi
|
| 391 |
+
class WarmupThenCosine:
|
| 392 |
+
def __init__(
|
| 393 |
+
self,
|
| 394 |
+
base_value: float,
|
| 395 |
+
final_value: float,
|
| 396 |
+
total_iters: int,
|
| 397 |
+
warmup_iters: int = 0,
|
| 398 |
+
start_warmup_value: float = 0.0,
|
| 399 |
+
freeze_iters: int = 0,
|
| 400 |
+
truncate_cos: float = 1.0,
|
| 401 |
+
):
|
| 402 |
+
super().__init__()
|
| 403 |
+
self.final_value = final_value
|
| 404 |
+
self.total_iters = total_iters
|
| 405 |
+
|
| 406 |
+
freeze_schedule = np.zeros(freeze_iters)
|
| 407 |
+
|
| 408 |
+
warmup_schedule = np.linspace(start_warmup_value, base_value, warmup_iters)
|
| 409 |
+
|
| 410 |
+
iters = np.arange(total_iters - warmup_iters - freeze_iters)
|
| 411 |
+
schedule = final_value + 0.5 * (base_value - final_value) * (
|
| 412 |
+
1 + np.cos(np.pi * truncate_cos * iters / len(iters))
|
| 413 |
+
)
|
| 414 |
+
self.schedule = np.concatenate((freeze_schedule, warmup_schedule, schedule))
|
| 415 |
+
assert len(self.schedule) == self.total_iters
|
| 416 |
+
|
| 417 |
+
def __getitem__(self, it: int) -> float:
|
| 418 |
+
if it >= self.total_iters:
|
| 419 |
+
return self.final_value
|
| 420 |
+
# cast to float or else it can corrupt the checkpoint
|
| 421 |
+
return float(self.schedule[it])
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
# adapted from timm backward logic
|
| 425 |
+
# https://github.com/huggingface/pytorch-image-models/blob/main/timm/utils/cuda.py
|
| 426 |
+
def backward_step(
|
| 427 |
+
loss: Tensor,
|
| 428 |
+
optimizer: Optimizer,
|
| 429 |
+
scaler: GradScaler = None,
|
| 430 |
+
need_update: bool = True,
|
| 431 |
+
max_norm: float | None = None,
|
| 432 |
+
) -> Tensor | None:
|
| 433 |
+
if scaler is not None:
|
| 434 |
+
scaler.scale(loss).backward()
|
| 435 |
+
else:
|
| 436 |
+
loss.backward()
|
| 437 |
+
|
| 438 |
+
if need_update:
|
| 439 |
+
if scaler is not None:
|
| 440 |
+
scaler.unscale_(optimizer)
|
| 441 |
+
|
| 442 |
+
total_norm = clip_grad(optimizer, max_norm)
|
| 443 |
+
|
| 444 |
+
if scaler is not None:
|
| 445 |
+
scaler.step(optimizer)
|
| 446 |
+
scaler.update()
|
| 447 |
+
else:
|
| 448 |
+
optimizer.step()
|
| 449 |
+
optimizer.zero_grad()
|
| 450 |
+
else:
|
| 451 |
+
total_norm = None
|
| 452 |
+
return total_norm
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def clip_grad(optimizer: Optimizer, max_norm: float | None = None) -> Tensor:
|
| 456 |
+
params = [p for group in optimizer.param_groups for p in group["params"]]
|
| 457 |
+
if max_norm:
|
| 458 |
+
total_norm = nn.utils.clip_grad_norm_(params, max_norm, error_if_nonfinite=False)
|
| 459 |
+
else:
|
| 460 |
+
grads = [p.grad for p in params if p.grad is not None]
|
| 461 |
+
total_norm = nn.utils.get_total_norm(grads, error_if_nonfinite=False)
|
| 462 |
+
torch._assert_async(torch.isfinite(total_norm), "non-finite gradient norm")
|
| 463 |
+
return total_norm
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
# from dinov2 with some minor changes
|
| 467 |
+
def get_param_groups(model, patch_embed_lr_mult=1.0):
|
| 468 |
+
# no lr decay, we could add this later if needed
|
| 469 |
+
all_params = []
|
| 470 |
+
|
| 471 |
+
for name, param in model.named_parameters():
|
| 472 |
+
if not param.requires_grad:
|
| 473 |
+
continue
|
| 474 |
+
d = {"param": param, "lr_multiplier": 1.0, "wd_multiplier": 1.0, "name": name}
|
| 475 |
+
|
| 476 |
+
if name.endswith(".bias") or "norm" in name or "gamma" in name:
|
| 477 |
+
d["wd_multiplier"] = 0.0
|
| 478 |
+
|
| 479 |
+
if "patch_embed" in name:
|
| 480 |
+
d["lr_multiplier"] = d["lr_multiplier"] * patch_embed_lr_mult
|
| 481 |
+
|
| 482 |
+
all_params.append(d)
|
| 483 |
+
|
| 484 |
+
param_groups = _fuse_param_groups(all_params)
|
| 485 |
+
return param_groups
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _fuse_param_groups(all_param_groups):
|
| 489 |
+
fused_param_groups = defaultdict(lambda: {"params": []})
|
| 490 |
+
for d in all_param_groups:
|
| 491 |
+
keys = sorted(set(d.keys()) - {"param", "name"})
|
| 492 |
+
identifier = "_".join(f"{k}{d[k]}" for k in keys)
|
| 493 |
+
for k in keys:
|
| 494 |
+
fused_param_groups[identifier][k] = d[k]
|
| 495 |
+
fused_param_groups[identifier]["params"].append(d["param"])
|
| 496 |
+
|
| 497 |
+
param_groups = list(fused_param_groups.values())
|
| 498 |
+
return param_groups
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def update_lr(param_groups, lr: float):
|
| 502 |
+
for group in param_groups:
|
| 503 |
+
group["lr"] = lr * group["lr_multiplier"]
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def update_wd(param_groups, weight_decay: float | None = None):
|
| 507 |
+
for group in param_groups:
|
| 508 |
+
group["weight_decay"] = weight_decay * group["wd_multiplier"]
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# moving data to cuda utils copied from capi
|
| 512 |
+
# added device argument
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def send_data(x, device=None, dtype_map=None):
|
| 516 |
+
if device is None:
|
| 517 |
+
device = torch.device("cuda")
|
| 518 |
+
else:
|
| 519 |
+
device = torch.device(device)
|
| 520 |
+
|
| 521 |
+
if isinstance(x, torch.Tensor):
|
| 522 |
+
dtype = dtype_map.get(x.dtype) if dtype_map else None
|
| 523 |
+
return x.to(device=device, dtype=dtype, non_blocking=True)
|
| 524 |
+
if isinstance(x, dict):
|
| 525 |
+
return {k: send_data(v, device=device, dtype_map=dtype_map) for k, v in x.items()}
|
| 526 |
+
if isinstance(x, list):
|
| 527 |
+
return [send_data(v, device=device, dtype_map=dtype_map) for v in x]
|
| 528 |
+
return x
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def pre_send_to_cuda_wrapper(generator, device=None, dtype_map=None):
|
| 532 |
+
"""From apex"""
|
| 533 |
+
data = None
|
| 534 |
+
stream = torch.cuda.Stream(device)
|
| 535 |
+
for next_data in generator:
|
| 536 |
+
with torch.cuda.stream(stream):
|
| 537 |
+
next_data = send_data(next_data, device=device, dtype_map=dtype_map)
|
| 538 |
+
if data is not None:
|
| 539 |
+
yield data
|
| 540 |
+
torch.cuda.current_stream(device).wait_stream(stream)
|
| 541 |
+
data = next_data
|
| 542 |
+
if data is not None:
|
| 543 |
+
yield data
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
# other misc utils
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
# from dino
|
| 550 |
+
def get_sha():
|
| 551 |
+
cwd = os.path.dirname(os.path.abspath(__file__))
|
| 552 |
+
|
| 553 |
+
def _run(command):
|
| 554 |
+
return subprocess.check_output(command, cwd=cwd).decode("ascii").strip()
|
| 555 |
+
|
| 556 |
+
sha = "N/A"
|
| 557 |
+
diff = "clean"
|
| 558 |
+
branch = "N/A"
|
| 559 |
+
try:
|
| 560 |
+
sha = _run(["git", "rev-parse", "HEAD"])
|
| 561 |
+
diff = _run(["git", "diff-index", "HEAD"])
|
| 562 |
+
diff = "has uncommitted changes" if diff else "clean"
|
| 563 |
+
branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"])
|
| 564 |
+
except Exception:
|
| 565 |
+
pass
|
| 566 |
+
message = f"sha: {sha}, status: {diff}, branch: {branch}"
|
| 567 |
+
return message
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
# from timm
|
| 571 |
+
def random_seed(seed=42, rank=0):
|
| 572 |
+
torch.manual_seed(seed + rank)
|
| 573 |
+
np.random.seed(seed + rank)
|
| 574 |
+
random.seed(seed + rank)
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# mine :)
|
| 578 |
+
def filter_kwargs(func, kwargs):
|
| 579 |
+
sigature = inspect.signature(func)
|
| 580 |
+
kwargs = {k: v for k, v in kwargs.items() if k in sigature.parameters}
|
| 581 |
+
return kwargs
|
fomo_tune_baseline/output/task3/build/smri_mae/visualization.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections.abc import Mapping
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from matplotlib import patches
|
| 7 |
+
from matplotlib import pyplot as plt
|
| 8 |
+
from PIL import Image
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
VIEW_NAMES = {
|
| 12 |
+
"sagittal": "Sagittal",
|
| 13 |
+
"saggital": "Sagittal",
|
| 14 |
+
"axial": "Axial",
|
| 15 |
+
"coronal": "Coronal",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def fig2pil(fig) -> Image.Image:
|
| 20 |
+
buffer = BytesIO()
|
| 21 |
+
fig.savefig(buffer, format="png", dpi=fig.dpi, facecolor=fig.get_facecolor())
|
| 22 |
+
buffer.seek(0)
|
| 23 |
+
image = Image.open(buffer).convert("RGB")
|
| 24 |
+
buffer.close()
|
| 25 |
+
return image
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def raw_stats_from_batch(batch: dict) -> tuple[Tensor | None, Tensor | None]:
|
| 29 |
+
metas = batch.get("meta")
|
| 30 |
+
if not metas:
|
| 31 |
+
return None, None
|
| 32 |
+
|
| 33 |
+
means = []
|
| 34 |
+
stds = []
|
| 35 |
+
for meta in metas:
|
| 36 |
+
try:
|
| 37 |
+
mean = meta["raw_mean"]
|
| 38 |
+
std = meta["raw_std"]
|
| 39 |
+
except (KeyError, TypeError):
|
| 40 |
+
return None, None
|
| 41 |
+
if mean in ("", None) or std in ("", None):
|
| 42 |
+
return None, None
|
| 43 |
+
means.append(float(mean))
|
| 44 |
+
stds.append(float(std))
|
| 45 |
+
return torch.tensor(means), torch.tensor(stds)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def plot_mask_pred(
|
| 49 |
+
target: Tensor,
|
| 50 |
+
pred: Tensor,
|
| 51 |
+
pred_mask: Tensor | None = None,
|
| 52 |
+
img_mask: Tensor | None = None,
|
| 53 |
+
sample_idx: int = 0,
|
| 54 |
+
channel_idx: int = 0,
|
| 55 |
+
slice_idx: int | Mapping[str, int] | None = None,
|
| 56 |
+
patch_size: int | tuple[int, int, int] = 16,
|
| 57 |
+
views: tuple[str, ...] = ("sagittal", "axial", "coronal"),
|
| 58 |
+
cmap: str = "gray",
|
| 59 |
+
figsize: tuple[float, float] | None = None,
|
| 60 |
+
mask_style: str = "blank",
|
| 61 |
+
raw_mean: float | Tensor | None = None,
|
| 62 |
+
raw_std: float | Tensor | None = None,
|
| 63 |
+
):
|
| 64 |
+
target_vol = _select_volume(target, sample_idx=sample_idx, channel_idx=channel_idx)
|
| 65 |
+
pred_vol = _select_volume(pred, sample_idx=sample_idx, channel_idx=channel_idx)
|
| 66 |
+
if raw_mean is not None and raw_std is not None:
|
| 67 |
+
raw_mean = _select_scalar(raw_mean, sample_idx=sample_idx)
|
| 68 |
+
raw_std = _select_scalar(raw_std, sample_idx=sample_idx)
|
| 69 |
+
target_vol = target_vol * raw_std + raw_mean
|
| 70 |
+
pred_vol = pred_vol * raw_std + raw_mean
|
| 71 |
+
pred_mask_vol = (
|
| 72 |
+
torch.zeros_like(target_vol)
|
| 73 |
+
if pred_mask is None
|
| 74 |
+
else _select_volume(pred_mask, sample_idx=sample_idx, channel_idx=channel_idx) > 0
|
| 75 |
+
)
|
| 76 |
+
img_mask_vol = None
|
| 77 |
+
if img_mask is not None:
|
| 78 |
+
img_mask_vol = _select_volume(img_mask, sample_idx=sample_idx, channel_idx=channel_idx) > 0
|
| 79 |
+
|
| 80 |
+
composite_vol = _prediction_composite(target_vol, pred_vol, pred_mask_vol)
|
| 81 |
+
vmin, vmax = _intensity_limits(target_vol, img_mask_vol)
|
| 82 |
+
|
| 83 |
+
patch_size = _as_3tuple(patch_size)
|
| 84 |
+
view_items = []
|
| 85 |
+
for view in views:
|
| 86 |
+
view_key = view.lower()
|
| 87 |
+
if view_key not in VIEW_NAMES:
|
| 88 |
+
raise ValueError(f"unknown MRI view {view!r}; expected one of {tuple(VIEW_NAMES)}")
|
| 89 |
+
target_slice = _extract_view_slice(target_vol, view_key, slice_idx)
|
| 90 |
+
composite_slice = _extract_view_slice(composite_vol, view_key, slice_idx)
|
| 91 |
+
mask_slice = _extract_view_slice(pred_mask_vol.float(), view_key, slice_idx) > 0
|
| 92 |
+
img_mask_slice = None
|
| 93 |
+
if img_mask_vol is not None:
|
| 94 |
+
img_mask_slice = _extract_view_slice(img_mask_vol.float(), view_key, slice_idx) > 0
|
| 95 |
+
view_items.append(
|
| 96 |
+
{
|
| 97 |
+
"key": view_key,
|
| 98 |
+
"title": VIEW_NAMES[view_key],
|
| 99 |
+
"target": _masked_input_display(target_slice, mask_slice, img_mask_slice, vmin),
|
| 100 |
+
"composite": _apply_display_mask(composite_slice, img_mask_slice, vmin),
|
| 101 |
+
"actual": _apply_display_mask(target_slice, img_mask_slice, vmin),
|
| 102 |
+
"mask": mask_slice,
|
| 103 |
+
"img_mask": img_mask_slice,
|
| 104 |
+
"patch_rc": _view_patch_size(view_key, patch_size),
|
| 105 |
+
}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
_crop_view_items(view_items)
|
| 109 |
+
|
| 110 |
+
fig, axes, layout = _make_figure_canvas(view_items, figsize=figsize)
|
| 111 |
+
for item, x in zip(view_items, layout["col_centers"]):
|
| 112 |
+
fig.text(
|
| 113 |
+
x,
|
| 114 |
+
layout["title_y"],
|
| 115 |
+
item["title"],
|
| 116 |
+
ha="center",
|
| 117 |
+
va="center",
|
| 118 |
+
color="#f8fafc",
|
| 119 |
+
fontsize=7,
|
| 120 |
+
)
|
| 121 |
+
for label, y in zip(("Masked", "Pred", "Actual"), layout["row_centers"]):
|
| 122 |
+
fig.text(layout["label_x"], y, label, ha="right", va="center", color="#cbd5e1", fontsize=6)
|
| 123 |
+
|
| 124 |
+
for item, top_ax, middle_ax, bottom_ax in zip(view_items, axes[0], axes[1], axes[2]):
|
| 125 |
+
top_ax.imshow(
|
| 126 |
+
item["target"],
|
| 127 |
+
cmap=cmap,
|
| 128 |
+
vmin=vmin,
|
| 129 |
+
vmax=vmax,
|
| 130 |
+
interpolation="nearest",
|
| 131 |
+
origin="upper",
|
| 132 |
+
)
|
| 133 |
+
if mask_style == "boxes":
|
| 134 |
+
_draw_patch_boxes(top_ax, item["mask"], item["patch_rc"])
|
| 135 |
+
elif mask_style != "blank":
|
| 136 |
+
raise ValueError("mask_style must be 'blank' or 'boxes'")
|
| 137 |
+
_style_axis(top_ax)
|
| 138 |
+
|
| 139 |
+
middle_ax.imshow(
|
| 140 |
+
item["composite"],
|
| 141 |
+
cmap=cmap,
|
| 142 |
+
vmin=vmin,
|
| 143 |
+
vmax=vmax,
|
| 144 |
+
interpolation="nearest",
|
| 145 |
+
origin="upper",
|
| 146 |
+
)
|
| 147 |
+
_style_axis(middle_ax)
|
| 148 |
+
|
| 149 |
+
bottom_ax.imshow(
|
| 150 |
+
item["actual"],
|
| 151 |
+
cmap=cmap,
|
| 152 |
+
vmin=vmin,
|
| 153 |
+
vmax=vmax,
|
| 154 |
+
interpolation="nearest",
|
| 155 |
+
origin="upper",
|
| 156 |
+
)
|
| 157 |
+
_style_axis(bottom_ax)
|
| 158 |
+
return fig
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _select_volume(
|
| 162 |
+
x: Tensor,
|
| 163 |
+
sample_idx: int = 0,
|
| 164 |
+
channel_idx: int = 0,
|
| 165 |
+
) -> Tensor:
|
| 166 |
+
x = x.detach().float().cpu()
|
| 167 |
+
if x.ndim == 5:
|
| 168 |
+
return x[sample_idx, channel_idx]
|
| 169 |
+
if x.ndim == 4:
|
| 170 |
+
return x[sample_idx]
|
| 171 |
+
if x.ndim == 3:
|
| 172 |
+
return x
|
| 173 |
+
raise ValueError(f"expected a 3D volume tensor, got shape {tuple(x.shape)}")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _select_scalar(value: float | Tensor, sample_idx: int = 0) -> float:
|
| 177 |
+
if isinstance(value, Tensor):
|
| 178 |
+
value = value.detach().float().cpu()
|
| 179 |
+
if value.ndim > 0:
|
| 180 |
+
value = value.reshape(-1)[sample_idx]
|
| 181 |
+
return float(value)
|
| 182 |
+
return float(value)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _prediction_composite(target: Tensor, pred: Tensor, pred_mask: Tensor) -> Tensor:
|
| 186 |
+
pred_mask = pred_mask.to(dtype=target.dtype)
|
| 187 |
+
return target * (1 - pred_mask) + pred * pred_mask
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def _extract_view_slice(
|
| 191 |
+
volume: Tensor,
|
| 192 |
+
view: str,
|
| 193 |
+
slice_idx: int | Mapping[str, int] | None = None,
|
| 194 |
+
) -> Tensor:
|
| 195 |
+
if isinstance(slice_idx, Mapping):
|
| 196 |
+
slice_idx = slice_idx.get(view)
|
| 197 |
+
|
| 198 |
+
if view in ("sagittal", "saggital"):
|
| 199 |
+
idx = _resolve_slice_idx(volume.shape[0], slice_idx)
|
| 200 |
+
return volume[idx, :, :].transpose(0, 1).flip(0)
|
| 201 |
+
if view == "axial":
|
| 202 |
+
idx = _resolve_slice_idx(volume.shape[2], slice_idx)
|
| 203 |
+
return volume[:, :, idx].transpose(0, 1).flip(0)
|
| 204 |
+
if view == "coronal":
|
| 205 |
+
idx = _resolve_slice_idx(volume.shape[1], slice_idx)
|
| 206 |
+
return volume[:, idx, :].transpose(0, 1).flip(0)
|
| 207 |
+
raise ValueError(f"unknown MRI view {view!r}")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _resolve_slice_idx(size: int, slice_idx: int | None) -> int:
|
| 211 |
+
idx = size // 2 if slice_idx is None else int(slice_idx)
|
| 212 |
+
if idx < 0:
|
| 213 |
+
idx += size
|
| 214 |
+
if idx < 0 or idx >= size:
|
| 215 |
+
raise IndexError(f"slice index {idx} is out of bounds for axis with size {size}")
|
| 216 |
+
return idx
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _intensity_limits(volume: Tensor, mask: Tensor | None = None) -> tuple[float, float]:
|
| 220 |
+
values = volume[mask] if mask is not None and mask.any() else volume.flatten()
|
| 221 |
+
values = values[torch.isfinite(values)]
|
| 222 |
+
if values.numel() == 0:
|
| 223 |
+
return 0.0, 1.0
|
| 224 |
+
if values.numel() < 32:
|
| 225 |
+
vmin = values.min()
|
| 226 |
+
vmax = values.max()
|
| 227 |
+
else:
|
| 228 |
+
vmin, vmax = torch.quantile(values, torch.tensor([0.005, 0.995]))
|
| 229 |
+
if torch.isclose(vmin, vmax):
|
| 230 |
+
delta = max(abs(float(vmin)) * 0.05, 1.0)
|
| 231 |
+
return float(vmin) - delta, float(vmax) + delta
|
| 232 |
+
return float(vmin), float(vmax)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _apply_display_mask(image: Tensor, mask: Tensor | None, fill_value: float) -> Tensor:
|
| 236 |
+
if mask is None:
|
| 237 |
+
return image
|
| 238 |
+
return torch.where(mask, image, torch.full_like(image, fill_value))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _masked_input_display(
|
| 242 |
+
image: Tensor,
|
| 243 |
+
pred_mask: Tensor,
|
| 244 |
+
img_mask: Tensor | None,
|
| 245 |
+
fill_value: float,
|
| 246 |
+
) -> Tensor:
|
| 247 |
+
display = torch.where(pred_mask, torch.full_like(image, fill_value), image)
|
| 248 |
+
return _apply_display_mask(display, img_mask, fill_value)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _crop_view_items(view_items: list[dict]) -> None:
|
| 252 |
+
for item in view_items:
|
| 253 |
+
mask = item["img_mask"]
|
| 254 |
+
if mask is None:
|
| 255 |
+
mask = item["actual"] != item["actual"].min()
|
| 256 |
+
row_slice, col_slice = _content_crop(mask, item["patch_rc"])
|
| 257 |
+
for key in ("target", "composite", "actual", "mask"):
|
| 258 |
+
item[key] = item[key][row_slice, col_slice]
|
| 259 |
+
if item["img_mask"] is not None:
|
| 260 |
+
item["img_mask"] = item["img_mask"][row_slice, col_slice]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _content_crop(mask: Tensor, patch_size: tuple[int, int]) -> tuple[slice, slice]:
|
| 264 |
+
mask = mask.detach().cpu() > 0
|
| 265 |
+
if not mask.any():
|
| 266 |
+
return slice(None), slice(None)
|
| 267 |
+
|
| 268 |
+
rows, cols = mask.nonzero(as_tuple=True)
|
| 269 |
+
patch_h, patch_w = patch_size
|
| 270 |
+
height, width = mask.shape
|
| 271 |
+
row0 = max((int(rows.min()) // patch_h - 1) * patch_h, 0)
|
| 272 |
+
row1 = min((int(rows.max()) // patch_h + 2) * patch_h, height)
|
| 273 |
+
col0 = max((int(cols.min()) // patch_w - 1) * patch_w, 0)
|
| 274 |
+
col1 = min((int(cols.max()) // patch_w + 2) * patch_w, width)
|
| 275 |
+
return slice(row0, row1), slice(col0, col1)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _as_3tuple(value: int | tuple[int, int, int]) -> tuple[int, int, int]:
|
| 279 |
+
if isinstance(value, int):
|
| 280 |
+
return (value, value, value)
|
| 281 |
+
if len(value) != 3:
|
| 282 |
+
raise ValueError(f"expected a 3-tuple patch size, got {value!r}")
|
| 283 |
+
return tuple(int(v) for v in value)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def _view_patch_size(view: str, patch_size: tuple[int, int, int]) -> tuple[int, int]:
|
| 287 |
+
p_x, p_y, p_z = patch_size
|
| 288 |
+
if view in ("sagittal", "saggital"):
|
| 289 |
+
return p_z, p_y
|
| 290 |
+
if view == "axial":
|
| 291 |
+
return p_y, p_x
|
| 292 |
+
if view == "coronal":
|
| 293 |
+
return p_z, p_x
|
| 294 |
+
raise ValueError(f"unknown MRI view {view!r}")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def _make_figure_canvas(
|
| 298 |
+
view_items: list[dict[str, Tensor | str | tuple[int, int]]],
|
| 299 |
+
figsize: tuple[float, float] | None = None,
|
| 300 |
+
):
|
| 301 |
+
dpi = 160
|
| 302 |
+
left = 58
|
| 303 |
+
right = 6
|
| 304 |
+
top = 18
|
| 305 |
+
bottom = 8
|
| 306 |
+
row_gap = 14
|
| 307 |
+
col_gap = 8
|
| 308 |
+
widths = [int(item["target"].shape[1]) for item in view_items]
|
| 309 |
+
heights = [int(item["target"].shape[0]) for item in view_items]
|
| 310 |
+
row_h = max(heights)
|
| 311 |
+
num_rows = 3
|
| 312 |
+
fig_w = left + right + sum(widths) + col_gap * (len(widths) - 1)
|
| 313 |
+
fig_h = top + bottom + row_h * num_rows + row_gap * (num_rows - 1)
|
| 314 |
+
|
| 315 |
+
scale = 1.35
|
| 316 |
+
if figsize is not None:
|
| 317 |
+
requested_w = figsize[0] * dpi
|
| 318 |
+
requested_h = figsize[1] * dpi
|
| 319 |
+
scale = max(requested_w / fig_w, requested_h / fig_h)
|
| 320 |
+
figsize = (fig_w * scale / dpi, fig_h * scale / dpi)
|
| 321 |
+
fig = plt.figure(figsize=figsize, dpi=dpi, facecolor="#0b0f14")
|
| 322 |
+
|
| 323 |
+
axes = [[] for _ in range(num_rows)]
|
| 324 |
+
col_centers = []
|
| 325 |
+
x = left
|
| 326 |
+
for width, height in zip(widths, heights):
|
| 327 |
+
col_centers.append((x + width / 2) / fig_w)
|
| 328 |
+
ys = [
|
| 329 |
+
bottom + (num_rows - row - 1) * (row_h + row_gap) + (row_h - height) / 2
|
| 330 |
+
for row in range(num_rows)
|
| 331 |
+
]
|
| 332 |
+
for row, y in enumerate(ys):
|
| 333 |
+
axes[row].append(
|
| 334 |
+
fig.add_axes(
|
| 335 |
+
[
|
| 336 |
+
x / fig_w,
|
| 337 |
+
y / fig_h,
|
| 338 |
+
width / fig_w,
|
| 339 |
+
height / fig_h,
|
| 340 |
+
],
|
| 341 |
+
facecolor="black",
|
| 342 |
+
)
|
| 343 |
+
)
|
| 344 |
+
x += width + col_gap
|
| 345 |
+
row_centers = [
|
| 346 |
+
(bottom + (num_rows - row - 1) * (row_h + row_gap) + row_h / 2) / fig_h
|
| 347 |
+
for row in range(num_rows)
|
| 348 |
+
]
|
| 349 |
+
layout = {
|
| 350 |
+
"col_centers": col_centers,
|
| 351 |
+
"row_centers": row_centers,
|
| 352 |
+
"label_x": (left - 8) / fig_w,
|
| 353 |
+
"title_y": (fig_h - top / 2) / fig_h,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
return fig, axes, layout
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _style_axis(ax) -> None:
|
| 360 |
+
ax.set_xticks([])
|
| 361 |
+
ax.set_yticks([])
|
| 362 |
+
for spine in ax.spines.values():
|
| 363 |
+
spine.set_visible(False)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _draw_patch_boxes(
|
| 367 |
+
ax,
|
| 368 |
+
mask: Tensor,
|
| 369 |
+
patch_size: tuple[int, int],
|
| 370 |
+
color: str = "#facc15",
|
| 371 |
+
) -> None:
|
| 372 |
+
for col, row, width, height in _patch_rectangles(mask, patch_size):
|
| 373 |
+
ax.add_patch(
|
| 374 |
+
patches.Rectangle(
|
| 375 |
+
(col - 0.5, row - 0.5),
|
| 376 |
+
width,
|
| 377 |
+
height,
|
| 378 |
+
fill=False,
|
| 379 |
+
edgecolor=color,
|
| 380 |
+
linewidth=0.75,
|
| 381 |
+
alpha=0.95,
|
| 382 |
+
)
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
def _patch_rectangles(
|
| 387 |
+
mask: Tensor,
|
| 388 |
+
patch_size: tuple[int, int],
|
| 389 |
+
) -> list[tuple[int, int, int, int]]:
|
| 390 |
+
mask = mask.detach().cpu() > 0
|
| 391 |
+
patch_h, patch_w = patch_size
|
| 392 |
+
height, width = mask.shape
|
| 393 |
+
rectangles = []
|
| 394 |
+
for row in range(0, height, patch_h):
|
| 395 |
+
box_h = min(patch_h, height - row)
|
| 396 |
+
for col in range(0, width, patch_w):
|
| 397 |
+
box_w = min(patch_w, width - col)
|
| 398 |
+
if mask[row : row + box_h, col : col + box_w].any():
|
| 399 |
+
rectangles.append((col, row, box_w, box_h))
|
| 400 |
+
return rectangles
|
fomo_tune_baseline/output/task3/config.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
task: task3
|
| 2 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 3 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 4 |
+
name: task3
|
| 5 |
+
device: cuda
|
| 6 |
+
seed: 4466
|
fomo_tune_baseline/output/task3/log.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
16:34:12 run task3 (git 1df2e5d)
|
| 2 |
+
16:34:12 config:
|
| 3 |
+
task: task3
|
| 4 |
+
ckpt_path: /data/mihir-stuff/smri-pretrained/pretrain_full_90_10_h100/checkpoint-last.pth
|
| 5 |
+
output_root: experiments/fomo_tune_baseline/output
|
| 6 |
+
name: task3
|
| 7 |
+
device: cuda
|
| 8 |
+
seed: 4466
|
| 9 |
+
16:35:16 dataset: 494 subjects, age 19-80 mean 45.2
|
| 10 |
+
16:38:39 fold 1/20 n=25 mae=3.91 (190s)
|
| 11 |
+
16:38:51 fold 2/20 n=25 mae=4.50 (202s)
|
| 12 |
+
16:38:57 fold 3/20 n=25 mae=3.74 (208s)
|
| 13 |
+
16:39:03 fold 4/20 n=25 mae=3.58 (214s)
|
| 14 |
+
16:39:09 fold 5/20 n=25 mae=3.57 (220s)
|
| 15 |
+
16:39:14 fold 6/20 n=25 mae=3.12 (226s)
|
| 16 |
+
16:39:20 fold 7/20 n=25 mae=3.42 (232s)
|
| 17 |
+
16:39:26 fold 8/20 n=25 mae=3.12 (238s)
|
| 18 |
+
16:39:32 fold 9/20 n=25 mae=3.74 (244s)
|
| 19 |
+
16:39:38 fold 10/20 n=25 mae=4.20 (249s)
|
| 20 |
+
16:39:44 fold 11/20 n=25 mae=3.22 (255s)
|
| 21 |
+
16:39:50 fold 12/20 n=25 mae=3.50 (261s)
|
| 22 |
+
16:39:55 fold 13/20 n=25 mae=3.39 (267s)
|
| 23 |
+
16:40:01 fold 14/20 n=25 mae=4.17 (273s)
|
| 24 |
+
16:40:07 fold 15/20 n=24 mae=4.36 (278s)
|
| 25 |
+
16:40:12 fold 16/20 n=24 mae=4.15 (284s)
|
| 26 |
+
16:40:18 fold 17/20 n=24 mae=3.66 (289s)
|
| 27 |
+
16:40:23 fold 18/20 n=24 mae=3.54 (295s)
|
| 28 |
+
16:40:29 fold 19/20 n=24 mae=3.42 (300s)
|
| 29 |
+
16:40:34 fold 20/20 n=24 mae=3.53 (306s)
|
| 30 |
+
16:40:35 result: pearson_r=0.9631 pearson_r_ci_low=0.9567 pearson_r_ci_high=0.9687 mae=3.6910 mae_ci_low=3.4464 mae_ci_high=3.9466 (306s)
|
fomo_tune_baseline/output/task3/metrics.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"name": "task3", "pearson_r": 0.9630771532969529, "pearson_r_ci_low": 0.9567144985968425, "pearson_r_ci_high": 0.9687044710309238, "mae": 3.6910024558001684, "mae_ci_low": 3.446356968454986, "mae_ci_high": 3.946628273643462, "run_time": 306.1}
|