init
Browse files- .gitignore +62 -0
- README.md +214 -3
- auto_detect_breast_mri/__init__.py +1 -0
- auto_detect_breast_mri/config.py +244 -0
- auto_detect_breast_mri/evaluation/__init__.py +1 -0
- auto_detect_breast_mri/evaluation/cluster_bootstrap.py +303 -0
- auto_detect_breast_mri/evaluation/dataset_stats.py +238 -0
- auto_detect_breast_mri/evaluation/gradcam.py +299 -0
- auto_detect_breast_mri/evaluation/metrics.py +459 -0
- auto_detect_breast_mri/evaluation/oof_table.py +327 -0
- auto_detect_breast_mri/evaluation/subgroups.py +186 -0
- auto_detect_breast_mri/models/__init__.py +1 -0
- auto_detect_breast_mri/models/checkpoints.py +53 -0
- auto_detect_breast_mri/models/resnets.py +30 -0
- auto_detect_breast_mri/models/unet_crop.py +290 -0
- auto_detect_breast_mri/preprocessing/__init__.py +1 -0
- auto_detect_breast_mri/preprocessing/anonymize_dicom.py +288 -0
- auto_detect_breast_mri/preprocessing/split_breasts.py +337 -0
- auto_detect_breast_mri/training/__init__.py +1 -0
- auto_detect_breast_mri/training/cli.py +65 -0
- auto_detect_breast_mri/training/loops.py +300 -0
- auto_detect_breast_mri/training/lr_scheduler.py +65 -0
- config.example.yaml +57 -0
- pyproject.toml +25 -0
- requirements.txt +26 -0
- tests/test_augmentation.py +144 -0
- tests/test_dim_comparison.py +87 -0
- tests/test_training.py +7 -0
- tools/check_no_private_paths.py +202 -0
- tools/check_split_fractions.py +78 -0
- tools/make_synthetic_dataset.py +358 -0
.gitignore
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- site configuration (never commit: contains absolute paths of your machine/cluster) ---------
|
| 2 |
+
# config.hpc.yaml, config.local.yaml, ... all stay local; only the example is tracked.
|
| 3 |
+
config*.yaml
|
| 4 |
+
config*.yml
|
| 5 |
+
!config.example.yaml
|
| 6 |
+
jobs/site.env
|
| 7 |
+
*.sbatch
|
| 8 |
+
!*.sbatch.example
|
| 9 |
+
.env
|
| 10 |
+
|
| 11 |
+
# --- patient data and anything derived from it --------------------------------------------------
|
| 12 |
+
# Nothing that could carry patient information belongs in this repository. If you ever need to
|
| 13 |
+
# commit a small synthetic file, add it explicitly with `git add -f`.
|
| 14 |
+
*.nii
|
| 15 |
+
*.nii.gz
|
| 16 |
+
*.dcm
|
| 17 |
+
*.dicom
|
| 18 |
+
*.csv
|
| 19 |
+
*.tsv
|
| 20 |
+
*.xlsx
|
| 21 |
+
*.xls
|
| 22 |
+
data/
|
| 23 |
+
nifti/
|
| 24 |
+
synthetic_data/
|
| 25 |
+
tools/rsync_test_folders.py
|
| 26 |
+
|
| 27 |
+
# --- run artefacts -----------------------------------------------------------------------------
|
| 28 |
+
outputs/
|
| 29 |
+
*.pth
|
| 30 |
+
*.pt
|
| 31 |
+
*.ckpt
|
| 32 |
+
*.pkl
|
| 33 |
+
*.png
|
| 34 |
+
*.pdf
|
| 35 |
+
*.svg
|
| 36 |
+
wandb/
|
| 37 |
+
lightning_logs/
|
| 38 |
+
output_files/
|
| 39 |
+
slurm-*.out
|
| 40 |
+
|
| 41 |
+
# --- python ------------------------------------------------------------------------------------
|
| 42 |
+
__pycache__/
|
| 43 |
+
*.py[cod]
|
| 44 |
+
*.egg-info/
|
| 45 |
+
build/
|
| 46 |
+
dist/
|
| 47 |
+
.eggs/
|
| 48 |
+
.venv/
|
| 49 |
+
venv/
|
| 50 |
+
env/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
.mypy_cache/
|
| 53 |
+
.ruff_cache/
|
| 54 |
+
.coverage
|
| 55 |
+
htmlcov/
|
| 56 |
+
.ipynb_checkpoints/
|
| 57 |
+
|
| 58 |
+
# --- editors / os ------------------------------------------------------------------------------
|
| 59 |
+
.idea/
|
| 60 |
+
.vscode/
|
| 61 |
+
*.swp
|
| 62 |
+
.DS_Store
|
README.md
CHANGED
|
@@ -1,3 +1,214 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# auto_detect_breast_mri
|
| 2 |
+
|
| 3 |
+
Malignancy classification on breast MRI, comparing an **abbreviated protocol (AP)** against the
|
| 4 |
+
**full dynamic protocol (FDP)**. The importable package is `auto_detect_breast_mri`; every command
|
| 5 |
+
line entry point lives in `scripts/`.
|
| 6 |
+
|
| 7 |
+
"AP" and "abrv" throughout this repository means *abbreviated protocol*: the native scan plus the first
|
| 8 |
+
subtraction image, instead of the full dynamic series, which is referred to as "FDP" or "full".
|
| 9 |
+
|
| 10 |
+
## Getting started
|
| 11 |
+
|
| 12 |
+
With [uv](https://docs.astral.sh/uv/), which reads `pyproject.toml` and installs the package itself:
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
uv sync # creates .venv from uv.lock, package included
|
| 16 |
+
cp config.example.yaml config.yaml # config*.yaml is gitignored
|
| 17 |
+
$EDITOR config.yaml # fill in your paths
|
| 18 |
+
export AIMRI_CONFIG=$PWD/config.yaml
|
| 19 |
+
uv run python scripts/train/train.py resnet18_abrv -b 2 -n 1 -c 0 -d 0 -t -e
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
With conda or plain pip instead:
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
pip install -r requirements.txt # or: conda env create -f ai_mri.yml
|
| 26 |
+
pip install -e . --no-deps # makes `import auto_detect_breast_mri` work
|
| 27 |
+
cp config.example.yaml config.yaml
|
| 28 |
+
$EDITOR config.yaml
|
| 29 |
+
export AIMRI_CONFIG=$PWD/config.yaml
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
Both routes read the same two files: dependencies come from `requirements.txt` and the version from
|
| 33 |
+
`auto_detect_breast_mri/__init__.py`, which `pyproject.toml` pulls in dynamically.
|
| 34 |
+
|
| 35 |
+
On a GPU cluster, install the torch build that matches the site's CUDA before `uv sync` (or point uv
|
| 36 |
+
at the matching index), otherwise the default PyPI wheel decides which CUDA runtime you get.
|
| 37 |
+
|
| 38 |
+
The editable install is what lets the entry points under `scripts/` find the package: Python puts
|
| 39 |
+
the script's own directory on `sys.path`, never the working directory, so without it every script
|
| 40 |
+
fails with `ModuleNotFoundError: No module named 'auto_detect_breast_mri'`. `--no-deps` keeps pip
|
| 41 |
+
from reinstalling torch and friends over an environment that already works. `export PYTHONPATH=$PWD`
|
| 42 |
+
from the repository root does the same for a single shell, without installing anything; that is what
|
| 43 |
+
`jobs/cross_validation.sbatch.example` does.
|
| 44 |
+
|
| 45 |
+
Everything site specific — paths, cluster account, wandb project, CSV column names — lives in that
|
| 46 |
+
YAML file, never in the code. Command line arguments always take precedence over it, so an
|
| 47 |
+
invocation that passes all paths explicitly needs no config file at all:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
python scripts/train/train.py resnet18_abrv <data_root> <metadata_file> <split_root> -b 32 -n 60 -c 0 -d 0 -t -e
|
| 51 |
+
python scripts/train/train.py resnet18_abrv -b 32 -n 60 -c 0 -d 0 -t -e # same, paths from the config
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
`$AIMRI_CONFIG` may point anywhere, so several site configs can live side by side (for example
|
| 55 |
+
`config.hpc.yaml` for the cluster and `config.local.yaml` for a workstation). Only `config.yaml` and
|
| 56 |
+
`config.yml` in the repository root are found automatically.
|
| 57 |
+
|
| 58 |
+
### Try it without patient data
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
python tools/make_synthetic_dataset.py --out synthetic_data --masks
|
| 62 |
+
export AIMRI_CONFIG=synthetic_data/config.yaml
|
| 63 |
+
python scripts/train/train.py resnet18_abrv -b 2 -n 1 -c 0 -d 0 -t -e
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
This writes eight synthetic examinations in **both** supported folder layouts, a matching metadata
|
| 67 |
+
export and split files, plus a ready to use config. The volumes are random noise with a
|
| 68 |
+
label-dependent blob, so a model can actually overfit them. CI runs the tests against this same
|
| 69 |
+
dataset.
|
| 70 |
+
|
| 71 |
+
## Data contract
|
| 72 |
+
|
| 73 |
+
### Image folders
|
| 74 |
+
|
| 75 |
+
Two layouts are supported and may be mixed under one `data_root` (`data_root` may also be a comma
|
| 76 |
+
separated list of roots):
|
| 77 |
+
|
| 78 |
+
**Bilateral** — one folder per examination, side encoded in the file name:
|
| 79 |
+
|
| 80 |
+
```
|
| 81 |
+
<data_root>/<examination_id>/
|
| 82 |
+
L-Dyn_0.nii.gz L-Sub_1.nii.gz L-Sub_2.nii.gz ... L-T2.nii.gz
|
| 83 |
+
R-Dyn_0.nii.gz R-Sub_1.nii.gz ...
|
| 84 |
+
L-mask_breast_nn.nii.gz R-mask_breast_nn.nii.gz # only for segmentation/cropping
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
**Unilateral** — one folder per breast, side in the folder name, `Pre`/`Post_X` file names:
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
<data_root>/<examination_id>_left/
|
| 91 |
+
Pre.nii.gz Post_1.nii.gz Sub_1.nii.gz ... T2.nii.gz
|
| 92 |
+
mask_breast_nn.nii.gz # only for segmentation/cropping
|
| 93 |
+
<data_root>/<examination_id>_right/
|
| 94 |
+
...
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
`Pre` is mapped onto `Dyn_0` and `Post_X` onto `Dyn_X`, so both layouts end up with the same
|
| 98 |
+
canonical sequence names. A folder name ending in `01` is also matched against the ID without those
|
| 99 |
+
two digits, which some exports append.
|
| 100 |
+
|
| 101 |
+
### Sequence and protocol names
|
| 102 |
+
|
| 103 |
+
Canonical sequence names: `Dyn_0` (native), `Dyn_1` … `Dyn_4` (post contrast), `Sub_1` … `Sub_4`
|
| 104 |
+
(subtraction, i.e. `Dyn_X` − `Dyn_0`) and `T2`.
|
| 105 |
+
|
| 106 |
+
| protocol | expands to |
|
| 107 |
+
|-----------------|---------------------------------------------------|
|
| 108 |
+
| `abbreviated` | `Dyn_0`, `Sub_1` |
|
| 109 |
+
| `full` | `Dyn_0`, `Sub_1`, `Sub_2`, `Sub_3`, `Sub_4`, `T2` |
|
| 110 |
+
| `sub` | `Sub_1` |
|
| 111 |
+
| a list of names | exactly those, in the given order |
|
| 112 |
+
|
| 113 |
+
`Dyn_0` must come first when present; the model's input channel count equals the number of
|
| 114 |
+
sequences. Model names encode the protocol: `*_abrv` → abbreviated, `*_full` → full, `*_sub` → sub.
|
| 115 |
+
|
| 116 |
+
### Metadata export
|
| 117 |
+
|
| 118 |
+
One row per examination, given as `metadata_file` (`.csv` or `.xlsx`), with per-side label columns:
|
| 119 |
+
|
| 120 |
+
| exam_id | exam_id | pat_id | Malign_Left | Malign_Right |
|
| 121 |
+
|---------|---------|-----------|-------------|--------------|
|
| 122 |
+
| 10001 | 10001 | 90001 | 0 | 1 |
|
| 123 |
+
|
| 124 |
+
### Split files
|
| 125 |
+
|
| 126 |
+
One row per breast, under `split_root`, one folder per outer fold. `<k>` is the outer fold, `<s>` the
|
| 127 |
+
inner (validation) fold; the test set has no inner fold:
|
| 128 |
+
|
| 129 |
+
```
|
| 130 |
+
<split_root>/fold<k>/stratified_training_set-f<k>-<s>.csv
|
| 131 |
+
<split_root>/fold<k>/stratified_evaluation_set-f<k>-<s>.csv
|
| 132 |
+
<split_root>/fold<k>/stratified_test_set-f<k>.csv
|
| 133 |
+
<split_root>/fold<k>/stratified_training_set-f<k>-<s>_frac0.25.csv # training fractions
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
| exam_id | pat_id | Side | Malign |
|
| 137 |
+
|---------|-----------|-------|--------|
|
| 138 |
+
| 10001 | 90001 | Left | 0 |
|
| 139 |
+
| 10001 | 90001 | Right | 1 |
|
| 140 |
+
|
| 141 |
+
The **first column is read positionally** as the examination ID and must match the image folder
|
| 142 |
+
names. Splits are grouped by patient ID, so no patient appears in two folds.
|
| 143 |
+
|
| 144 |
+
### Column names are configurable
|
| 145 |
+
|
| 146 |
+
The column names above are the defaults of the original UKA export. Point the code at a differently
|
| 147 |
+
named export with the `columns:` block of your config file — no code change needed:
|
| 148 |
+
|
| 149 |
+
```yaml
|
| 150 |
+
columns:
|
| 151 |
+
examination_id: StudyID # in the split files
|
| 152 |
+
metadata_examination_id: StudyID # in the metadata export
|
| 153 |
+
patient_id: PatientID
|
| 154 |
+
side: Side
|
| 155 |
+
criterion: Cancer
|
| 156 |
+
criterion_left: Cancer_Left
|
| 157 |
+
criterion_right: Cancer_Right
|
| 158 |
+
side_left: L
|
| 159 |
+
side_right: R
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
## Pipeline
|
| 163 |
+
|
| 164 |
+
1. **Preprocess** — crop with the trained UNet and split each volume into left/right halves:
|
| 165 |
+
`python -m auto_detect_breast_mri.preprocessing.split_breasts --unet-path <unet.pth>`
|
| 166 |
+
(raw DICOM first goes through `auto_detect_breast_mri.preprocessing.anonymize_dicom`).
|
| 167 |
+
2. **Split** — patient-grouped, label-stratified folds plus nested training fractions:
|
| 168 |
+
`python scripts/data_utils/make_splits.py` and `python scripts/data_utils/make_split_fractions.py`
|
| 169 |
+
(verify the nesting with `python tools/check_split_fractions.py`).
|
| 170 |
+
3. **Train** — one model per fold: `python scripts/train/train.py <model_name> -c <fold> -d <subfold> -t -e`
|
| 171 |
+
On SLURM: `python jobs/run_trainings.py --nets resnet18_abrv --folds 0 1 2 3 4 --dry-run`
|
| 172 |
+
4. **Compare protocols** — abbreviated against full, with DeLong p-value and GradCAM heatmaps:
|
| 173 |
+
`python scripts/validation/compare_protocols.py <model_name> ... -o <checkpoint pattern>` # checkpoint pattern will be formatted with model_key and fold, so use something like <your_pretrained_models_root>/{}_fold={}<further_filename_parts>.pth depending on where you have model_key and fold in your filename
|
| 174 |
+
5. **Non-inferiority** — out-of-fold predictions, then the clustered bootstrap:
|
| 175 |
+
`python scripts/validation/predict_oof.py resnet18 <data_root> <metadata.csv> <split_files_root> -c 0 -m '<path_to_pretrained_models_root>/{model_key}_fold={fold}_subfold=0_frac=0.05_FINAL.pth'` then
|
| 176 |
+
`python scripts/validation/analyze_noninferiority.py <output_root_from_predict_oof> <path to metadata.csv> --bca --skip_secondary`
|
| 177 |
+
6. **Per indication subgroups** — exploratory, on the same out-of-fold predictions:
|
| 178 |
+
`python scripts/validation/analyze_subgroups.py <output_root_from_predict_oof> <path to metadata.csv>`
|
| 179 |
+
Reports per indication the AUC of both protocols and the paired AP minus FDP difference with a
|
| 180 |
+
patient-clustered interval. Give the codes readable names with an `indication_labels:` block in
|
| 181 |
+
the site config. Exploratory only: the subgroups are small, nothing is adjusted for multiplicity
|
| 182 |
+
across them, and no margin is tested.
|
| 183 |
+
|
| 184 |
+
## Layout
|
| 185 |
+
|
| 186 |
+
```
|
| 187 |
+
auto_detect_breast_mri/
|
| 188 |
+
├── config.py site configuration ($AIMRI_CONFIG), path and column resolution
|
| 189 |
+
├── data/ datasets, transforms, split generation, dataloaders, metadata/NIfTI IO
|
| 190 |
+
├── models/ ResNets, the cropping UNet, checkpoint IO
|
| 191 |
+
├── training/ training and evaluation loops, LR schedules, shared CLI
|
| 192 |
+
├── evaluation/ metrics, GradCAM, clustered bootstrap, dataset statistics
|
| 193 |
+
└── preprocessing/ DICOM anonymisation, cropping and left/right splitting
|
| 194 |
+
scripts/ every command line entry point, verb-named
|
| 195 |
+
├── data_utils/ splits, metadata fixes, label checks, dataloader/transform inspection
|
| 196 |
+
├── train/ training entry points (classifier, pretrained baseline, cropping UNet)
|
| 197 |
+
└── validation/ inference, evaluation, non-inferiority analysis, plots and heatmaps
|
| 198 |
+
jobs/ SLURM templates and launchers (copy the .example files)
|
| 199 |
+
tools/ synthetic dataset generator, repository checks
|
| 200 |
+
tests/ unit tests only
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
## Keeping patient data out of the repository
|
| 204 |
+
|
| 205 |
+
- `config*.yaml` (except the example), `jobs/site.env` and `*.sbatch` are gitignored: they are the
|
| 206 |
+
only place where absolute paths, cluster accounts and personal directories belong.
|
| 207 |
+
- `.gitignore` also excludes NIfTI/DICOM/CSV/XLSX files, checkpoints, plots and `wandb/`.
|
| 208 |
+
- `python tools/check_no_private_paths.py` fails on absolute paths, cluster accounts, personal names
|
| 209 |
+
and bare 7–10 digit numbers (which could be examination IDs). Install it as a pre-commit hook with
|
| 210 |
+
`pip install pre-commit && pre-commit install`; CI runs it on every push.
|
| 211 |
+
- Weights & Biases: run configs and names are visible to everyone with access to the project, and
|
| 212 |
+
heatmap captions contain patient keys. Set `wandb_mode: offline` (or `disabled`) in your config
|
| 213 |
+
when that is not acceptable.
|
| 214 |
+
|
auto_detect_breast_mri/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = '0.0.1'
|
auto_detect_breast_mri/config.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Site configuration: everything that differs between machines, cluster accounts and metadata exports.
|
| 3 |
+
|
| 4 |
+
Nothing in this repository may contain an absolute path, a cluster account, a user name or a column
|
| 5 |
+
name of a specific metadata export. Those values live in a YAML file that is *not* under version
|
| 6 |
+
control. `config.example.yaml` documents the shape; copy it and fill it in:
|
| 7 |
+
|
| 8 |
+
cp config.example.yaml config.yaml # config*.yaml is gitignored
|
| 9 |
+
export AIMRI_CONFIG=/path/to/config.yaml
|
| 10 |
+
|
| 11 |
+
Resolution order for the config file:
|
| 12 |
+
1. the path given to `load_config()`
|
| 13 |
+
2. $AIMRI_CONFIG
|
| 14 |
+
3. config.yaml / config.yml next to this repository's root
|
| 15 |
+
4. no config at all -- every value must then be given on the command line
|
| 16 |
+
|
| 17 |
+
Command line arguments always win over the config file, so any invocation that worked before a
|
| 18 |
+
config file existed keeps working unchanged. Use `resolve_path()` for that precedence:
|
| 19 |
+
|
| 20 |
+
path_base = resolve_path(args.data_path, 'data_root', 'root folder of the NIfTI data')
|
| 21 |
+
|
| 22 |
+
Column names of the label/split CSVs are configurable in the same file under `columns:`; the
|
| 23 |
+
defaults reproduce the original UKA export, so behaviour is unchanged when nothing is configured.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations # keeps the config importable on interpreters before 3.10
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
CONFIG_ENV_VAR = "AIMRI_CONFIG"
|
| 32 |
+
DEFAULT_CONFIG_NAMES = ("config.yaml", "config.yml")
|
| 33 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent # the checkout, not the package
|
| 34 |
+
|
| 35 |
+
# Path keys understood in the config file. Values are documented in config.example.yaml.
|
| 36 |
+
PATH_KEYS = ("data_root", "metadata_file", "split_root", "output_root", "model_root")
|
| 37 |
+
|
| 38 |
+
# Column names of the metadata and split CSVs. The defaults are the original UKA export's names;
|
| 39 |
+
# override any of them in the config file to point the code at a differently named export.
|
| 40 |
+
DEFAULT_COLUMNS = {
|
| 41 |
+
"examination_id": "AnforderungsNr", # one MRI examination, in the split CSVs
|
| 42 |
+
"metadata_examination_id": "AnforderungsNrE", # the same ID in the metadata export
|
| 43 |
+
"patient_id": "PNR", # patient, i.e. the grouping key for splitting
|
| 44 |
+
"side": "Side", # breast side in the split CSVs
|
| 45 |
+
"metadata_side": "Seite", # breast side in the metadata export
|
| 46 |
+
"criterion": "Malign", # training label, one row per breast
|
| 47 |
+
"criterion_left": "Malign_Left", # per-side label in the wide metadata export
|
| 48 |
+
"criterion_right": "Malign_Right",
|
| 49 |
+
"side_left": "Left", # values found in the side column
|
| 50 |
+
"side_right": "Right",
|
| 51 |
+
"indication_left": "IndicationType_Left", # per-side indication code in the metadata export
|
| 52 |
+
"indication_right": "IndicationType_Right",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# Meaning of the indication codes, used to label the subgroups of the per-indication analysis.
|
| 56 |
+
# Codes are compared as strings after stripping a trailing '.0', so both 1 and 1.0 map onto "1".
|
| 57 |
+
# The site config overrides this under `indication_labels:`; unmapped codes keep their raw value.
|
| 58 |
+
DEFAULT_INDICATION_LABELS = {}
|
| 59 |
+
|
| 60 |
+
DEFAULT_WANDB = {
|
| 61 |
+
"wandb_project": None,
|
| 62 |
+
"wandb_entity": None,
|
| 63 |
+
"wandb_mode": None, # 'online' | 'offline' | 'disabled'; None leaves wandb's own default
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ConfigError(RuntimeError):
|
| 68 |
+
"""Raised when a config file exists but cannot be used."""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class Config:
|
| 72 |
+
"""Read-only view on the site configuration. Missing values are None rather than an error."""
|
| 73 |
+
|
| 74 |
+
def __init__(self, values: dict | None = None, source: Path | None = None):
|
| 75 |
+
self._values = dict(values or {})
|
| 76 |
+
self.source = source
|
| 77 |
+
|
| 78 |
+
columns = dict(DEFAULT_COLUMNS)
|
| 79 |
+
configured = self._values.get("columns") or {}
|
| 80 |
+
if not isinstance(configured, dict):
|
| 81 |
+
raise ConfigError(f"'columns' in {source} must be a mapping, got {type(configured).__name__}")
|
| 82 |
+
unknown = set(configured) - set(DEFAULT_COLUMNS)
|
| 83 |
+
if unknown:
|
| 84 |
+
raise ConfigError(f"Unknown column keys in {source}: {sorted(unknown)}. "
|
| 85 |
+
f"Known keys: {sorted(DEFAULT_COLUMNS)}")
|
| 86 |
+
columns.update({key: value for key, value in configured.items() if value})
|
| 87 |
+
self._columns = columns
|
| 88 |
+
|
| 89 |
+
def get(self, key: str, default=None):
|
| 90 |
+
"""Value of a top level config key, or `default` if it is absent or empty."""
|
| 91 |
+
value = self._values.get(key)
|
| 92 |
+
return default if value in (None, "") else value
|
| 93 |
+
|
| 94 |
+
def path(self, key: str, default=None):
|
| 95 |
+
"""Value of a path key as str. Unknown keys are a programming error, not a config error."""
|
| 96 |
+
if key not in PATH_KEYS:
|
| 97 |
+
raise KeyError(f"Unknown path key '{key}'. Known keys: {PATH_KEYS}")
|
| 98 |
+
value = self.get(key, default)
|
| 99 |
+
return str(value) if value is not None else None
|
| 100 |
+
|
| 101 |
+
def column(self, name: str) -> str:
|
| 102 |
+
"""Configured name of a metadata/split CSV column (see DEFAULT_COLUMNS)."""
|
| 103 |
+
if name not in DEFAULT_COLUMNS:
|
| 104 |
+
raise KeyError(f"Unknown column '{name}'. Known columns: {sorted(DEFAULT_COLUMNS)}")
|
| 105 |
+
return self._columns[name]
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def columns(self) -> dict:
|
| 109 |
+
return dict(self._columns)
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def indication_labels(self) -> dict:
|
| 113 |
+
"""Code -> readable name for the indication subgroups, keys normalised to strings."""
|
| 114 |
+
labels = dict(DEFAULT_INDICATION_LABELS)
|
| 115 |
+
configured = self._values.get("indication_labels") or {}
|
| 116 |
+
if not isinstance(configured, dict):
|
| 117 |
+
raise ConfigError(f"'indication_labels' in {self.source} must be a mapping, "
|
| 118 |
+
f"got {type(configured).__name__}")
|
| 119 |
+
labels.update(configured)
|
| 120 |
+
return {normalise_indication_code(key): str(value) for key, value in labels.items()}
|
| 121 |
+
|
| 122 |
+
def wandb(self, key: str, default=None):
|
| 123 |
+
"""Value of a wandb key ('wandb_project', 'wandb_entity', 'wandb_mode')."""
|
| 124 |
+
if key not in DEFAULT_WANDB:
|
| 125 |
+
raise KeyError(f"Unknown wandb key '{key}'. Known keys: {sorted(DEFAULT_WANDB)}")
|
| 126 |
+
return self.get(key, default)
|
| 127 |
+
|
| 128 |
+
def wandb_init_kwargs(self, **overrides) -> dict:
|
| 129 |
+
"""
|
| 130 |
+
Keyword arguments for `wandb.init()` that carry no site information beyond what is
|
| 131 |
+
configured. Keys whose value is None are omitted so wandb applies its own default.
|
| 132 |
+
"""
|
| 133 |
+
kwargs = {"project": self.wandb("wandb_project"),
|
| 134 |
+
"entity": self.wandb("wandb_entity"),
|
| 135 |
+
"mode": self.wandb("wandb_mode")}
|
| 136 |
+
kwargs.update(overrides)
|
| 137 |
+
return {key: value for key, value in kwargs.items() if value is not None}
|
| 138 |
+
|
| 139 |
+
def __repr__(self):
|
| 140 |
+
return f"Config(source={self.source}, keys={sorted(self._values)})"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def config_path(explicit: str | os.PathLike | None = None) -> Path | None:
|
| 144 |
+
"""The config file that would be used, or None if there is none."""
|
| 145 |
+
if explicit:
|
| 146 |
+
return Path(explicit)
|
| 147 |
+
from_env = os.environ.get(CONFIG_ENV_VAR)
|
| 148 |
+
if from_env:
|
| 149 |
+
return Path(from_env)
|
| 150 |
+
for name in DEFAULT_CONFIG_NAMES:
|
| 151 |
+
candidate = REPO_ROOT / name
|
| 152 |
+
if candidate.exists():
|
| 153 |
+
return candidate
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
| 158 |
+
"""
|
| 159 |
+
Load the site configuration. Returns an empty Config when no config file is present, so the
|
| 160 |
+
repository stays usable with command line arguments only.
|
| 161 |
+
"""
|
| 162 |
+
path = config_path(explicit)
|
| 163 |
+
if path is None:
|
| 164 |
+
return Config()
|
| 165 |
+
if not path.exists():
|
| 166 |
+
raise ConfigError(f"Config file {path} does not exist "
|
| 167 |
+
f"(set ${CONFIG_ENV_VAR} to an existing file or unset it).")
|
| 168 |
+
try:
|
| 169 |
+
import yaml
|
| 170 |
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
| 171 |
+
raise ConfigError(f"Reading {path} needs PyYAML: pip install pyyaml") from exc
|
| 172 |
+
|
| 173 |
+
with open(path) as handle:
|
| 174 |
+
values = yaml.safe_load(handle) or {}
|
| 175 |
+
if not isinstance(values, dict):
|
| 176 |
+
raise ConfigError(f"{path} must contain a mapping at the top level, "
|
| 177 |
+
f"got {type(values).__name__}")
|
| 178 |
+
return Config(values, source=path)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
_CACHED: Config | None = None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def get_config() -> Config:
|
| 185 |
+
"""The process wide site configuration, loaded once."""
|
| 186 |
+
global _CACHED
|
| 187 |
+
if _CACHED is None:
|
| 188 |
+
_CACHED = load_config()
|
| 189 |
+
return _CACHED
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def reset_config() -> None:
|
| 193 |
+
"""Forget the cached configuration (for tests that change $AIMRI_CONFIG)."""
|
| 194 |
+
global _CACHED
|
| 195 |
+
_CACHED = None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def resolve_path(cli_value, key: str, description: str = "", required: bool = True):
|
| 199 |
+
"""
|
| 200 |
+
Resolve one path: the command line value if given, else the config file value.
|
| 201 |
+
|
| 202 |
+
:param cli_value: value coming from argparse, or None
|
| 203 |
+
:param key: config key to fall back to, one of PATH_KEYS
|
| 204 |
+
:param description: what the path is, used in the error message
|
| 205 |
+
:param required: if True, exit with a readable message when neither source provides a value
|
| 206 |
+
:return: the resolved path as str, or None when not required and unset
|
| 207 |
+
"""
|
| 208 |
+
if cli_value:
|
| 209 |
+
return str(cli_value)
|
| 210 |
+
config = get_config()
|
| 211 |
+
value = config.path(key)
|
| 212 |
+
if value:
|
| 213 |
+
return value
|
| 214 |
+
if not required:
|
| 215 |
+
return None
|
| 216 |
+
where = f"'{key}' in {config.source}" if config.source else f"'{key}' in a config file"
|
| 217 |
+
raise SystemExit(
|
| 218 |
+
f"ERROR: no {description or key} given.\n"
|
| 219 |
+
f" Pass it on the command line, or set {where}.\n"
|
| 220 |
+
f" See config.example.yaml; point ${CONFIG_ENV_VAR} at your copy."
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def normalise_indication_code(code) -> str:
|
| 225 |
+
"""
|
| 226 |
+
The canonical string form of an indication code, so 1, '1', 1.0 and '1.0' are one subgroup.
|
| 227 |
+
Non numeric codes are kept as their stripped string.
|
| 228 |
+
"""
|
| 229 |
+
text = str(code).strip()
|
| 230 |
+
try:
|
| 231 |
+
number = float(text)
|
| 232 |
+
except ValueError:
|
| 233 |
+
return text
|
| 234 |
+
return str(int(number)) if number.is_integer() else text
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def indication_labels() -> dict:
|
| 238 |
+
"""Configured code -> readable name mapping for the indication subgroups."""
|
| 239 |
+
return get_config().indication_labels
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def column(name: str) -> str:
|
| 243 |
+
"""Configured name of a metadata/split CSV column."""
|
| 244 |
+
return get_config().column(name)
|
auto_detect_breast_mri/evaluation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Metrics, statistics, GradCAM and the analyses built on predictions."""
|
auto_detect_breast_mri/evaluation/cluster_bootstrap.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fold-stratified, patient-clustered bootstrap for the paired AP vs FDP comparison.
|
| 3 |
+
|
| 4 |
+
The scores of the five outer folds come from five separately trained models, so their scales are not
|
| 5 |
+
comparable. Pooling all scores into one AUC would silently treat those scale differences as ranking
|
| 6 |
+
information. Everything here therefore computes AUCs *within* a fold and combines the fold level
|
| 7 |
+
differences with weights that are fixed before resampling starts.
|
| 8 |
+
|
| 9 |
+
Resampling draws whole women with replacement, so all breasts and all repeated examinations of one
|
| 10 |
+
woman enter or leave a replication together, within fold and within patient level outcome stratum.
|
| 11 |
+
|
| 12 |
+
Limitation: bootstrapping stored out-of-fold scores reflects test sample uncertainty and the patient
|
| 13 |
+
clustering, but not the uncertainty of retraining the models. A fully unconditional bootstrap would
|
| 14 |
+
repeat the whole training pipeline per replication.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
DEFAULT_MARGINS = (0.02, 0.03, 0.05)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def rank_average(values):
|
| 23 |
+
"""
|
| 24 |
+
Ranks of `values` starting at 1, with tied values receiving their average rank.
|
| 25 |
+
:param values: 1D array of scores
|
| 26 |
+
:return: float array of the same length
|
| 27 |
+
"""
|
| 28 |
+
values = np.asarray(values, dtype=float)
|
| 29 |
+
order = np.argsort(values, kind='mergesort')
|
| 30 |
+
sorted_values = values[order]
|
| 31 |
+
ranks = np.empty(len(values), dtype=float)
|
| 32 |
+
|
| 33 |
+
# assign each group of equal values the mean of the ranks it spans
|
| 34 |
+
group_start = 0
|
| 35 |
+
for position in range(1, len(sorted_values) + 1):
|
| 36 |
+
if position == len(sorted_values) or sorted_values[position] != sorted_values[group_start]:
|
| 37 |
+
mean_rank = 0.5 * (group_start + position + 1) # ranks group_start+1 .. position
|
| 38 |
+
ranks[order[group_start:position]] = mean_rank
|
| 39 |
+
group_start = position
|
| 40 |
+
return ranks
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def auc(labels, scores):
|
| 44 |
+
"""
|
| 45 |
+
Area under the ROC curve via the rank sum of the positives, with ties counted as half.
|
| 46 |
+
:param labels: binary 1D array, 1 = malignant
|
| 47 |
+
:param scores: 1D array of scores, higher meaning more suspicious
|
| 48 |
+
:return: AUC, or nan if one of the two classes is absent
|
| 49 |
+
"""
|
| 50 |
+
labels = np.asarray(labels)
|
| 51 |
+
positives = labels == 1
|
| 52 |
+
n_pos = int(positives.sum())
|
| 53 |
+
n_neg = int(len(labels) - n_pos)
|
| 54 |
+
if n_pos == 0 or n_neg == 0:
|
| 55 |
+
return np.nan
|
| 56 |
+
ranks = rank_average(scores)
|
| 57 |
+
return (ranks[positives].sum() - n_pos * (n_pos + 1) / 2.0) / (n_pos * n_neg)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def sensitivity_at_specificity(labels, scores, target_specificity):
|
| 61 |
+
"""
|
| 62 |
+
Sensitivity at the lowest threshold that still reaches `target_specificity` on this same data.
|
| 63 |
+
|
| 64 |
+
A case counts as predicted malignant when its score is at or above the threshold, so
|
| 65 |
+
specificity is the share of benign breasts scoring strictly below it. Among all thresholds
|
| 66 |
+
meeting the target, the smallest one is chosen, which is the one with the highest sensitivity.
|
| 67 |
+
|
| 68 |
+
Note that deriving the threshold on the data it is evaluated on is optimistic; this is only
|
| 69 |
+
meant for the descriptive secondary analysis.
|
| 70 |
+
:return: (sensitivity, achieved specificity, threshold), all nan if a class is absent
|
| 71 |
+
"""
|
| 72 |
+
labels = np.asarray(labels)
|
| 73 |
+
scores = np.asarray(scores, dtype=float)
|
| 74 |
+
negatives = np.sort(scores[labels == 0])
|
| 75 |
+
positives = scores[labels == 1]
|
| 76 |
+
if len(negatives) == 0 or len(positives) == 0:
|
| 77 |
+
return np.nan, np.nan, np.nan
|
| 78 |
+
|
| 79 |
+
# at least this many benign breasts have to fall strictly below the threshold
|
| 80 |
+
needed = int(np.ceil(target_specificity * len(negatives)))
|
| 81 |
+
if needed <= 0:
|
| 82 |
+
threshold = -np.inf
|
| 83 |
+
else:
|
| 84 |
+
boundary = negatives[needed - 1]
|
| 85 |
+
# the smallest score actually observed above that boundary; thresholds in between would
|
| 86 |
+
# define the same classifier but a larger one would needlessly cost sensitivity
|
| 87 |
+
ordered = np.sort(scores)
|
| 88 |
+
position = np.searchsorted(ordered, boundary, side='right')
|
| 89 |
+
threshold = ordered[position] if position < len(ordered) else np.inf
|
| 90 |
+
|
| 91 |
+
achieved_specificity = float(np.mean(negatives < threshold))
|
| 92 |
+
sensitivity = float(np.mean(positives >= threshold))
|
| 93 |
+
return sensitivity, achieved_specificity, float(threshold)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def specificity_at_sensitivity(labels, scores, target_sensitivity):
|
| 97 |
+
"""
|
| 98 |
+
Specificity at the highest threshold that still reaches `target_sensitivity`, the mirror image
|
| 99 |
+
of sensitivity_at_specificity.
|
| 100 |
+
|
| 101 |
+
A case counts as predicted malignant when its score is at or above the threshold, so sensitivity
|
| 102 |
+
is the share of malignant breasts scoring at or above it. Among all thresholds meeting the
|
| 103 |
+
target, the largest one is chosen, which is the one with the highest specificity.
|
| 104 |
+
|
| 105 |
+
Deriving the threshold on the data it is evaluated on is optimistic, the same caveat as above.
|
| 106 |
+
:return: (specificity, achieved sensitivity, threshold), all nan if a class is absent
|
| 107 |
+
"""
|
| 108 |
+
labels = np.asarray(labels)
|
| 109 |
+
scores = np.asarray(scores, dtype=float)
|
| 110 |
+
negatives = scores[labels == 0]
|
| 111 |
+
positives = np.sort(scores[labels == 1])
|
| 112 |
+
if len(negatives) == 0 or len(positives) == 0:
|
| 113 |
+
return np.nan, np.nan, np.nan
|
| 114 |
+
|
| 115 |
+
# at least this many malignant breasts have to lie at or above the threshold
|
| 116 |
+
needed = int(np.ceil(target_sensitivity * len(positives)))
|
| 117 |
+
threshold = -np.inf if needed <= 0 else positives[len(positives) - needed]
|
| 118 |
+
|
| 119 |
+
achieved_sensitivity = float(np.mean(positives >= threshold))
|
| 120 |
+
specificity = float(np.mean(negatives < threshold))
|
| 121 |
+
return specificity, achieved_sensitivity, float(threshold)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class FoldClusterBootstrap:
|
| 125 |
+
"""
|
| 126 |
+
Resampling machinery for one out-of-fold table.
|
| 127 |
+
|
| 128 |
+
:param folds: 1D array assigning each row to an outer fold
|
| 129 |
+
:param patients: 1D array assigning each row to a woman
|
| 130 |
+
:param labels: binary 1D array, 1 = malignant
|
| 131 |
+
:param stratify_by_outcome: bool
|
| 132 |
+
if True, women are resampled separately among those with and those without any malignant
|
| 133 |
+
breast in that fold, keeping the class distribution stable across replications
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
def __init__(self, folds, patients, labels, stratify_by_outcome=True):
|
| 137 |
+
self.folds = np.asarray(folds)
|
| 138 |
+
self.patients = np.asarray(patients)
|
| 139 |
+
self.labels = np.asarray(labels)
|
| 140 |
+
self.stratify_by_outcome = stratify_by_outcome
|
| 141 |
+
self.fold_ids = np.unique(self.folds)
|
| 142 |
+
|
| 143 |
+
# per fold: the row indices of every woman, split into the two outcome strata
|
| 144 |
+
self.strata_of_fold = {}
|
| 145 |
+
for fold in self.fold_ids:
|
| 146 |
+
fold_rows = np.flatnonzero(self.folds == fold)
|
| 147 |
+
rows_of_patient = {}
|
| 148 |
+
for row in fold_rows:
|
| 149 |
+
rows_of_patient.setdefault(self.patients[row], []).append(row)
|
| 150 |
+
|
| 151 |
+
strata = {0: [], 1: []}
|
| 152 |
+
for patient, rows in rows_of_patient.items():
|
| 153 |
+
rows = np.asarray(rows)
|
| 154 |
+
stratum = 1 if self.labels[rows].max() == 1 else 0
|
| 155 |
+
strata[stratum if self.stratify_by_outcome else 0].append(rows)
|
| 156 |
+
self.strata_of_fold[fold] = [strata[0], strata[1]]
|
| 157 |
+
|
| 158 |
+
def fold_weights(self, weighting='pairs'):
|
| 159 |
+
"""
|
| 160 |
+
Weights used to combine the fold level differences. They are computed once on the observed
|
| 161 |
+
data and then held fixed across all replications, so they carry no replication specific
|
| 162 |
+
information.
|
| 163 |
+
:param weighting: 'pairs' for w_k proportional to positives x negatives, 'cases' for the
|
| 164 |
+
number of breasts in the fold, or 'equal'
|
| 165 |
+
:return: dict fold -> weight, summing to 1
|
| 166 |
+
"""
|
| 167 |
+
weights = {}
|
| 168 |
+
for fold in self.fold_ids:
|
| 169 |
+
fold_labels = self.labels[self.folds == fold]
|
| 170 |
+
n_pos = int((fold_labels == 1).sum())
|
| 171 |
+
n_neg = int((fold_labels == 0).sum())
|
| 172 |
+
if weighting == 'pairs':
|
| 173 |
+
weights[fold] = float(n_pos * n_neg)
|
| 174 |
+
elif weighting == 'cases':
|
| 175 |
+
weights[fold] = float(n_pos + n_neg)
|
| 176 |
+
elif weighting == 'equal':
|
| 177 |
+
weights[fold] = 1.0
|
| 178 |
+
else:
|
| 179 |
+
raise ValueError(f"Unknown weighting '{weighting}'. Use 'pairs', 'cases' or 'equal'.")
|
| 180 |
+
total = sum(weights.values())
|
| 181 |
+
return {fold: weight / total for fold, weight in weights.items()}
|
| 182 |
+
|
| 183 |
+
def observed_rows(self):
|
| 184 |
+
"""Row indices per fold of the unresampled data."""
|
| 185 |
+
return {fold: np.flatnonzero(self.folds == fold) for fold in self.fold_ids}
|
| 186 |
+
|
| 187 |
+
def resample_rows(self, rng):
|
| 188 |
+
"""
|
| 189 |
+
Draw one bootstrap replication: women with replacement, within fold and outcome stratum.
|
| 190 |
+
:return: dict fold -> row indices of the replication (a woman drawn twice contributes all
|
| 191 |
+
of her breasts twice)
|
| 192 |
+
"""
|
| 193 |
+
rows_of_fold = {}
|
| 194 |
+
for fold in self.fold_ids:
|
| 195 |
+
drawn = []
|
| 196 |
+
for stratum in self.strata_of_fold[fold]:
|
| 197 |
+
if not len(stratum):
|
| 198 |
+
continue
|
| 199 |
+
picks = rng.integers(0, len(stratum), size=len(stratum))
|
| 200 |
+
drawn.extend(stratum[pick] for pick in picks)
|
| 201 |
+
rows_of_fold[fold] = np.concatenate(drawn) if drawn else np.empty(0, dtype=int)
|
| 202 |
+
return rows_of_fold
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def weighted_fold_difference(rows_of_fold, labels, score_a, score_b, weights):
|
| 206 |
+
"""
|
| 207 |
+
Difference of the two AUCs per fold, combined with the fixed weights.
|
| 208 |
+
Folds in which an AUC is undefined are dropped and the weights of the remaining folds are
|
| 209 |
+
renormalized, so a replication is only lost if every fold degenerates.
|
| 210 |
+
:return: (combined difference, dict fold -> (auc_a, auc_b))
|
| 211 |
+
"""
|
| 212 |
+
per_fold = {}
|
| 213 |
+
total_weight = 0.0
|
| 214 |
+
combined = 0.0
|
| 215 |
+
for fold, rows in rows_of_fold.items():
|
| 216 |
+
fold_labels = labels[rows]
|
| 217 |
+
auc_a = auc(fold_labels, score_a[rows])
|
| 218 |
+
auc_b = auc(fold_labels, score_b[rows])
|
| 219 |
+
per_fold[fold] = (auc_a, auc_b)
|
| 220 |
+
if np.isnan(auc_a) or np.isnan(auc_b):
|
| 221 |
+
continue
|
| 222 |
+
combined += weights[fold] * (auc_a - auc_b)
|
| 223 |
+
total_weight += weights[fold]
|
| 224 |
+
if total_weight == 0:
|
| 225 |
+
return np.nan, per_fold
|
| 226 |
+
return combined / total_weight, per_fold
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def percentile_interval(replications, confidence):
|
| 230 |
+
"""
|
| 231 |
+
Two sided percentile interval.
|
| 232 |
+
:param replications: 1D array of bootstrap statistics
|
| 233 |
+
:param confidence: e.g. 0.95 or 0.975
|
| 234 |
+
:return: (lower, upper)
|
| 235 |
+
"""
|
| 236 |
+
replications = np.asarray(replications)
|
| 237 |
+
replications = replications[~np.isnan(replications)]
|
| 238 |
+
alpha = (1.0 - confidence) / 2.0
|
| 239 |
+
return (float(np.quantile(replications, alpha)),
|
| 240 |
+
float(np.quantile(replications, 1.0 - alpha)))
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def bca_interval(replications, observed, jackknife_values, confidence):
|
| 244 |
+
"""
|
| 245 |
+
Bias corrected and accelerated interval, to be used as a prespecified sensitivity analysis only.
|
| 246 |
+
:param replications: 1D array of bootstrap statistics
|
| 247 |
+
:param observed: the statistic on the original data
|
| 248 |
+
:param jackknife_values: statistic with one woman left out at a time
|
| 249 |
+
:param confidence: e.g. 0.95
|
| 250 |
+
:return: (lower, upper)
|
| 251 |
+
"""
|
| 252 |
+
from scipy.stats import norm
|
| 253 |
+
|
| 254 |
+
replications = np.asarray(replications)
|
| 255 |
+
replications = replications[~np.isnan(replications)]
|
| 256 |
+
jackknife_values = np.asarray(jackknife_values)
|
| 257 |
+
jackknife_values = jackknife_values[~np.isnan(jackknife_values)]
|
| 258 |
+
|
| 259 |
+
proportion_below = np.mean(replications < observed)
|
| 260 |
+
if proportion_below <= 0 or proportion_below >= 1:
|
| 261 |
+
return percentile_interval(replications, confidence)
|
| 262 |
+
bias_correction = norm.ppf(proportion_below)
|
| 263 |
+
|
| 264 |
+
centred = jackknife_values.mean() - jackknife_values
|
| 265 |
+
denominator = 6.0 * (np.sum(centred ** 2) ** 1.5)
|
| 266 |
+
acceleration = 0.0 if denominator == 0 else float(np.sum(centred ** 3) / denominator)
|
| 267 |
+
|
| 268 |
+
alpha = (1.0 - confidence) / 2.0
|
| 269 |
+
bounds = []
|
| 270 |
+
for tail in (alpha, 1.0 - alpha):
|
| 271 |
+
z = norm.ppf(tail)
|
| 272 |
+
adjusted = bias_correction + (bias_correction + z) / (1.0 - acceleration * (bias_correction + z))
|
| 273 |
+
bounds.append(float(np.quantile(replications, norm.cdf(adjusted))))
|
| 274 |
+
return tuple(bounds)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def noninferiority_p_value(replications, margin):
|
| 278 |
+
"""
|
| 279 |
+
One sided bootstrap p value for H0: delta <= -margin against H1: delta > -margin.
|
| 280 |
+
Uses the same percentile scale as the reported intervals, so a two sided 95% interval whose
|
| 281 |
+
lower bound exceeds -margin corresponds to p < 0.025.
|
| 282 |
+
:return: p value in (0, 1]
|
| 283 |
+
"""
|
| 284 |
+
replications = np.asarray(replications)
|
| 285 |
+
replications = replications[~np.isnan(replications)]
|
| 286 |
+
return float((np.sum(replications <= -margin) + 1) / (len(replications) + 1))
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def holm_adjust(p_values):
|
| 290 |
+
"""
|
| 291 |
+
Holm step down adjustment.
|
| 292 |
+
:param p_values: dict name -> raw p value
|
| 293 |
+
:return: dict name -> adjusted p value
|
| 294 |
+
"""
|
| 295 |
+
ordered = sorted(p_values.items(), key=lambda item: item[1])
|
| 296 |
+
n = len(ordered)
|
| 297 |
+
adjusted = {}
|
| 298 |
+
running_max = 0.0
|
| 299 |
+
for rank, (name, p_value) in enumerate(ordered):
|
| 300 |
+
candidate = min(1.0, (n - rank) * p_value)
|
| 301 |
+
running_max = max(running_max, candidate)
|
| 302 |
+
adjusted[name] = running_max
|
| 303 |
+
return adjusted
|
auto_detect_breast_mri/evaluation/dataset_stats.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Descriptive statistics and plots of a dataset and of the generated splits."""
|
| 2 |
+
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import pandas as pd
|
| 5 |
+
|
| 6 |
+
from auto_detect_breast_mri.config import column
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def analyze_dataset(metadata_cleaned: str, llm_metadata: str):
|
| 10 |
+
print(metadata_cleaned)
|
| 11 |
+
print(llm_metadata)
|
| 12 |
+
data_meta_id_key = column('examination_id')
|
| 13 |
+
if metadata_cleaned.endswith('.xlsx'):
|
| 14 |
+
data = pd.read_excel(metadata_cleaned)
|
| 15 |
+
elif metadata_cleaned.endswith('.csv'):
|
| 16 |
+
data = pd.read_csv(metadata_cleaned)
|
| 17 |
+
else:
|
| 18 |
+
raise ValueError("DataSplitter: Don't know how to read this file: " + metadata_cleaned)
|
| 19 |
+
|
| 20 |
+
meta_id_key = column('examination_id')
|
| 21 |
+
if llm_metadata.endswith('.xlsx'):
|
| 22 |
+
llm_metadata = pd.read_excel(llm_metadata)
|
| 23 |
+
elif llm_metadata.endswith('.csv'):
|
| 24 |
+
llm_metadata = pd.read_csv(llm_metadata)
|
| 25 |
+
else:
|
| 26 |
+
raise ValueError("DataSplitter: Don't know how to read this file: " + llm_metadata)
|
| 27 |
+
# only use data from one fold (otherwise redundance)
|
| 28 |
+
|
| 29 |
+
llm_metadata = llm_metadata[llm_metadata['Fold'] == 0]
|
| 30 |
+
|
| 31 |
+
additional_metadata = data[data[data_meta_id_key].isin(llm_metadata[meta_id_key])]
|
| 32 |
+
if len(additional_metadata) == 0:
|
| 33 |
+
# trim last two digits as this file contains all IDs with appended 01
|
| 34 |
+
trimmed_meta = data[data_meta_id_key].astype(str).str[:-2].astype('int64')
|
| 35 |
+
data[data_meta_id_key] = trimmed_meta
|
| 36 |
+
additional_metadata = data[data[data_meta_id_key].isin(llm_metadata[meta_id_key])]
|
| 37 |
+
|
| 38 |
+
print(f"Number of patients in dataset: {len(data[column('patient_id')].unique())}")
|
| 39 |
+
print("Len of nan PNRs: " + str(len(data[data[column('patient_id')] != data[column('patient_id')]])))
|
| 40 |
+
print("\nNumber of patients per split:")
|
| 41 |
+
|
| 42 |
+
print(f"\nNumber of unilateral examinations: {len(data[meta_id_key].unique())}")
|
| 43 |
+
'''split_groups = llm_metadata.groupby(['Split']).count()
|
| 44 |
+
print("\nNumber of unilateral examinations per split:")
|
| 45 |
+
print(split_groups[meta_id_key])'''
|
| 46 |
+
|
| 47 |
+
print("\nBIRADS distribution over the whole dataset:")
|
| 48 |
+
birads_groups = llm_metadata.groupby("BIRADS").count()
|
| 49 |
+
print(birads_groups[meta_id_key]/(len(llm_metadata)))
|
| 50 |
+
print("BIRADS absolute numbers:")
|
| 51 |
+
print(birads_groups[meta_id_key])
|
| 52 |
+
|
| 53 |
+
# number of patients
|
| 54 |
+
if 'Menopause' in additional_metadata.keys():
|
| 55 |
+
print("\nMenopausal status over the whole dataset:")
|
| 56 |
+
menopause_group = additional_metadata.groupby('Menopause').count()
|
| 57 |
+
print(menopause_group[data_meta_id_key])
|
| 58 |
+
print(f"Unknown = {len(additional_metadata[additional_metadata['Menopause'] != additional_metadata['Menopause']])}")
|
| 59 |
+
|
| 60 |
+
if 'IndicationType_Left' in additional_metadata.keys() and 'IndicationType_Right' in additional_metadata.keys():
|
| 61 |
+
print("\nIndication per patient over the whole dataset")
|
| 62 |
+
indications = pd.melt(additional_metadata, id_vars=[data_meta_id_key], value_vars=['IndicationType_Left', 'IndicationType_Right'],
|
| 63 |
+
var_name=column('side'), value_name='Indication')
|
| 64 |
+
indications[column('side')] = indications[column('side')].str.extract(r'_(\w+)')
|
| 65 |
+
print(indications.groupby('Indication').count()[data_meta_id_key])
|
| 66 |
+
|
| 67 |
+
if 'BIRADS_Left' in data.keys() and 'BIRADS_Right' in data.keys():
|
| 68 |
+
birads = pd.melt(data, id_vars=[data_meta_id_key, column('patient_id'), ], value_vars=['BIRADS_Left', 'BIRADS_Right'], var_name=column('side'),
|
| 69 |
+
value_name='BIRADS')
|
| 70 |
+
birads[column('side')] = birads[column('side')].str.extract(r'_(\w+)')
|
| 71 |
+
data.groupby([column('patient_id'), 'BIRADS_Left', 'BIRADS_Right']).agg('last')
|
| 72 |
+
print(f"\nTotal number of patients at beginning {len(data[column('patient_id')].unique())}")
|
| 73 |
+
certain_left = data[data['MalignCertainty_Left'] > 19]
|
| 74 |
+
certain_both = certain_left[certain_left['MalignCertainty_Right'] > 19]
|
| 75 |
+
print(f"\nNumber of patients with certainty <= 20 {len(data[column('patient_id')].unique()) - len(certain_both[column('patient_id')].unique())}")
|
| 76 |
+
|
| 77 |
+
'''
|
| 78 |
+
mean_age = grouped['Age'].mean()
|
| 79 |
+
age_min = grouped['Age'].min()
|
| 80 |
+
age_max = grouped['Age'].max()
|
| 81 |
+
print("Mean Age: ________________________")
|
| 82 |
+
print(mean_age)
|
| 83 |
+
print("Minimum Age: ________________________")
|
| 84 |
+
print(age_min)
|
| 85 |
+
print("Maximum Age: ________________________")
|
| 86 |
+
print(age_max)
|
| 87 |
+
'''
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def analyze_split(metadata: str, split_files: [str]):
|
| 91 |
+
'''
|
| 92 |
+
Analyze a split defined by csv file with the metadata defined in given metadata file
|
| 93 |
+
:param metadata: path to csv file with metadata. 'AnforderungsNrE' is a required key.
|
| 94 |
+
:param split_file: path to csv file about the according split.
|
| 95 |
+
Required keys are the configured examination ID, patient ID, side and criterion columns.
|
| 96 |
+
:return:
|
| 97 |
+
'''
|
| 98 |
+
print(metadata)
|
| 99 |
+
print(split_files)
|
| 100 |
+
data_meta_id_key = column('metadata_examination_id')
|
| 101 |
+
data = pd.read_csv(metadata)
|
| 102 |
+
|
| 103 |
+
split_id_key = column('examination_id')
|
| 104 |
+
all_split_data = []
|
| 105 |
+
all_splits_results = {}
|
| 106 |
+
split_name = {0: 'train', 1: 'eval', 2: 'test'}
|
| 107 |
+
for i, split_file in enumerate(split_files):
|
| 108 |
+
if 'Fold0' in data.keys():
|
| 109 |
+
data = data[data['Fold0'] == split_name[i%3]]
|
| 110 |
+
split_data = pd.read_csv(split_file)
|
| 111 |
+
split_results = {}
|
| 112 |
+
|
| 113 |
+
split_metadata = data[data[data_meta_id_key].isin(split_data[split_id_key])]
|
| 114 |
+
if len(split_metadata) == 0:
|
| 115 |
+
# trim last two digits as this file contains all IDs with appended 01
|
| 116 |
+
trimmed_meta = data[data_meta_id_key].astype(str).str[:-2].astype('int64')
|
| 117 |
+
data[data_meta_id_key] = trimmed_meta
|
| 118 |
+
split_metadata = data[data[data_meta_id_key].isin(split_data[split_id_key])]
|
| 119 |
+
|
| 120 |
+
split_id_with_no_metadata = split_data[~ split_data[split_id_key].isin(split_metadata[data_meta_id_key])]
|
| 121 |
+
print(f"Number of examinations in splitfile {split_file} for which no metadata is found: {len(split_id_with_no_metadata)}")
|
| 122 |
+
all_split_data.append(split_data)
|
| 123 |
+
split_id_with_metadata = split_data[split_data[split_id_key].isin(split_metadata[data_meta_id_key])]
|
| 124 |
+
split_results['Number of available metadata for split'] = len(split_id_with_metadata)
|
| 125 |
+
split_results['Number of exams without metadata for split'] = len(split_id_with_no_metadata)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
split_results['Patient_number'] = len(split_data[column('patient_id')].unique())
|
| 129 |
+
split_results['Examination_number'] = len(split_data[split_id_key].unique())
|
| 130 |
+
split_results['Exams without matching metadata'] = len(split_id_with_no_metadata)
|
| 131 |
+
|
| 132 |
+
# BIRADS
|
| 133 |
+
birads = pd.melt(split_metadata, id_vars=[data_meta_id_key],
|
| 134 |
+
value_vars=['BIRADS_Left', 'BIRADS_Right'],
|
| 135 |
+
var_name=column('side'), value_name='BIRADS')
|
| 136 |
+
birads[column('side')] = birads[column('side')].str.extract(r'_(\w+)')
|
| 137 |
+
birads_groups = birads.groupby("BIRADS").count()
|
| 138 |
+
split_results['BIRADS distribution'] = birads_groups[data_meta_id_key] / (len(split_data))
|
| 139 |
+
split_results['BIRADS absolute'] = birads_groups[data_meta_id_key]
|
| 140 |
+
|
| 141 |
+
# Malign
|
| 142 |
+
split_results['Malign %'] = split_data[column('criterion')].sum() / len(split_data[column('criterion')])
|
| 143 |
+
split_results['Malign absolute'] = {'True': len(split_data[split_data[column('criterion')] == 1]), 'False': len(split_data[split_data[column('criterion')] == 0])}
|
| 144 |
+
|
| 145 |
+
j = 0
|
| 146 |
+
for prev in range(j, i):
|
| 147 |
+
print("Compare split {} with {}".format(split_files[prev], split_file))
|
| 148 |
+
prev_split_data = all_split_data[prev]
|
| 149 |
+
doubles = prev_split_data[prev_split_data[split_id_key].isin(split_data[split_id_key])][split_id_key]
|
| 150 |
+
split_results[f'PNRs in split {prev} and {i}'] = doubles
|
| 151 |
+
split_results[f'PNRs COUNT in split {prev} and {i}'] = len(doubles)
|
| 152 |
+
print(split_results)
|
| 153 |
+
all_splits_results[split_file.split("cross_validation_sets/")[-1].replace("fold0/stratified_", "")] = split_results
|
| 154 |
+
return all_splits_results
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# Function to plot BIRADS distribution
|
| 158 |
+
def plot_birads_distribution2(split_name, split_data):
|
| 159 |
+
birads_distribution = split_data['BIRADS distribution']
|
| 160 |
+
birads_absolute = split_data['BIRADS absolute']
|
| 161 |
+
|
| 162 |
+
# Convert to pandas Series for easy plotting
|
| 163 |
+
birads_dist_series = pd.Series(birads_distribution)
|
| 164 |
+
birads_abs_series = pd.Series(birads_absolute)
|
| 165 |
+
|
| 166 |
+
# Plot BIRADS distribution
|
| 167 |
+
plt.figure(figsize=(12, 5))
|
| 168 |
+
|
| 169 |
+
plt.subplot(1, 2, 1)
|
| 170 |
+
birads_dist_series.plot(kind='bar')
|
| 171 |
+
plt.title(f'{split_name} - BIRADS Distribution (Percentage)')
|
| 172 |
+
plt.xlabel('BIRADS')
|
| 173 |
+
plt.ylabel('Percentage')
|
| 174 |
+
|
| 175 |
+
plt.subplot(1, 2, 2)
|
| 176 |
+
birads_abs_series.plot(kind='bar')
|
| 177 |
+
plt.title(f'{split_name} - BIRADS Distribution (Absolute)')
|
| 178 |
+
plt.xlabel('BIRADS')
|
| 179 |
+
plt.ylabel('Count')
|
| 180 |
+
|
| 181 |
+
plt.tight_layout()
|
| 182 |
+
plt.show()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# Function to plot Malignancy distribution
|
| 186 |
+
def plot_malign_distribution(split_name, split_data):
|
| 187 |
+
malign_percent = split_data['Malign %']
|
| 188 |
+
malign_absolute = split_data['Malign absolute']
|
| 189 |
+
|
| 190 |
+
labels = ['True', 'False']
|
| 191 |
+
sizes = [malign_absolute['True'], malign_absolute['False']]
|
| 192 |
+
|
| 193 |
+
plt.figure(figsize=(6, 6))
|
| 194 |
+
plt.pie(sizes, labels=labels, autopct='%1.1f%%', startangle=140)
|
| 195 |
+
plt.title(f'{split_name} - Malignancy Distribution\n(Malign %: {malign_percent:.2%})')
|
| 196 |
+
plt.show()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def plot_birads_distribution(split_name, data):
|
| 200 |
+
birads_labels = list(data['BIRADS distribution'].keys())
|
| 201 |
+
birads_values = list(data['BIRADS distribution'].values)
|
| 202 |
+
|
| 203 |
+
plt.figure(figsize=(10, 6))
|
| 204 |
+
plt.bar(birads_labels, birads_values, color='blue', alpha=0.7)
|
| 205 |
+
plt.xlabel('BIRADS Category')
|
| 206 |
+
plt.ylabel('Distribution')
|
| 207 |
+
plt.title(f'{split_name} - BIRADS Distribution')
|
| 208 |
+
plt.grid(axis='y', linestyle='--', alpha=0.7)
|
| 209 |
+
plt.show()
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def plot_birads_absolute(split_name, data):
|
| 213 |
+
birads_labels = list(data['BIRADS absolute'].keys())
|
| 214 |
+
birads_values = list(data['BIRADS absolute'].values)
|
| 215 |
+
|
| 216 |
+
plt.figure(figsize=(10, 6))
|
| 217 |
+
plt.bar(birads_labels, birads_values, color='green', alpha=0.7)
|
| 218 |
+
plt.xlabel('BIRADS Category')
|
| 219 |
+
plt.ylabel('Absolute Counts')
|
| 220 |
+
plt.title(f'{split_name} - BIRADS Absolute Counts')
|
| 221 |
+
plt.grid(axis='y', linestyle='--', alpha=0.7)
|
| 222 |
+
plt.show()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def plot_malignancy(split_name, data):
|
| 226 |
+
labels = list(data['Malign absolute'].keys())
|
| 227 |
+
values = list(data['Malign absolute'].values())
|
| 228 |
+
|
| 229 |
+
plt.figure(figsize=(6, 6))
|
| 230 |
+
plt.bar(labels, values, color='red', alpha=0.7)
|
| 231 |
+
plt.xlabel('Malignancy')
|
| 232 |
+
plt.ylabel('Counts')
|
| 233 |
+
plt.title(f'{split_name} - Malignancy Counts')
|
| 234 |
+
plt.grid(axis='y', linestyle='--', alpha=0.7)
|
| 235 |
+
plt.show()
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
|
auto_detect_breast_mri/evaluation/gradcam.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import numpy as np
|
| 3 |
+
import torch
|
| 4 |
+
import matplotlib
|
| 5 |
+
matplotlib.use("Agg")
|
| 6 |
+
import matplotlib.pyplot as plt
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("gradcam")
|
| 9 |
+
|
| 10 |
+
# Layer types GradCAM can meaningfully hook: convolutions preserve spatial
|
| 11 |
+
# structure directly; the listed norm types are used as a fallback spatial
|
| 12 |
+
# proxy for transformer blocks that have no Conv3d (e.g. Swin stages).
|
| 13 |
+
_CANDIDATE_TYPES = (torch.nn.Conv3d, torch.nn.Conv2d, torch.nn.LayerNorm,
|
| 14 |
+
torch.nn.InstanceNorm3d, torch.nn.BatchNorm3d)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# --------------------------------------------------------------------------
|
| 18 |
+
# Layout adapters
|
| 19 |
+
#
|
| 20 |
+
# pytorch_grad_cam requires the hooked activations/gradients to be channels-
|
| 21 |
+
# first with the same leading batch as `input_tensor`. render_heatmap always
|
| 22 |
+
# feeds a single 5D volume (1, C, W, H, D) as torchio orders it, so every adapter below normalises
|
| 23 |
+
# an observed activation to (1, C, d, h, w). Anything we cannot map that way is
|
| 24 |
+
# rejected outright rather than fed to GradCAM -- a mis-mapped layout does not
|
| 25 |
+
# raise, it silently yields a meaningless heatmap.
|
| 26 |
+
# --------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
def _identity(t):
|
| 29 |
+
return t
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _mean_over_leading(t):
|
| 33 |
+
"""(g, C, d, h, w) -> (1, C, d, h, w): average head/group-split activations.
|
| 34 |
+
|
| 35 |
+
MViT's attention pooling convs run on (B*num_heads, head_dim, t, h, w);
|
| 36 |
+
averaging the heads keeps one CAM per sample instead of silently reporting
|
| 37 |
+
head 0 only.
|
| 38 |
+
"""
|
| 39 |
+
return t.mean(dim=0, keepdim=True)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _add_depth_axis(t):
|
| 43 |
+
"""(1, C, h, w) -> (1, C, 1, h, w)."""
|
| 44 |
+
return t.unsqueeze(2)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _slices_to_depth_first(t):
|
| 48 |
+
"""(N, C, h, w) -> (1, C, N, h, w): a per-slice 2D backbone's slice batch is
|
| 49 |
+
the volume's depth axis."""
|
| 50 |
+
return t.permute(1, 0, 2, 3).unsqueeze(0)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _slices_to_depth_last(t):
|
| 54 |
+
"""(N, h, w, C) -> (1, C, N, h, w): channels-last per-slice activations."""
|
| 55 |
+
return t.permute(3, 0, 1, 2).unsqueeze(0)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _channels_last_to_first(t):
|
| 59 |
+
"""(B, d, h, w, C) -> (B, C, d, h, w): MONAI SwinUNETR stages keep the
|
| 60 |
+
channel axis last."""
|
| 61 |
+
return t.permute(0, 4, 1, 2, 3)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _layout_transform(module, shape, batch):
|
| 65 |
+
"""Return the reshape_transform mapping `module`'s activation layout to
|
| 66 |
+
(1, C, d, h, w), or None if the layout is not interpretable.
|
| 67 |
+
|
| 68 |
+
`torch.nn.LayerNorm` normalises over the *trailing* dims by construction, so
|
| 69 |
+
its output is channels-last by definition -- that is a fact about the module,
|
| 70 |
+
not a guess from the shape. Conv outputs are always channels-first.
|
| 71 |
+
"""
|
| 72 |
+
ndim = len(shape)
|
| 73 |
+
channels_last = isinstance(module, torch.nn.LayerNorm)
|
| 74 |
+
if ndim == 5:
|
| 75 |
+
if channels_last:
|
| 76 |
+
# (B, d, h, w, C): three middle dims are an unambiguous spatial grid.
|
| 77 |
+
return _channels_last_to_first if shape[0] == batch else None
|
| 78 |
+
return _identity if shape[0] == batch else _mean_over_leading
|
| 79 |
+
if ndim == 4:
|
| 80 |
+
if channels_last:
|
| 81 |
+
# Only a slice-stacked per-slice 2D backbone yields a trustworthy
|
| 82 |
+
# (N, h, w, C) grid. When the leading dim IS the batch, the two
|
| 83 |
+
# middle dims of a 4D channels-last tensor are just as likely to be
|
| 84 |
+
# (heads, tokens) as (h, w) -- MViT's attention norms are exactly
|
| 85 |
+
# that -- so reject rather than emit a meaningless heatmap.
|
| 86 |
+
return _slices_to_depth_last if shape[0] != batch else None
|
| 87 |
+
return _add_depth_axis if shape[0] == batch else _slices_to_depth_first
|
| 88 |
+
# (B, C) pooled embeddings and (B, tokens, C) sequences carry no recoverable
|
| 89 |
+
# spatial grid -> not usable as a CAM target.
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def resolve_cam_target(model, model_name, sample_input=None):
|
| 94 |
+
"""Return (target_layer, reshape_transform) for GradCAM.
|
| 95 |
+
|
| 96 |
+
If `sample_input` is given, the selection is *empirical*: one lightweight
|
| 97 |
+
forward+backward pass records which candidate layers GradCAM would actually
|
| 98 |
+
be able to read, and the deepest usable one is returned. A candidate is
|
| 99 |
+
usable only if BOTH hold:
|
| 100 |
+
|
| 101 |
+
1. its output tensor's `register_hook` fires during backward -- this is
|
| 102 |
+
exactly how pytorch_grad_cam captures gradients. Checking `.grad` via
|
| 103 |
+
`retain_grad()` instead is NOT equivalent: nnU-Net's
|
| 104 |
+
`LeakyReLU(inplace=True)` rebases the norm output's autograd node, so
|
| 105 |
+
`.grad` is populated while the tensor hook never fires, and GradCAM
|
| 106 |
+
then sees `grads=None`.
|
| 107 |
+
2. its activation layout maps onto (1, C, d, h, w) (see _layout_transform).
|
| 108 |
+
Without this check the deepest connected layer is often a head
|
| 109 |
+
LayerNorm over a pooled (B, C) embedding or a (B, tokens, C) sequence,
|
| 110 |
+
which makes GradCAM raise "Invalid grads shape".
|
| 111 |
+
|
| 112 |
+
This also keeps multi-task models honest: challenge_2BCN_AIM's SwinUNETR
|
| 113 |
+
decoder/segmentation head is not on the classification path, so its layers
|
| 114 |
+
never fire and are skipped.
|
| 115 |
+
|
| 116 |
+
Falls back to the static per-architecture heuristic below when no
|
| 117 |
+
`sample_input` is given or no candidate qualifies.
|
| 118 |
+
"""
|
| 119 |
+
if sample_input is not None:
|
| 120 |
+
try:
|
| 121 |
+
layer, transform = _connected_target_layer(model, sample_input)
|
| 122 |
+
if layer is not None:
|
| 123 |
+
return layer, transform
|
| 124 |
+
except Exception as exc:
|
| 125 |
+
logger.warning(
|
| 126 |
+
"GradCAM connectivity probe failed for %s (%s); falling back "
|
| 127 |
+
"to heuristic target-layer selection.", model_name, exc,
|
| 128 |
+
)
|
| 129 |
+
return _heuristic_target_layer(model, model_name), None
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def get_target_layer(model, model_name, sample_input=None):
|
| 133 |
+
"""Return the layer GradCAM hooks (see resolve_cam_target)."""
|
| 134 |
+
return resolve_cam_target(model, model_name, sample_input)[0]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _heuristic_target_layer(model, model_name):
|
| 138 |
+
conv_layers = [m for m in model.modules() if isinstance(m, torch.nn.Conv3d)]
|
| 139 |
+
if model_name in ("challenge_1DivideAndConquer", "challenge_5pimed") and conv_layers:
|
| 140 |
+
return conv_layers[-1]
|
| 141 |
+
if model_name == "challenge_3agaldran": # MViT: hook the last conv in the temporal reduce / core
|
| 142 |
+
return conv_layers[-1] if conv_layers else _last_norm(model)
|
| 143 |
+
if model_name == "challenge_2BCN_AIM":
|
| 144 |
+
# SwinUNETRMultiTask's classification head only consumes the Swin
|
| 145 |
+
# ENCODER features (hidden_states_out); the decoder + segmentation
|
| 146 |
+
# head hung off model.swin_unetr are gradient-disconnected from the
|
| 147 |
+
# classification logits. Restrict the search to the encoder so we
|
| 148 |
+
# never return a decoder/seg-head layer here.
|
| 149 |
+
swin_vit = getattr(getattr(model, "swin_unetr", None), "swinViT", None)
|
| 150 |
+
if swin_vit is not None:
|
| 151 |
+
sub_convs = [m for m in swin_vit.modules() if isinstance(m, torch.nn.Conv3d)]
|
| 152 |
+
return sub_convs[-1] if sub_convs else _last_norm(swin_vit)
|
| 153 |
+
return _last_norm(model)
|
| 154 |
+
# challenge_4abmil (ABMIL-Swin, no Conv3d) and any other model -> last conv else last norm
|
| 155 |
+
if conv_layers:
|
| 156 |
+
return conv_layers[-1]
|
| 157 |
+
return _last_norm(model)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _connected_target_layer(model, sample_input):
|
| 161 |
+
"""Probe which candidate layer GradCAM can actually consume.
|
| 162 |
+
|
| 163 |
+
Registers forward hooks on every candidate, runs one forward + backward pass
|
| 164 |
+
(batch size 1, summed logits) while recording each output's shape and
|
| 165 |
+
whether its gradient hook fired, and returns the deepest candidate (last in
|
| 166 |
+
`model.modules()` order) that both received gradient and has a usable
|
| 167 |
+
layout. Hooks are always removed before returning. Returns (None, None) if
|
| 168 |
+
no candidate qualifies.
|
| 169 |
+
"""
|
| 170 |
+
candidates = [m for m in model.modules() if isinstance(m, _CANDIDATE_TYPES)]
|
| 171 |
+
if not candidates:
|
| 172 |
+
return None, None
|
| 173 |
+
|
| 174 |
+
shapes = {}
|
| 175 |
+
fired = set()
|
| 176 |
+
hooks = []
|
| 177 |
+
|
| 178 |
+
def _make_hook(mod):
|
| 179 |
+
def _hook(module, inputs, output):
|
| 180 |
+
tensor = output[0] if isinstance(output, (tuple, list)) else output
|
| 181 |
+
if not isinstance(tensor, torch.Tensor):
|
| 182 |
+
return
|
| 183 |
+
shapes[mod] = tuple(tensor.shape)
|
| 184 |
+
if tensor.requires_grad:
|
| 185 |
+
tensor.register_hook(lambda grad, m=mod: fired.add(m))
|
| 186 |
+
return _hook
|
| 187 |
+
|
| 188 |
+
for m in candidates:
|
| 189 |
+
hooks.append(m.register_forward_hook(_make_hook(m)))
|
| 190 |
+
|
| 191 |
+
was_training = model.training
|
| 192 |
+
single = sample_input[:1] if sample_input.shape[0] > 1 else sample_input
|
| 193 |
+
try:
|
| 194 |
+
model.eval()
|
| 195 |
+
with torch.enable_grad():
|
| 196 |
+
model.zero_grad(set_to_none=True)
|
| 197 |
+
output = model(single)
|
| 198 |
+
logits = output[0] if isinstance(output, (tuple, list)) else output
|
| 199 |
+
logits.sum().backward()
|
| 200 |
+
finally:
|
| 201 |
+
for h in hooks:
|
| 202 |
+
h.remove()
|
| 203 |
+
model.train(was_training)
|
| 204 |
+
|
| 205 |
+
batch = single.shape[0]
|
| 206 |
+
for m in reversed(candidates):
|
| 207 |
+
if m not in fired:
|
| 208 |
+
continue
|
| 209 |
+
transform = _layout_transform(m, shapes[m], batch)
|
| 210 |
+
if transform is not None:
|
| 211 |
+
return m, transform
|
| 212 |
+
return None, None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _last_norm(model):
|
| 216 |
+
norms = [m for m in model.modules()
|
| 217 |
+
if isinstance(m, (torch.nn.LayerNorm, torch.nn.InstanceNorm3d,
|
| 218 |
+
torch.nn.BatchNorm3d))]
|
| 219 |
+
if not norms:
|
| 220 |
+
raise RuntimeError("No conv or norm layer found for GradCAM target.")
|
| 221 |
+
return norms[-1]
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def select_heatmap_cases(dataset):
|
| 225 |
+
"""Deterministic: sort by UID, first sample per (label, center)."""
|
| 226 |
+
picks = {}
|
| 227 |
+
for s in sorted(dataset.samples, key=lambda s: s["uid"]):
|
| 228 |
+
key = (s["label"], s["center"])
|
| 229 |
+
if key not in picks:
|
| 230 |
+
picks[key] = s
|
| 231 |
+
return list(picks.values())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def uka_label_of(dataset, index):
|
| 235 |
+
"""Label of `dataset[index]`, read from the untransformed Subject.
|
| 236 |
+
|
| 237 |
+
The label is already resolved from the split CSV when BreastMRISubjects builds
|
| 238 |
+
its Subjects, so heatmap cases can be picked cheaply, without reading any
|
| 239 |
+
NIfTI file.
|
| 240 |
+
"""
|
| 241 |
+
return int(float(dataset.subjects[index]['label']))
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def uka_key_of(dataset, index):
|
| 245 |
+
"""Patient key ('<PatID><side>') of `dataset[index]`."""
|
| 246 |
+
return dataset.subjects[index]['path']
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def select_uka_heatmap_cases(dataset, per_label=1):
|
| 250 |
+
"""Deterministic: sort by patient key, take the first `per_label` samples per label.
|
| 251 |
+
|
| 252 |
+
Returns [(index, label), ...] for a BreastMRISubjects. Samples whose label
|
| 253 |
+
cannot be resolved (e.g. missing row in the split file) are skipped rather
|
| 254 |
+
than aborting the selection.
|
| 255 |
+
"""
|
| 256 |
+
order = sorted(range(len(dataset.subjects)), key=lambda i: uka_key_of(dataset, i))
|
| 257 |
+
picks, counts = [], {}
|
| 258 |
+
for index in order:
|
| 259 |
+
try:
|
| 260 |
+
label = uka_label_of(dataset, index)
|
| 261 |
+
except Exception as exc:
|
| 262 |
+
logger.warning("Could not read label of sample %d (%s); skipping.", index, exc)
|
| 263 |
+
continue
|
| 264 |
+
if counts.get(label, 0) >= per_label:
|
| 265 |
+
continue
|
| 266 |
+
counts[label] = counts.get(label, 0) + 1
|
| 267 |
+
picks.append((index, label))
|
| 268 |
+
return picks
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def render_heatmap(model, model_name, volume, output_png, device=None):
|
| 272 |
+
# The design requires render_heatmap to NEVER crash the eval run: target-layer
|
| 273 |
+
# resolution, device transfers, CAM computation, and saving all live inside
|
| 274 |
+
# this try/except so any failure (e.g. no connected/spatial layer found,
|
| 275 |
+
# a finicky transformer CAM, an I/O error) is logged and swallowed.
|
| 276 |
+
try:
|
| 277 |
+
from pytorch_grad_cam import GradCAM
|
| 278 |
+
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 279 |
+
model = model.to(device).eval()
|
| 280 |
+
volume = volume.to(device)
|
| 281 |
+
target_layer, reshape = resolve_cam_target(model, model_name, sample_input=volume)
|
| 282 |
+
cam = GradCAM(model=model, target_layers=[target_layer],
|
| 283 |
+
reshape_transform=reshape)
|
| 284 |
+
grayscale = cam(input_tensor=volume) # (1, W, H, D) or (1, W, H)
|
| 285 |
+
heat = np.asarray(grayscale)[0]
|
| 286 |
+
# torchio hands over volumes as (C, W, H, D), so the slice axis is the LAST one, not the
|
| 287 |
+
# first spatial one. Cutting along dim 2 gives a W x D strip (e.g. 256 x 32), which is why
|
| 288 |
+
# the overlay came out as a squeezed sliver instead of an in-plane image.
|
| 289 |
+
img_slice = volume[0, 0, :, :, volume.shape[-1] // 2].detach().cpu().numpy()
|
| 290 |
+
heat_slice = heat[..., heat.shape[-1] // 2] if heat.ndim == 3 else heat
|
| 291 |
+
fig, ax = plt.subplots()
|
| 292 |
+
# .T so height runs down the image and width across it, the usual in-plane orientation
|
| 293 |
+
ax.imshow(img_slice.T, cmap="gray")
|
| 294 |
+
ax.imshow(heat_slice.T, cmap="jet", alpha=0.4)
|
| 295 |
+
ax.axis("off")
|
| 296 |
+
fig.savefig(output_png, bbox_inches="tight")
|
| 297 |
+
plt.close(fig)
|
| 298 |
+
except Exception as exc: # transformer CAMs can be finicky; never crash the eval run
|
| 299 |
+
logger.warning("GradCAM failed for %s (%s); skipping heatmap.", model_name, exc)
|
auto_detect_breast_mri/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pickle
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import pylab as pl
|
| 6 |
+
import scipy.stats
|
| 7 |
+
import torch
|
| 8 |
+
import wandb
|
| 9 |
+
from sklearn.metrics import confusion_matrix, roc_curve, auc
|
| 10 |
+
|
| 11 |
+
from auto_detect_breast_mri.evaluation.cluster_bootstrap import (sensitivity_at_specificity,
|
| 12 |
+
specificity_at_sensitivity)
|
| 13 |
+
|
| 14 |
+
import matplotlib.pyplot as plt
|
| 15 |
+
|
| 16 |
+
# The rate held fixed when reading off the other one, see log_operating_points.
|
| 17 |
+
DEFAULT_OPERATING_TARGET = 0.9
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Copied from https://github.com/yandexdataschool/roc_comparison/blob/master/compare_auc_delong_xu.py
|
| 21 |
+
|
| 22 |
+
# AUC comparison adapted from
|
| 23 |
+
# https://github.com/Netflix/vmaf/
|
| 24 |
+
def compute_midrank(x):
|
| 25 |
+
"""Computes midranks.
|
| 26 |
+
Args:
|
| 27 |
+
x - a 1D numpy array
|
| 28 |
+
Returns:
|
| 29 |
+
array of midranks
|
| 30 |
+
"""
|
| 31 |
+
J = np.argsort(x)
|
| 32 |
+
Z = x[J]
|
| 33 |
+
N = len(x)
|
| 34 |
+
T = np.zeros(N, dtype=np.float64)
|
| 35 |
+
i = 0
|
| 36 |
+
while i < N:
|
| 37 |
+
j = i
|
| 38 |
+
while j < N and Z[j] == Z[i]:
|
| 39 |
+
j += 1
|
| 40 |
+
T[i:j] = 0.5*(i + j - 1)
|
| 41 |
+
i = j
|
| 42 |
+
T2 = np.empty(N, dtype=np.float64)
|
| 43 |
+
# Note(kazeevn) +1 is due to Python using 0-based indexing
|
| 44 |
+
# instead of 1-based in the AUC formula in the paper
|
| 45 |
+
T2[J] = T + 1
|
| 46 |
+
return T2
|
| 47 |
+
|
| 48 |
+
def compute_ground_truth_statistics(ground_truth):
|
| 49 |
+
assert np.array_equal(np.unique(ground_truth), [0, 1])
|
| 50 |
+
order = (-ground_truth).argsort()
|
| 51 |
+
label_1_count = int(ground_truth.sum())
|
| 52 |
+
return order, label_1_count
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fastDeLong(predictions_sorted_transposed, label_1_count):
|
| 56 |
+
"""
|
| 57 |
+
The fast version of DeLong's method for computing the covariance of
|
| 58 |
+
unadjusted AUC.
|
| 59 |
+
Args:
|
| 60 |
+
predictions_sorted_transposed: a 2D numpy.array[n_classifiers, n_examples]
|
| 61 |
+
sorted such as the examples with label "1" are first
|
| 62 |
+
Returns:
|
| 63 |
+
(AUC value, DeLong covariance)
|
| 64 |
+
Reference:
|
| 65 |
+
@article{sun2014fast,
|
| 66 |
+
title={Fast Implementation of DeLong's Algorithm for
|
| 67 |
+
Comparing the Areas Under Correlated Receiver Operating Characteristic Curves},
|
| 68 |
+
author={Xu Sun and Weichao Xu},
|
| 69 |
+
journal={IEEE Signal Processing Letters},
|
| 70 |
+
volume={21},
|
| 71 |
+
number={11},
|
| 72 |
+
pages={1389--1393},
|
| 73 |
+
year={2014},
|
| 74 |
+
publisher={IEEE}
|
| 75 |
+
}
|
| 76 |
+
"""
|
| 77 |
+
# Short variables are named as they are in the paper
|
| 78 |
+
m = label_1_count
|
| 79 |
+
n = predictions_sorted_transposed.shape[1] - m
|
| 80 |
+
positive_examples = predictions_sorted_transposed[:, :m]
|
| 81 |
+
negative_examples = predictions_sorted_transposed[:, m:]
|
| 82 |
+
k = predictions_sorted_transposed.shape[0]
|
| 83 |
+
|
| 84 |
+
tx = np.empty([k, m], dtype=np.float64)
|
| 85 |
+
ty = np.empty([k, n], dtype=np.float64)
|
| 86 |
+
tz = np.empty([k, m + n], dtype=np.float64)
|
| 87 |
+
for r in range(k):
|
| 88 |
+
tx[r, :] = compute_midrank(positive_examples[r, :])
|
| 89 |
+
ty[r, :] = compute_midrank(negative_examples[r, :])
|
| 90 |
+
tz[r, :] = compute_midrank(predictions_sorted_transposed[r, :])
|
| 91 |
+
aucs = tz[:, :m].sum(axis=1) / m / n - float(m + 1.0) / 2.0 / n
|
| 92 |
+
v01 = (tz[:, :m] - tx[:, :]) / n
|
| 93 |
+
v10 = 1.0 - (tz[:, m:] - ty[:, :]) / m
|
| 94 |
+
sx = np.cov(v01)
|
| 95 |
+
sy = np.cov(v10)
|
| 96 |
+
delongcov = sx / m + sy / n
|
| 97 |
+
return aucs, delongcov
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def calc_pvalue(aucs, sigma):
|
| 101 |
+
"""Computes log(10) of p-values.
|
| 102 |
+
Args:
|
| 103 |
+
aucs: 1D array of AUCs
|
| 104 |
+
sigma: AUC DeLong covariances
|
| 105 |
+
Returns:
|
| 106 |
+
pvalue
|
| 107 |
+
"""
|
| 108 |
+
l = np.array([[1, -1]])
|
| 109 |
+
z = np.abs(np.diff(aucs)) / np.sqrt(np.dot(np.dot(l, sigma), l.T))
|
| 110 |
+
return 2 * scipy.stats.norm.sf(z)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# modified according to https://github.com/yandexdataschool/roc_comparison/issues/10
|
| 114 |
+
def delong_roc_test(ground_truth, predictions_one, predictions_two):
|
| 115 |
+
"""
|
| 116 |
+
Computes log(p-value) for hypothesis that two ROC AUCs are different
|
| 117 |
+
Args:
|
| 118 |
+
ground_truth: np.array of 0 and 1
|
| 119 |
+
predictions_one: predictions of the first model,
|
| 120 |
+
np.array of floats of the probability of being class 1
|
| 121 |
+
predictions_two: predictions of the second model,
|
| 122 |
+
np.array of floats of the probability of being class 1
|
| 123 |
+
"""
|
| 124 |
+
order, label_1_count = compute_ground_truth_statistics(ground_truth.flatten())
|
| 125 |
+
predictions_sorted_transposed = np.vstack((predictions_one.flatten(), predictions_two.flatten()))[:, order]
|
| 126 |
+
aucs, delongcov = fastDeLong(predictions_sorted_transposed, label_1_count)
|
| 127 |
+
return calc_pvalue(aucs, delongcov)
|
| 128 |
+
|
| 129 |
+
def _as_numpy(tensor):
|
| 130 |
+
"""Detach a torch tensor into a flat numpy array; pass numpy and lists through."""
|
| 131 |
+
if isinstance(tensor, torch.Tensor):
|
| 132 |
+
return tensor.detach().cpu().flatten().numpy()
|
| 133 |
+
return np.asarray(tensor).flatten()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def log_operating_points(y_true, y_scores, target=DEFAULT_OPERATING_TARGET, prefix=''):
|
| 137 |
+
"""
|
| 138 |
+
Report the two fixed operating points of a score: the sensitivity reached once specificity is
|
| 139 |
+
held at `target`, and the specificity reached once sensitivity is held at `target`.
|
| 140 |
+
|
| 141 |
+
The single number the callers log next to the AUC is taken at the model's own 0.5 threshold,
|
| 142 |
+
which says little on an imbalanced set and is not comparable between models. Pinning one of the
|
| 143 |
+
two rates and reading off the other is, which is what this adds.
|
| 144 |
+
|
| 145 |
+
The thresholds come from the same data they are evaluated on, so these values are optimistic
|
| 146 |
+
and descriptive only, exactly like the secondary analysis in analyze_noninferiority.py.
|
| 147 |
+
|
| 148 |
+
:param y_true: ground truth binary labels
|
| 149 |
+
:param y_scores: continuous scores/probabilities of the positive class
|
| 150 |
+
:param target: the rate that is held fixed, for both directions. Default 0.9.
|
| 151 |
+
:param prefix: prepended to the wandb keys, e.g. 'Test'
|
| 152 |
+
:return: dict of the logged values, empty if a class is absent from y_true
|
| 153 |
+
"""
|
| 154 |
+
labels, scores = _as_numpy(y_true), _as_numpy(y_scores)
|
| 155 |
+
sens_at, achieved_spec, threshold_spec = sensitivity_at_specificity(labels, scores, target)
|
| 156 |
+
spec_at, achieved_sens, threshold_sens = specificity_at_sensitivity(labels, scores, target)
|
| 157 |
+
if np.isnan(sens_at) or np.isnan(spec_at):
|
| 158 |
+
print(f"Operating points at {target:.0%} need both classes; y_true holds only "
|
| 159 |
+
f"{sorted(set(labels.tolist()))}.")
|
| 160 |
+
return {}
|
| 161 |
+
|
| 162 |
+
print(f"At specificity {achieved_spec:.4f} (target {target:.0%}, threshold {threshold_spec:.4f}): "
|
| 163 |
+
f"sensitivity {sens_at:.4f}")
|
| 164 |
+
print(f"At sensitivity {achieved_sens:.4f} (target {target:.0%}, threshold {threshold_sens:.4f}): "
|
| 165 |
+
f"specificity {spec_at:.4f}")
|
| 166 |
+
values = {f"sensitivity_at_specificity{target:g}" + (f"/{prefix}" if prefix else ""): sens_at,
|
| 167 |
+
f"specificity_at_sensitivity{target:g}" + (f"/{prefix}" if prefix else ""): spec_at,
|
| 168 |
+
f"threshold_at_specificity{target:g}" + (f"/{prefix}" if prefix else ""): threshold_spec,
|
| 169 |
+
f"threshold_at_sensitivity{target:g}" + (f"/{prefix}" if prefix else ""): threshold_sens}
|
| 170 |
+
if wandb.run is not None:
|
| 171 |
+
wandb.log(values)
|
| 172 |
+
return values
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def compute_sensitivity_specificity(y_true, y_pred, y_scores=None,
|
| 176 |
+
target=DEFAULT_OPERATING_TARGET, prefix=''):
|
| 177 |
+
"""
|
| 178 |
+
Compute sensitivity (true positive rate) and specificity (true negative rate).
|
| 179 |
+
|
| 180 |
+
Parameters:
|
| 181 |
+
- y_true: Ground truth labels (true binary labels).
|
| 182 |
+
- y_pred: Predicted labels (predicted binary labels).
|
| 183 |
+
- y_scores: Continuous scores of the positive class. If given, the two fixed operating points
|
| 184 |
+
are logged in addition (see log_operating_points): sensitivity at specificity `target` and
|
| 185 |
+
specificity at sensitivity `target`. Without scores only the 0.5 threshold pair is available.
|
| 186 |
+
- target: the rate held fixed for those operating points. Default 0.9.
|
| 187 |
+
- prefix: prepended to the wandb keys of those operating points, e.g. 'Test'.
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
- sensitivity: Sensitivity (true positive rate).
|
| 191 |
+
- specificity: Specificity (true negative rate).
|
| 192 |
+
"""
|
| 193 |
+
if y_scores is not None:
|
| 194 |
+
log_operating_points(y_true, y_scores, target=target, prefix=prefix)
|
| 195 |
+
|
| 196 |
+
if not torch.all(y_true == y_pred):
|
| 197 |
+
tn, fp, fn, tp = confusion_matrix(y_true.flatten(), y_pred.flatten()).ravel()
|
| 198 |
+
sensitivity = tp / (tp + fn) if (tp + fn) != 0 else tp
|
| 199 |
+
specificity = tn / (tn + fp) if (tn + fp) != 0 else tn
|
| 200 |
+
return sensitivity, specificity
|
| 201 |
+
return 1, 1
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def compute_stratified_metrics(y_true, y_pred, y_scores, metadata,
|
| 205 |
+
indication_column='Indication', birads_column='BIRADS'):
|
| 206 |
+
"""Compute overall and subgroup metrics for aligned breast-level predictions.
|
| 207 |
+
|
| 208 |
+
``metadata`` must contain one row per prediction, in the same order as the three
|
| 209 |
+
prediction arrays. The returned DataFrame has one overall row and one row for
|
| 210 |
+
every observed indication and BI-RADS category. AUC is ``NaN`` when a group
|
| 211 |
+
contains only one class, because an ROC curve is undefined in that case.
|
| 212 |
+
"""
|
| 213 |
+
if not isinstance(metadata, pd.DataFrame):
|
| 214 |
+
raise TypeError("metadata must be a pandas DataFrame")
|
| 215 |
+
|
| 216 |
+
true_values = np.asarray(y_true).reshape(-1)
|
| 217 |
+
predicted_values = np.asarray(y_pred).reshape(-1)
|
| 218 |
+
score_values = np.asarray(y_scores).reshape(-1)
|
| 219 |
+
if not (len(true_values) == len(predicted_values) == len(score_values) == len(metadata)):
|
| 220 |
+
raise ValueError("y_true, y_pred, y_scores, and metadata must have the same length")
|
| 221 |
+
|
| 222 |
+
missing_columns = [name for name in (indication_column, birads_column)
|
| 223 |
+
if name not in metadata.columns]
|
| 224 |
+
if missing_columns:
|
| 225 |
+
raise ValueError(f"metadata is missing stratification column(s): {missing_columns}")
|
| 226 |
+
|
| 227 |
+
def metric_row(scope, category, indices):
|
| 228 |
+
group_true = true_values[indices].astype(int)
|
| 229 |
+
group_pred = predicted_values[indices].astype(int)
|
| 230 |
+
group_scores = score_values[indices].astype(float)
|
| 231 |
+
sensitivity, specificity = compute_sensitivity_specificity(group_true, group_pred)
|
| 232 |
+
roc_auc = (float(auc(*roc_curve(group_true, group_scores)[:2]))
|
| 233 |
+
if np.unique(group_true).size == 2 else np.nan)
|
| 234 |
+
return {
|
| 235 |
+
'scope': scope,
|
| 236 |
+
'category': category,
|
| 237 |
+
'n': len(indices),
|
| 238 |
+
'positives': int(group_true.sum()),
|
| 239 |
+
'sensitivity': float(sensitivity),
|
| 240 |
+
'specificity': float(specificity),
|
| 241 |
+
'auc': roc_auc,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
rows = [metric_row('overall', 'overall', np.arange(len(true_values)))]
|
| 245 |
+
for scope, column_name in (('indication', indication_column), ('birads', birads_column)):
|
| 246 |
+
categories = metadata[column_name].astype('object').where(
|
| 247 |
+
metadata[column_name].notna(), 'Unknown')
|
| 248 |
+
for category, group in categories.groupby(categories, sort=True):
|
| 249 |
+
rows.append(metric_row(scope, category, group.index.to_numpy()))
|
| 250 |
+
return pd.DataFrame(rows, columns=[
|
| 251 |
+
'scope', 'category', 'n', 'positives', 'sensitivity', 'specificity', 'auc'])
|
| 252 |
+
|
| 253 |
+
def compute_roc_curve(y_true, y_scores, plot=False, title_suffix=''):
|
| 254 |
+
"""
|
| 255 |
+
Compute the ROC curve and AUC (Area Under the Curve).
|
| 256 |
+
|
| 257 |
+
Parameters:
|
| 258 |
+
- y_true: Ground truth labels (true binary labels).
|
| 259 |
+
- y_scores: Predicted scores or probabilities for positive class.
|
| 260 |
+
- plot: Boolean to define if computed ROC AUC shall be plotted or not. Default is False.
|
| 261 |
+
- title_suffix: string to be appended to the plot title in case plot=True.
|
| 262 |
+
If plot is False, this parameter is ignored.
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
- fpr: False Positive Rate (1 - Specificity).
|
| 266 |
+
- tpr: True Positive Rate (Sensitivity).
|
| 267 |
+
- roc_auc: Area Under the ROC Curve (AUC).
|
| 268 |
+
"""
|
| 269 |
+
fpr, tpr, thresholds = roc_curve(y_true.flatten().detach().numpy(), y_scores.flatten().detach().numpy())
|
| 270 |
+
best_index = np.argmax(tpr - fpr)
|
| 271 |
+
print("Youden Index: ", thresholds[best_index])
|
| 272 |
+
print(f"FPR: {fpr[best_index]}, TPR: {tpr[best_index]}")
|
| 273 |
+
print(f"Specificity: {1 - fpr[best_index]}, Sensitivity: {tpr[best_index]}")
|
| 274 |
+
roc_auc = auc(fpr, tpr)
|
| 275 |
+
|
| 276 |
+
if plot:
|
| 277 |
+
# ROC plot
|
| 278 |
+
if title_suffix is None:
|
| 279 |
+
title_suffix = ''
|
| 280 |
+
plt.title('Receiver Operating Characteristic: ' + title_suffix)
|
| 281 |
+
plt.plot(fpr, tpr, 'b', label='AUC = %0.2f' % roc_auc)
|
| 282 |
+
plt.legend(loc='lower right')
|
| 283 |
+
plt.plot([0, 1], [0, 1], 'r--')
|
| 284 |
+
plt.xlim([0, 1])
|
| 285 |
+
plt.ylim([0, 1])
|
| 286 |
+
plt.ylabel('True Positive Rate')
|
| 287 |
+
plt.xlabel('False Positive Rate')
|
| 288 |
+
#plt.show()
|
| 289 |
+
|
| 290 |
+
title_suffix = title_suffix.replace(' ', '_')
|
| 291 |
+
title_suffix = title_suffix.replace('resnet50_', '')
|
| 292 |
+
title_suffix = title_suffix.replace('resnet18_', '')
|
| 293 |
+
roc_key = "roc_auc/" + title_suffix
|
| 294 |
+
spec_key = "Specificity (Youden)/" + title_suffix
|
| 295 |
+
sens_key = "Sensitivity (Youden)/" + title_suffix
|
| 296 |
+
wandb.log({
|
| 297 |
+
roc_key: wandb.Image(plt),
|
| 298 |
+
spec_key: 1 - fpr[best_index],
|
| 299 |
+
sens_key: tpr[best_index]
|
| 300 |
+
})
|
| 301 |
+
plt.close()
|
| 302 |
+
|
| 303 |
+
# TPR against 1-FPR: helps to find optimal threshold
|
| 304 |
+
i = np.arange(len(tpr)) # index for df
|
| 305 |
+
roc = pd.DataFrame(
|
| 306 |
+
{'fpr': pd.Series(fpr, index=i), 'tpr': pd.Series(tpr, index=i), '1-fpr': pd.Series(1 - fpr, index=i),
|
| 307 |
+
'tf': pd.Series(tpr - (1 - fpr), index=i), 'thresholds': pd.Series(thresholds, index=i)})
|
| 308 |
+
roc.iloc[(roc.tf - 0).abs().argsort()[:1]]
|
| 309 |
+
# Plot tpr vs 1-fpr
|
| 310 |
+
fig, ax = pl.subplots()
|
| 311 |
+
pl.plot(roc['tpr'])
|
| 312 |
+
pl.plot(roc['1-fpr'], color='red')
|
| 313 |
+
pl.xlabel('1-False Positive Rate')
|
| 314 |
+
pl.ylabel('True Positive Rate')
|
| 315 |
+
pl.title('Receiver operating characteristic')
|
| 316 |
+
ax.set_xticklabels([])
|
| 317 |
+
wandb.log({
|
| 318 |
+
"Thresholds/" + title_suffix: fig
|
| 319 |
+
})
|
| 320 |
+
pl.close(fig)
|
| 321 |
+
roc_values = {"roc": roc, "fpr": fpr, "tpr": tpr, "thresholds": thresholds, "best_index_val": best_index, "roc_auc_val": roc_auc}
|
| 322 |
+
else:
|
| 323 |
+
roc_values = {"fpr": fpr, "tpr": tpr, "thresholds": thresholds, "best_index_val": best_index, "roc_auc_val": roc_auc}
|
| 324 |
+
return roc_values
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def auc_bootstrapping(y_true, y_score, bootstrapping=1000, drop_intermediate=False):
|
| 328 |
+
tprs, aucs, thrs, sens, spec = [], [], [], [], []
|
| 329 |
+
mean_fpr = np.linspace(0, 1, 100) # fixed set for analysation later
|
| 330 |
+
np.random.seed(0)
|
| 331 |
+
rand_idxs = np.random.randint(0, len(y_true), size=(bootstrapping, len(y_true))) # Note: with replacement
|
| 332 |
+
for rand_idx in rand_idxs:
|
| 333 |
+
y_true_set = y_true[rand_idx]
|
| 334 |
+
y_score_set = y_score[rand_idx]
|
| 335 |
+
|
| 336 |
+
# calc aucs
|
| 337 |
+
fpr, tpr, thresholds = roc_curve(y_true_set, y_score_set, drop_intermediate=drop_intermediate)
|
| 338 |
+
tpr_interp = np.interp(mean_fpr, fpr, tpr) # must be interpolated to gain constant/equal fpr positions
|
| 339 |
+
tprs.append(tpr_interp)
|
| 340 |
+
aucs.append(auc(fpr, tpr))
|
| 341 |
+
optimal_idx = np.argmax(tpr - fpr)
|
| 342 |
+
thrs.append(thresholds[optimal_idx])
|
| 343 |
+
# sensitivity and specificity
|
| 344 |
+
sens.append(tpr[optimal_idx])
|
| 345 |
+
spec.append(1 - fpr[optimal_idx])
|
| 346 |
+
return tprs, aucs, thrs, mean_fpr, sens, spec
|
| 347 |
+
|
| 348 |
+
def plot_aucs(mean_fpr, tprs1, tprs2, aucs1, aucs2, model_name, file_name):
|
| 349 |
+
# Durchschnittliche TPR und Standardabweichung für Modell 1
|
| 350 |
+
mean_tpr_1 = np.mean(tprs1 , axis=0)
|
| 351 |
+
std_tpr_1 = np.std(tprs1 , axis=0)
|
| 352 |
+
|
| 353 |
+
# Durchschnittliche TPR und Standardabweichung für Modell 2
|
| 354 |
+
mean_tpr_2 = np.mean(tprs2 , axis=0)
|
| 355 |
+
std_tpr_2 = np.std(tprs2 , axis=0)
|
| 356 |
+
|
| 357 |
+
# Plotte die ROC-Kurven
|
| 358 |
+
plt.figure()
|
| 359 |
+
plt.plot(mean_fpr, mean_tpr_1, color='blue',label='FDP (AUC={:.3f})'.format(np.mean(aucs1)))
|
| 360 |
+
|
| 361 |
+
# diagonal
|
| 362 |
+
plt.plot([0, 1], [0, 1], 'r--')
|
| 363 |
+
plt.xlim([0, 1])
|
| 364 |
+
plt.ylim([0, 1])
|
| 365 |
+
plt.fill_between(mean_fpr ,
|
| 366 |
+
mean_tpr_1 - std_tpr_1,
|
| 367 |
+
mean_tpr_1 + std_tpr_1,
|
| 368 |
+
color='blue', alpha=0.3)
|
| 369 |
+
|
| 370 |
+
plt.plot(mean_fpr, mean_tpr_2, color='orange',label='AP (AUC={:.3f})'.format(np.mean(aucs2)))
|
| 371 |
+
plt.fill_between(mean_fpr,
|
| 372 |
+
mean_tpr_2 - std_tpr_2,
|
| 373 |
+
mean_tpr_2 + std_tpr_2,
|
| 374 |
+
color='orange', alpha=0.3)
|
| 375 |
+
|
| 376 |
+
plt.xlabel('False Positive Rate')
|
| 377 |
+
plt.ylabel('True Positive Rate')
|
| 378 |
+
plt.title(f'Receiver Operating Characteristic for {model_name}')
|
| 379 |
+
plt.legend(loc='lower right')
|
| 380 |
+
plt.savefig(file_name)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
if __name__ == "__main__":
|
| 384 |
+
"""
|
| 385 |
+
Bootstrap AUC/sensitivity/specificity of the abbreviated against the full protocol from the
|
| 386 |
+
per-fold result pickles written by the comparison scripts.
|
| 387 |
+
|
| 388 |
+
python -m auto_detect_breast_mri.evaluation.metrics --model-name resnet50 [--result-root <folder>]
|
| 389 |
+
"""
|
| 390 |
+
import argparse
|
| 391 |
+
|
| 392 |
+
from auto_detect_breast_mri.config import get_config, resolve_path
|
| 393 |
+
|
| 394 |
+
parser = argparse.ArgumentParser(description="Bootstrap the abrv/full comparison of one architecture.")
|
| 395 |
+
parser.add_argument("--model-name", default="resnet50", help="architecture prefix of the pickles")
|
| 396 |
+
parser.add_argument("--result-root", default=None,
|
| 397 |
+
help="folder holding the *_roc-dict.pkl files. Config key: output_root")
|
| 398 |
+
parser.add_argument("--pickle-pattern", default="{}_{}_split{}-0_roc-dict.pkl",
|
| 399 |
+
help="file name pattern with model name, protocol and fold placeholders")
|
| 400 |
+
parser.add_argument("--folds", type=int, nargs="+", default=[0, 1, 2, 3, 4])
|
| 401 |
+
args = parser.parse_args()
|
| 402 |
+
|
| 403 |
+
result_root = resolve_path(args.result_root, "output_root", "folder with the result pickles")
|
| 404 |
+
pickle_file_name = os.path.join(result_root, args.pickle_pattern)
|
| 405 |
+
model_name = args.model_name
|
| 406 |
+
wandb.init(**get_config().wandb_init_kwargs(name=model_name))
|
| 407 |
+
|
| 408 |
+
full_predictions = []
|
| 409 |
+
full_probs = []
|
| 410 |
+
abrv_predictions = []
|
| 411 |
+
abrv_probs = []
|
| 412 |
+
ground_truth = []
|
| 413 |
+
for fold in args.folds:
|
| 414 |
+
for prot in ["abrv", "full"]:
|
| 415 |
+
curr_pickle_file = pickle_file_name.format(model_name, prot, fold)
|
| 416 |
+
with open(curr_pickle_file, 'rb') as pickle_file:
|
| 417 |
+
loaded_data = pickle.load(pickle_file)
|
| 418 |
+
if prot == "full":
|
| 419 |
+
ground_truth.append(loaded_data["y_true"])
|
| 420 |
+
full_predictions.append(loaded_data["y_pred"])
|
| 421 |
+
full_probs.append(loaded_data["y_probs"])
|
| 422 |
+
else:
|
| 423 |
+
abrv_predictions.append(loaded_data["y_pred"])
|
| 424 |
+
abrv_probs.append(loaded_data["y_probs"])
|
| 425 |
+
print("model " + model_name + "full vs abrv: ")
|
| 426 |
+
|
| 427 |
+
# compute the evaluation metrices:
|
| 428 |
+
### bootstrapping for AUC, Sensitivities and Specificities
|
| 429 |
+
tprs_full, aucs_full, thrs_full, mean_fpr_full, sens_full, spec_full = auc_bootstrapping(np.concatenate(ground_truth).flatten(), np.concatenate(full_probs).flatten())
|
| 430 |
+
tprs_abrv, aucs_abrv, thrs_abrv, mean_fpr_abrv, sens_abrv, spec_abrv = auc_bootstrapping(np.concatenate(ground_truth).flatten(), np.concatenate(abrv_probs).flatten())
|
| 431 |
+
|
| 432 |
+
p_value = delong_roc_test(np.concatenate(ground_truth), np.concatenate(abrv_predictions), np.concatenate(full_predictions))
|
| 433 |
+
print("Total p value: " + str(p_value))
|
| 434 |
+
|
| 435 |
+
# plot roc curves of abrv and full in one figure:
|
| 436 |
+
plot_filename = pickle_file_name.split("_split")[0].format(model_name, "bootstrap.png")
|
| 437 |
+
plot_aucs(mean_fpr_full, tprs_full, tprs_abrv, aucs_full, aucs_abrv, model_name, plot_filename)
|
| 438 |
+
wandb.log({"p_value": p_value,
|
| 439 |
+
"AUC-mean/full": np.mean(aucs_full),
|
| 440 |
+
"AUC-mean/abrv": np.mean(aucs_abrv),
|
| 441 |
+
"AUC-std/full": np.std(aucs_full),
|
| 442 |
+
"AUC-std/abrv": np.std(aucs_abrv),
|
| 443 |
+
"Sensitivity-mean/full": np.mean(sens_full),
|
| 444 |
+
"Sensitivity-mean/abrv": np.mean(sens_abrv),
|
| 445 |
+
"Sensitivity-std/full": np.std(sens_full),
|
| 446 |
+
"Sensitivity-std/abrv": np.std(sens_abrv),
|
| 447 |
+
"Specificity-mean/full": np.mean(spec_full),
|
| 448 |
+
"Specificity-mean/abrv": np.mean(spec_abrv),
|
| 449 |
+
"Specificity-std/full": np.std(spec_full),
|
| 450 |
+
"Specificity-std/abrv": np.std(spec_abrv),
|
| 451 |
+
})
|
| 452 |
+
|
| 453 |
+
bootstrap_result = {"tprs_full": tprs_full, "aucs_full": aucs_full, "thrs_full": thrs_full, "mean_fpr_full": mean_fpr_full,
|
| 454 |
+
"tprs_abrv": tprs_abrv, "aucs_abrv": aucs_abrv, "thrs_abrv": thrs_abrv, "mean_fpr_abrv": mean_fpr_abrv,
|
| 455 |
+
"p_value": p_value, "resulting_plot": plot_filename}
|
| 456 |
+
bootstrap_pickle_name = plot_filename.replace(".png", ".pkl")
|
| 457 |
+
with open(bootstrap_pickle_name, 'wb') as pickle_file:
|
| 458 |
+
pickle.dump(bootstrap_result, pickle_file)
|
| 459 |
+
|
auto_detect_breast_mri/evaluation/oof_table.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Assembly and integrity checking of the out-of-fold prediction table.
|
| 3 |
+
|
| 4 |
+
The table has one row per breast, with the paired AP and FDP score of every architecture that was
|
| 5 |
+
predicted side by side, so that every downstream analysis works on exactly the same cases:
|
| 6 |
+
|
| 7 |
+
patient_id, outer_fold, examination_id, side, label,
|
| 8 |
+
AP_score_ResNet18, FDP_score_ResNet18, AP_score_ResNet50, FDP_score_ResNet50
|
| 9 |
+
|
| 10 |
+
Which score columns exist follows from the prediction files that are actually there: predicting only
|
| 11 |
+
one architecture yields a table with that architecture's two columns. Both protocols of an
|
| 12 |
+
architecture are required, since the whole analysis is the paired AP vs FDP comparison.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from auto_detect_breast_mri.config import column, indication_labels, normalise_indication_code
|
| 16 |
+
|
| 17 |
+
import glob
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
|
| 21 |
+
import pandas as pd
|
| 22 |
+
|
| 23 |
+
ARCHITECTURE_LABELS = {'resnet18': 'ResNet18', 'resnet50': 'ResNet50'}
|
| 24 |
+
PROTOCOL_LABELS = {'abrv': 'AP', 'full': 'FDP'}
|
| 25 |
+
KEY_COLUMNS = ['examination_id', 'side']
|
| 26 |
+
SIDES = ('left', 'right')
|
| 27 |
+
# oof_<architecture>_fold<k>[_frac=<f>].csv, as written by predict_oof.py
|
| 28 |
+
FILE_PATTERN = re.compile(r'^oof_(?P<architecture>[A-Za-z0-9]+)_fold(?P<fold>\d+)'
|
| 29 |
+
r'(?:_frac=(?P<fraction>[0-9.]+))?\.csv$')
|
| 30 |
+
UNKNOWN_INDICATION = 'unknown'
|
| 31 |
+
CASE_COLUMNS = ['patient_id', 'outer_fold', 'examination_id', 'side', 'label']
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def score_columns(architecture):
|
| 35 |
+
"""The AP and the FDP score column of one architecture label, e.g. 'ResNet18'."""
|
| 36 |
+
return [f'{protocol}_score_{architecture}' for protocol in PROTOCOL_LABELS.values()]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def score_columns_of(table):
|
| 40 |
+
"""
|
| 41 |
+
The score columns present in a table, grouped by architecture and in a stable order.
|
| 42 |
+
:return: list of column names, AP before FDP within each architecture
|
| 43 |
+
"""
|
| 44 |
+
return [name for architecture in ARCHITECTURE_LABELS.values()
|
| 45 |
+
for name in score_columns(architecture) if name in table.columns]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def architectures_of(table):
|
| 49 |
+
"""The architecture labels a table carries both scores of."""
|
| 50 |
+
return [architecture for architecture in ARCHITECTURE_LABELS.values()
|
| 51 |
+
if all(name in table.columns for name in score_columns(architecture))]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def read_metadata(metadata_file, wanted_ids=None):
|
| 55 |
+
"""
|
| 56 |
+
Read the metadata export and give it an `examination_id` column that matches the split CSVs.
|
| 57 |
+
|
| 58 |
+
The metadata export appends a two digit running number to the examination ID ('9132094301' for
|
| 59 |
+
'91320943'), which the split files do not carry. Matching the raw IDs therefore resolves nothing
|
| 60 |
+
at all on the UKA export. The ID is taken raw when that matches, and trimmed by the two trailing
|
| 61 |
+
digits otherwise, the same fallback the dataset loader and dataset_stats apply.
|
| 62 |
+
|
| 63 |
+
:param metadata_file: str, CSV/XLSX metadata export
|
| 64 |
+
:param wanted_ids: iterable of examination IDs to match against, or None to always trim
|
| 65 |
+
:return: (metadata frame with an added 'examination_id' column, description of how it matched)
|
| 66 |
+
"""
|
| 67 |
+
reader = pd.read_excel if str(metadata_file).endswith(('.xlsx', '.xls')) else pd.read_csv
|
| 68 |
+
metadata = reader(metadata_file, dtype=str)
|
| 69 |
+
id_column = column('metadata_examination_id')
|
| 70 |
+
if id_column not in metadata.columns:
|
| 71 |
+
raise ValueError(f"{metadata_file} has no column '{id_column}'.")
|
| 72 |
+
|
| 73 |
+
raw = metadata[id_column].astype(str).str.strip()
|
| 74 |
+
trimmed = raw.str[:-2]
|
| 75 |
+
if wanted_ids is None:
|
| 76 |
+
metadata['examination_id'], how = trimmed, "trimmed by the two trailing digits"
|
| 77 |
+
else:
|
| 78 |
+
wanted = set(map(str, wanted_ids))
|
| 79 |
+
matched_raw, matched_trimmed = raw.isin(wanted).sum(), trimmed.isin(wanted).sum()
|
| 80 |
+
if matched_trimmed > matched_raw:
|
| 81 |
+
metadata['examination_id'] = trimmed
|
| 82 |
+
how = (f"trimmed by the two trailing digits ({matched_trimmed} matches, "
|
| 83 |
+
f"{matched_raw} without trimming)")
|
| 84 |
+
else:
|
| 85 |
+
metadata['examination_id'] = raw
|
| 86 |
+
how = f"used as they are ({matched_raw} matches)"
|
| 87 |
+
return metadata, how
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _unique_mapping(metadata, value_column, what):
|
| 91 |
+
"""
|
| 92 |
+
dict examination_id -> value, dropping IDs that carry conflicting values.
|
| 93 |
+
:return: (mapping, list of the dropped ambiguous IDs)
|
| 94 |
+
"""
|
| 95 |
+
pairs = metadata[['examination_id', value_column]].dropna().drop_duplicates()
|
| 96 |
+
counts = pairs.groupby('examination_id').size()
|
| 97 |
+
ambiguous = sorted(counts[counts > 1].index)
|
| 98 |
+
pairs = pairs[~pairs['examination_id'].isin(ambiguous)]
|
| 99 |
+
if ambiguous and what == 'women':
|
| 100 |
+
raise ValueError(f"{len(ambiguous)} examinations map to more than one woman, "
|
| 101 |
+
f"e.g. {ambiguous[:5]}. The clustering would be wrong.")
|
| 102 |
+
return dict(zip(pairs['examination_id'], pairs[value_column])), ambiguous
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def load_patient_map(metadata_file, wanted_ids=None):
|
| 106 |
+
"""
|
| 107 |
+
Map examination IDs to the woman they belong to.
|
| 108 |
+
:param metadata_file: str
|
| 109 |
+
CSV holding the examination ID and the patient ID column.
|
| 110 |
+
:param wanted_ids: the examination IDs to match against, see read_metadata
|
| 111 |
+
:return: dict examination_id -> patient_id
|
| 112 |
+
"""
|
| 113 |
+
metadata, how = read_metadata(metadata_file, wanted_ids)
|
| 114 |
+
patient_column = column('patient_id')
|
| 115 |
+
if patient_column not in metadata.columns:
|
| 116 |
+
raise ValueError(f"{metadata_file} has no column '{patient_column}'; cannot resolve women.")
|
| 117 |
+
print(f" patient IDs from {metadata_file}: examination IDs {how}")
|
| 118 |
+
mapping, _ = _unique_mapping(metadata, patient_column, 'women')
|
| 119 |
+
return mapping
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def load_indication_map(metadata_file, wanted_ids=None):
|
| 123 |
+
"""
|
| 124 |
+
Map (examination ID, side) to the indication code of that breast.
|
| 125 |
+
|
| 126 |
+
The indication is recorded per side in the wide metadata export, so it lands on exactly one
|
| 127 |
+
breast, i.e. one row of the out-of-fold table. Codes are normalised to strings ('1.0' -> '1').
|
| 128 |
+
|
| 129 |
+
:return: (dict (examination_id, side) -> code, report dict)
|
| 130 |
+
"""
|
| 131 |
+
metadata, how = read_metadata(metadata_file, wanted_ids)
|
| 132 |
+
mapping, report = {}, {'id_matching': how, 'ambiguous': {}, 'per_side': {}}
|
| 133 |
+
for side in SIDES:
|
| 134 |
+
indication_column = column(f'indication_{side}')
|
| 135 |
+
if indication_column not in metadata.columns:
|
| 136 |
+
raise ValueError(f"{metadata_file} has no column '{indication_column}'; "
|
| 137 |
+
f"cannot build the indication subgroups.")
|
| 138 |
+
per_side, ambiguous = _unique_mapping(metadata, indication_column, f'indication {side}')
|
| 139 |
+
report['ambiguous'][side] = ambiguous
|
| 140 |
+
report['per_side'][side] = len(per_side)
|
| 141 |
+
for examination_id, code in per_side.items():
|
| 142 |
+
mapping[(examination_id, side)] = normalise_indication_code(code)
|
| 143 |
+
return mapping, report
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def attach_indication(table, metadata_file):
|
| 147 |
+
"""
|
| 148 |
+
Add an `indication` column to an out-of-fold table, one code per breast.
|
| 149 |
+
|
| 150 |
+
Breasts whose examination is missing from the metadata, or whose indication field is empty, get
|
| 151 |
+
UNKNOWN_INDICATION rather than being dropped: whether they are analysed as their own subgroup or
|
| 152 |
+
excluded is the caller's decision, not this function's.
|
| 153 |
+
|
| 154 |
+
:return: (table with the added column, report dict describing the match)
|
| 155 |
+
"""
|
| 156 |
+
mapping, report = load_indication_map(metadata_file, table['examination_id'].unique())
|
| 157 |
+
table = table.copy()
|
| 158 |
+
keys = list(zip(table['examination_id'].astype(str), table['side'].astype(str).str.lower()))
|
| 159 |
+
table['indication'] = [mapping.get(key, UNKNOWN_INDICATION) for key in keys]
|
| 160 |
+
|
| 161 |
+
labels = indication_labels()
|
| 162 |
+
table['indication_label'] = [labels.get(code, f"code {code}") if code != UNKNOWN_INDICATION
|
| 163 |
+
else UNKNOWN_INDICATION for code in table['indication']]
|
| 164 |
+
report['breasts'] = len(table)
|
| 165 |
+
report['without_indication'] = int((table['indication'] == UNKNOWN_INDICATION).sum())
|
| 166 |
+
report['per_code'] = table['indication'].value_counts().to_dict()
|
| 167 |
+
return table, report
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def load_oof_table(prediction_dir, metadata_file):
|
| 171 |
+
"""
|
| 172 |
+
Read the per fold prediction files written by predict_oof.py and pivot them into the wide table.
|
| 173 |
+
Only breasts for which every score of every usable architecture exists are kept; the dropped ones
|
| 174 |
+
are reported separately. An architecture counts as usable once both its abrv and its full
|
| 175 |
+
prediction files are present.
|
| 176 |
+
:param prediction_dir: str
|
| 177 |
+
folder holding oof_<architecture>_fold<k>.csv
|
| 178 |
+
:param metadata_file: str
|
| 179 |
+
CSV mapping AnforderungsNrE to PNR
|
| 180 |
+
:return: (table, report) with report describing what was dropped while merging
|
| 181 |
+
"""
|
| 182 |
+
files = sorted(glob.glob(os.path.join(prediction_dir, 'oof_*_fold*.csv')))
|
| 183 |
+
if not files:
|
| 184 |
+
raise FileNotFoundError(f"No oof_*_fold*.csv found in {prediction_dir}. Run predict_oof.py first.")
|
| 185 |
+
|
| 186 |
+
frames, folds_of_architecture, fractions = [], {}, {}
|
| 187 |
+
for path in files:
|
| 188 |
+
parsed = FILE_PATTERN.match(os.path.basename(path))
|
| 189 |
+
if parsed is None or parsed.group('architecture') not in ARCHITECTURE_LABELS:
|
| 190 |
+
raise ValueError(f"Cannot tell the architecture and fold from file name {path}.")
|
| 191 |
+
architecture = ARCHITECTURE_LABELS[parsed.group('architecture')]
|
| 192 |
+
frame = pd.read_csv(path, dtype={'examination_id': str, 'side': str})
|
| 193 |
+
frame['architecture'] = architecture
|
| 194 |
+
frames.append(frame)
|
| 195 |
+
folds_of_architecture.setdefault(architecture, set()).add(int(parsed.group('fold')))
|
| 196 |
+
fractions.setdefault(parsed.group('fraction') or 'full', []).append(os.path.basename(path))
|
| 197 |
+
long_table = pd.concat(frames, ignore_index=True)
|
| 198 |
+
|
| 199 |
+
# Predictions of different training fractions are different models. Averaging or pivoting them
|
| 200 |
+
# into one table would silently mix them, so refuse rather than produce a meaningless table.
|
| 201 |
+
if len(fractions) > 1:
|
| 202 |
+
listing = "; ".join(f"{name}: {len(paths)} files" for name, paths in sorted(fractions.items()))
|
| 203 |
+
raise ValueError(f"{prediction_dir} mixes predictions of several training fractions "
|
| 204 |
+
f"({listing}). Keep one fraction per folder.")
|
| 205 |
+
|
| 206 |
+
# A fold that only some architectures predicted is dropped completely further down, because a
|
| 207 |
+
# breast needs every score column. Say so here instead of letting it vanish into a count.
|
| 208 |
+
all_folds = set().union(*folds_of_architecture.values())
|
| 209 |
+
for architecture, folds in sorted(folds_of_architecture.items()):
|
| 210 |
+
if folds != all_folds:
|
| 211 |
+
print(f" WARNING: {architecture} has no prediction file for fold(s) "
|
| 212 |
+
f"{sorted(all_folds - folds)}; every breast of those folds will be dropped "
|
| 213 |
+
f"because it cannot have a complete set of scores.")
|
| 214 |
+
long_table['column'] = (long_table['protocol'].map(PROTOCOL_LABELS) + '_score_'
|
| 215 |
+
+ long_table['architecture'])
|
| 216 |
+
|
| 217 |
+
duplicates = long_table.duplicated(subset=KEY_COLUMNS + ['column'], keep=False)
|
| 218 |
+
if duplicates.any():
|
| 219 |
+
offending = long_table.loc[duplicates, KEY_COLUMNS + ['column', 'outer_fold']]
|
| 220 |
+
raise ValueError(f"{duplicates.sum()} duplicated predictions for the same breast and model, "
|
| 221 |
+
f"e.g.\n{offending.head().to_string()}")
|
| 222 |
+
|
| 223 |
+
wide = long_table.pivot(index=KEY_COLUMNS, columns='column', values='score').reset_index()
|
| 224 |
+
wide.columns.name = None
|
| 225 |
+
|
| 226 |
+
# fold and label are properties of the case, not of the model: take them from the long table
|
| 227 |
+
case_info = (long_table[KEY_COLUMNS + ['outer_fold', 'label']]
|
| 228 |
+
.drop_duplicates(subset=KEY_COLUMNS))
|
| 229 |
+
inconsistent = (long_table.groupby(KEY_COLUMNS)[['outer_fold', 'label']].nunique() > 1)
|
| 230 |
+
inconsistent = inconsistent[inconsistent.any(axis=1)]
|
| 231 |
+
if len(inconsistent):
|
| 232 |
+
raise ValueError(f"{len(inconsistent)} breasts carry conflicting fold or label across the "
|
| 233 |
+
f"prediction files, e.g. {list(inconsistent.index[:5])}.")
|
| 234 |
+
table = case_info.merge(wide, on=KEY_COLUMNS, how='outer')
|
| 235 |
+
|
| 236 |
+
patient_map = load_patient_map(metadata_file, table['examination_id'].unique())
|
| 237 |
+
table['patient_id'] = table['examination_id'].map(patient_map)
|
| 238 |
+
|
| 239 |
+
report = {'cases_read': len(table)}
|
| 240 |
+
unmapped = table['patient_id'].isna()
|
| 241 |
+
report['dropped_without_patient_id'] = int(unmapped.sum())
|
| 242 |
+
report['dropped_without_patient_id_examples'] = sorted(table.loc[unmapped, 'examination_id'].unique()[:10])
|
| 243 |
+
table = table[~unmapped]
|
| 244 |
+
|
| 245 |
+
# an architecture is only usable if both of its protocols were predicted: the analysis is the
|
| 246 |
+
# paired AP vs FDP comparison, a lone AP or FDP column carries no comparison
|
| 247 |
+
architectures = architectures_of(table)
|
| 248 |
+
half_predicted = sorted(set(score_columns_of(table))
|
| 249 |
+
- {name for architecture in architectures for name in score_columns(architecture)})
|
| 250 |
+
if not architectures:
|
| 251 |
+
raise ValueError(f"No architecture in {prediction_dir} has both an abrv and a full prediction "
|
| 252 |
+
f"file; found only {half_predicted or 'no score columns'}.")
|
| 253 |
+
report['architectures'] = architectures
|
| 254 |
+
report['ignored_columns_without_counterpart'] = half_predicted
|
| 255 |
+
columns = [name for architecture in architectures for name in score_columns(architecture)]
|
| 256 |
+
|
| 257 |
+
incomplete = table[columns].isna().any(axis=1)
|
| 258 |
+
report['dropped_incomplete_scores'] = int(incomplete.sum())
|
| 259 |
+
report['incomplete_per_column'] = {name: int(table.loc[incomplete, name].isna().sum())
|
| 260 |
+
for name in columns}
|
| 261 |
+
table = table[~incomplete]
|
| 262 |
+
|
| 263 |
+
table = table[CASE_COLUMNS + columns]
|
| 264 |
+
table = table.sort_values(['outer_fold', 'patient_id', 'examination_id', 'side']).reset_index(drop=True)
|
| 265 |
+
report['cases_kept'] = len(table)
|
| 266 |
+
return table, report
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def check_integrity(table):
|
| 270 |
+
"""
|
| 271 |
+
The data integrity checks required before any inference is run.
|
| 272 |
+
:param table: the wide out-of-fold table
|
| 273 |
+
:return: (checks, summary) where checks maps a check name to (passed, message)
|
| 274 |
+
"""
|
| 275 |
+
checks = {}
|
| 276 |
+
|
| 277 |
+
columns = score_columns_of(table)
|
| 278 |
+
complete = table[columns].notna().all(axis=1).all()
|
| 279 |
+
checks['identical_cases_per_AP_FDP_pair'] = (
|
| 280 |
+
bool(complete),
|
| 281 |
+
f"every kept breast has an AP and an FDP score for {', '.join(architectures_of(table))}"
|
| 282 |
+
if complete else f"some breasts lack one of the {len(columns)} scores")
|
| 283 |
+
|
| 284 |
+
duplicated = table.duplicated(subset=KEY_COLUMNS).sum()
|
| 285 |
+
checks['no_duplicate_breasts'] = (
|
| 286 |
+
duplicated == 0,
|
| 287 |
+
"no breast appears twice" if duplicated == 0 else f"{duplicated} duplicated breasts")
|
| 288 |
+
|
| 289 |
+
folds_per_patient = table.groupby('patient_id')['outer_fold'].nunique()
|
| 290 |
+
straddling = folds_per_patient[folds_per_patient > 1]
|
| 291 |
+
checks['no_patient_in_several_test_folds'] = (
|
| 292 |
+
len(straddling) == 0,
|
| 293 |
+
f"all {len(folds_per_patient)} women sit in exactly one test fold"
|
| 294 |
+
if len(straddling) == 0 else
|
| 295 |
+
f"{len(straddling)} women appear in several test folds, e.g. {list(straddling.index[:5])}")
|
| 296 |
+
|
| 297 |
+
exams_per_patient = table.groupby('patient_id')['examination_id'].nunique()
|
| 298 |
+
repeated = exams_per_patient[exams_per_patient > 1]
|
| 299 |
+
folds_of_repeated = table[table['patient_id'].isin(repeated.index)].groupby('patient_id')['outer_fold'].nunique()
|
| 300 |
+
clustered = bool((folds_of_repeated == 1).all()) if len(repeated) else True
|
| 301 |
+
checks['repeated_examinations_clustered'] = (
|
| 302 |
+
clustered,
|
| 303 |
+
f"{len(repeated)} women have several examinations, all within one fold"
|
| 304 |
+
if clustered else "repeated examinations of a woman are spread over folds")
|
| 305 |
+
|
| 306 |
+
labels_ok = set(table['label'].unique()) <= {0, 1}
|
| 307 |
+
checks['labels_binary'] = (
|
| 308 |
+
labels_ok, "labels are binary" if labels_ok else f"unexpected labels {sorted(set(table['label']))}")
|
| 309 |
+
|
| 310 |
+
per_fold_classes = table.groupby('outer_fold')['label'].agg(['sum', 'count'])
|
| 311 |
+
both_classes = bool(((per_fold_classes['sum'] > 0) & (per_fold_classes['sum'] < per_fold_classes['count'])).all())
|
| 312 |
+
checks['both_classes_in_every_fold'] = (
|
| 313 |
+
both_classes, "every fold holds malignant and benign breasts"
|
| 314 |
+
if both_classes else "a fold holds only one class, its AUC is undefined")
|
| 315 |
+
|
| 316 |
+
summary = {
|
| 317 |
+
'breasts': len(table),
|
| 318 |
+
'examinations': table['examination_id'].nunique(),
|
| 319 |
+
'women': table['patient_id'].nunique(),
|
| 320 |
+
'malignant_breasts': int(table['label'].sum()),
|
| 321 |
+
'women_with_repeated_examinations': int(len(repeated)),
|
| 322 |
+
'max_examinations_per_woman': int(exams_per_patient.max()) if len(exams_per_patient) else 0,
|
| 323 |
+
'per_fold': table.groupby('outer_fold').agg(
|
| 324 |
+
breasts=('label', 'size'), malignant=('label', 'sum'),
|
| 325 |
+
women=('patient_id', 'nunique')).to_dict('index'),
|
| 326 |
+
}
|
| 327 |
+
return checks, summary
|
auto_detect_breast_mri/evaluation/subgroups.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Performance within a subgroup of the out-of-fold table, e.g. per indication.
|
| 3 |
+
|
| 4 |
+
This is an exploratory companion to the primary AP vs FDP analysis, not a second confirmatory one.
|
| 5 |
+
Each subgroup is analysed with exactly the machinery of the primary analysis, only restricted to its
|
| 6 |
+
own rows: AUCs are computed *within* an outer fold and combined with weights that are fixed before
|
| 7 |
+
resampling, and the bootstrap redraws whole women so that both breasts and all repeated examinations
|
| 8 |
+
of one woman move together.
|
| 9 |
+
|
| 10 |
+
Three things are worth keeping in mind when reading the output:
|
| 11 |
+
|
| 12 |
+
* The fold weights are recomputed inside the subgroup. A subgroup's weights are therefore not the
|
| 13 |
+
weights of the primary analysis, and its delta is not a decomposition of the overall delta.
|
| 14 |
+
* Subgroups are small. A fold that holds only benign breasts has no AUC, is dropped, and the
|
| 15 |
+
remaining folds are renormalized; `folds_used` reports how many actually carried information.
|
| 16 |
+
* Nothing here is multiplicity adjusted across subgroups, and no non-inferiority verdict is derived.
|
| 17 |
+
A wide interval in a small subgroup means "not measurable here", never "inferior".
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
|
| 22 |
+
from auto_detect_breast_mri.evaluation.cluster_bootstrap import (FoldClusterBootstrap, auc,
|
| 23 |
+
percentile_interval,
|
| 24 |
+
weighted_fold_difference)
|
| 25 |
+
from auto_detect_breast_mri.evaluation.oof_table import score_columns
|
| 26 |
+
|
| 27 |
+
# A subgroup below this many breasts, or with fewer than this many of either class, is reported with
|
| 28 |
+
# its counts but without a bootstrap: the interval would be an artefact of a handful of cases.
|
| 29 |
+
MIN_BREASTS = 20
|
| 30 |
+
MIN_PER_CLASS = 5
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def weighted_fold_auc(rows_of_fold, labels, scores, weights):
|
| 34 |
+
"""
|
| 35 |
+
One AUC per fold, combined with the fixed weights, folds without both classes dropped.
|
| 36 |
+
:return: (combined AUC, dict fold -> AUC, number of folds that contributed)
|
| 37 |
+
"""
|
| 38 |
+
per_fold, combined, total_weight = {}, 0.0, 0.0
|
| 39 |
+
for fold, rows in rows_of_fold.items():
|
| 40 |
+
value = auc(labels[rows], scores[rows])
|
| 41 |
+
per_fold[fold] = value
|
| 42 |
+
if not np.isnan(value):
|
| 43 |
+
combined += weights[fold] * value
|
| 44 |
+
total_weight += weights[fold]
|
| 45 |
+
if total_weight == 0:
|
| 46 |
+
return np.nan, per_fold, 0
|
| 47 |
+
return combined / total_weight, per_fold, int(sum(not np.isnan(v) for v in per_fold.values()))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def describe_subgroup(rows, table):
|
| 51 |
+
"""Counts that decide whether a subgroup can carry an analysis at all."""
|
| 52 |
+
part = table.iloc[rows]
|
| 53 |
+
labels = part['label'].to_numpy()
|
| 54 |
+
return {'breasts': len(part),
|
| 55 |
+
'women': int(part['patient_id'].nunique()),
|
| 56 |
+
'examinations': int(part['examination_id'].nunique()),
|
| 57 |
+
'malignant': int((labels == 1).sum()),
|
| 58 |
+
'benign': int((labels == 0).sum()),
|
| 59 |
+
'folds_with_both_classes': int(sum(
|
| 60 |
+
1 for _, fold_part in part.groupby('outer_fold')
|
| 61 |
+
if 0 < fold_part['label'].sum() < len(fold_part)))}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def analysable(counts, min_breasts=MIN_BREASTS, min_per_class=MIN_PER_CLASS):
|
| 65 |
+
"""
|
| 66 |
+
Whether a subgroup is worth bootstrapping at all.
|
| 67 |
+
:return: (bool, reason) with reason empty when it is analysable
|
| 68 |
+
"""
|
| 69 |
+
if counts['breasts'] < min_breasts:
|
| 70 |
+
return False, f"only {counts['breasts']} breasts (minimum {min_breasts})"
|
| 71 |
+
if counts['malignant'] < min_per_class or counts['benign'] < min_per_class:
|
| 72 |
+
return False, (f"only {counts['malignant']} malignant / {counts['benign']} benign "
|
| 73 |
+
f"(minimum {min_per_class} each)")
|
| 74 |
+
if counts['folds_with_both_classes'] == 0:
|
| 75 |
+
return False, "no outer fold holds both classes, every fold level AUC is undefined"
|
| 76 |
+
return True, ""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def analyse_subgroup(table, rows, architectures, replications, weighting, seed, stratify):
|
| 80 |
+
"""
|
| 81 |
+
AUC of every model and the paired AP minus FDP difference inside one subgroup.
|
| 82 |
+
|
| 83 |
+
:param table: the wide out-of-fold table
|
| 84 |
+
:param rows: positional row indices of the subgroup within `table`
|
| 85 |
+
:param architectures: architecture labels to analyse
|
| 86 |
+
:return: dict with the counts, the per architecture results and, if the subgroup is too small,
|
| 87 |
+
a 'skipped' reason instead of the bootstrap results
|
| 88 |
+
"""
|
| 89 |
+
counts = describe_subgroup(rows, table)
|
| 90 |
+
usable, reason = analysable(counts)
|
| 91 |
+
result = {'counts': counts, 'architectures': {}}
|
| 92 |
+
if not usable:
|
| 93 |
+
result['skipped'] = reason
|
| 94 |
+
return result
|
| 95 |
+
|
| 96 |
+
part = table.iloc[rows].reset_index(drop=True)
|
| 97 |
+
labels = part['label'].to_numpy()
|
| 98 |
+
bootstrap = FoldClusterBootstrap(part['outer_fold'].to_numpy(), part['patient_id'].to_numpy(),
|
| 99 |
+
labels, stratify_by_outcome=stratify)
|
| 100 |
+
weights = bootstrap.fold_weights(weighting)
|
| 101 |
+
observed_rows = bootstrap.observed_rows()
|
| 102 |
+
scores = {architecture: tuple(part[name].to_numpy() for name in score_columns(architecture))
|
| 103 |
+
for architecture in architectures}
|
| 104 |
+
|
| 105 |
+
for architecture in architectures:
|
| 106 |
+
ap_scores, fdp_scores = scores[architecture]
|
| 107 |
+
auc_ap, per_fold_ap, folds_ap = weighted_fold_auc(observed_rows, labels, ap_scores, weights)
|
| 108 |
+
auc_fdp, per_fold_fdp, _ = weighted_fold_auc(observed_rows, labels, fdp_scores, weights)
|
| 109 |
+
delta, _ = weighted_fold_difference(observed_rows, labels, ap_scores, fdp_scores, weights)
|
| 110 |
+
result['architectures'][architecture] = {
|
| 111 |
+
'auc_ap': auc_ap, 'auc_fdp': auc_fdp, 'observed_delta': delta,
|
| 112 |
+
'folds_used': folds_ap,
|
| 113 |
+
'per_fold': {fold: (per_fold_ap[fold], per_fold_fdp[fold]) for fold in per_fold_ap},
|
| 114 |
+
'replications': np.full(replications, np.nan)}
|
| 115 |
+
|
| 116 |
+
# one rng for the whole subgroup, so all architectures see the same resampled women
|
| 117 |
+
rng = np.random.default_rng(seed)
|
| 118 |
+
degenerate = 0
|
| 119 |
+
for replication in range(replications):
|
| 120 |
+
rows_of_fold = bootstrap.resample_rows(rng)
|
| 121 |
+
for architecture in architectures:
|
| 122 |
+
ap_scores, fdp_scores = scores[architecture]
|
| 123 |
+
delta, _ = weighted_fold_difference(rows_of_fold, labels, ap_scores, fdp_scores, weights)
|
| 124 |
+
result['architectures'][architecture]['replications'][replication] = delta
|
| 125 |
+
degenerate += int(np.isnan(delta))
|
| 126 |
+
|
| 127 |
+
for architecture in architectures:
|
| 128 |
+
entry = result['architectures'][architecture]
|
| 129 |
+
entry['ci_95'] = percentile_interval(entry['replications'], 0.95)
|
| 130 |
+
entry['bootstrap_se'] = float(np.nanstd(entry['replications'], ddof=1))
|
| 131 |
+
entry['degenerate_replications'] = int(np.isnan(entry['replications']).sum())
|
| 132 |
+
result['weights'] = weights
|
| 133 |
+
result['degenerate_replications'] = degenerate
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def analyse_by_group(table, group_column, architectures, replications=2000, weighting='pairs',
|
| 138 |
+
seed=0, stratify=True, label_column=None):
|
| 139 |
+
"""
|
| 140 |
+
Run analyse_subgroup for every value of `group_column`, plus one 'all' row over the whole table.
|
| 141 |
+
|
| 142 |
+
:param table: the wide out-of-fold table
|
| 143 |
+
:param group_column: column holding the subgroup key, e.g. 'indication'
|
| 144 |
+
:param label_column: optional column holding a readable name of the subgroup
|
| 145 |
+
:return: dict subgroup key -> result of analyse_subgroup, 'all' first
|
| 146 |
+
"""
|
| 147 |
+
results = {}
|
| 148 |
+
everything = np.arange(len(table))
|
| 149 |
+
results['all'] = analyse_subgroup(table, everything, architectures, replications, weighting,
|
| 150 |
+
seed, stratify)
|
| 151 |
+
results['all']['label'] = 'all breasts'
|
| 152 |
+
|
| 153 |
+
positions = {key: np.flatnonzero((table[group_column] == key).to_numpy())
|
| 154 |
+
for key in table[group_column].unique()}
|
| 155 |
+
for key in sorted(positions, key=lambda k: (-len(positions[k]), str(k))):
|
| 156 |
+
print(f" subgroup {key}: {len(positions[key])} breasts", flush=True)
|
| 157 |
+
entry = analyse_subgroup(table, positions[key], architectures, replications, weighting,
|
| 158 |
+
seed, stratify)
|
| 159 |
+
entry['label'] = (table.iloc[positions[key]][label_column].iloc[0]
|
| 160 |
+
if label_column else str(key))
|
| 161 |
+
results[key] = entry
|
| 162 |
+
return results
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def to_frame(results, architectures):
|
| 166 |
+
"""
|
| 167 |
+
The per subgroup results as a flat table, one row per (subgroup, architecture).
|
| 168 |
+
:return: list of dicts, ready for pandas
|
| 169 |
+
"""
|
| 170 |
+
rows = []
|
| 171 |
+
for key, entry in results.items():
|
| 172 |
+
counts = entry['counts']
|
| 173 |
+
for architecture in architectures:
|
| 174 |
+
row = {'subgroup': key, 'label': entry.get('label', str(key)),
|
| 175 |
+
'architecture': architecture, **counts}
|
| 176 |
+
values = entry['architectures'].get(architecture)
|
| 177 |
+
if values is None:
|
| 178 |
+
row['skipped'] = entry.get('skipped', '')
|
| 179 |
+
else:
|
| 180 |
+
row.update({'auc_AP': values['auc_ap'], 'auc_FDP': values['auc_fdp'],
|
| 181 |
+
'delta_AP_minus_FDP': values['observed_delta'],
|
| 182 |
+
'ci95_low': values['ci_95'][0], 'ci95_high': values['ci_95'][1],
|
| 183 |
+
'bootstrap_se': values['bootstrap_se'],
|
| 184 |
+
'folds_used': values['folds_used'], 'skipped': ''})
|
| 185 |
+
rows.append(row)
|
| 186 |
+
return rows
|
auto_detect_breast_mri/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Network definitions and checkpoint IO."""
|
auto_detect_breast_mri/models/checkpoints.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Saving and loading model checkpoints."""
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from torch import nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def save_model(model: nn.Module, parent_dir: str, filename: str):
|
| 8 |
+
if all([model, parent_dir, filename]):
|
| 9 |
+
torch.save(model.state_dict(), parent_dir + '/' + filename + '.pth')
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_pretrained_model(model, model_path, DEVICE):
|
| 13 |
+
'''
|
| 14 |
+
Load a model from '.pth' file to use the pretrained model in our later training loop or only evaluate it.
|
| 15 |
+
'''
|
| 16 |
+
checkpoint = torch.load(model_path, torch.device(DEVICE), weights_only=True)
|
| 17 |
+
state_dict = {}
|
| 18 |
+
if 'state_dict' in checkpoint.keys():
|
| 19 |
+
for k, v in checkpoint['state_dict'].items():
|
| 20 |
+
if k.startswith('module.'):
|
| 21 |
+
key = k.replace('module.', '')
|
| 22 |
+
else:
|
| 23 |
+
key = k
|
| 24 |
+
# check dimension of checkpoint value:
|
| 25 |
+
desired_shape = model.state_dict()[key].shape
|
| 26 |
+
if desired_shape != v.shape:
|
| 27 |
+
pretrained_layer_tensor = torch.empty(desired_shape)
|
| 28 |
+
(l, m, n, o, p) = desired_shape
|
| 29 |
+
(a, b, c, d, e) = v.shape # given pretrained parameters shape
|
| 30 |
+
pretrained_layer_tensor[:a, :b, :c, :d, :e] = v
|
| 31 |
+
|
| 32 |
+
# Duplicate values along the additional dimensions
|
| 33 |
+
if a < l:
|
| 34 |
+
for i in range(l):
|
| 35 |
+
pretrained_layer_tensor[a + i:a + i + 1, :, :, :, :] = pretrained_layer_tensor[:a, :, :, :, :]
|
| 36 |
+
if b < m:
|
| 37 |
+
for j in range(m):
|
| 38 |
+
pretrained_layer_tensor[:, b + j:b + j + 1, :, :, :] = pretrained_layer_tensor[:, :b, :, :, :]
|
| 39 |
+
if c < n:
|
| 40 |
+
for k in range(n):
|
| 41 |
+
pretrained_layer_tensor[:, :, c + k:c + k + 1, :, :] = pretrained_layer_tensor[:, :, :c, :, :]
|
| 42 |
+
if d < o:
|
| 43 |
+
for l in range(o):
|
| 44 |
+
pretrained_layer_tensor[:, :, :, d + l:d + l + 1, :] = pretrained_layer_tensor[:, :, :, :d, :]
|
| 45 |
+
if e < p:
|
| 46 |
+
for m in range(p):
|
| 47 |
+
pretrained_layer_tensor[:, :, :, :, e + m:e + m + 1] = pretrained_layer_tensor[:, :, :, :, :e]
|
| 48 |
+
for k, v in model.state_dict().items():
|
| 49 |
+
if k not in state_dict.keys():
|
| 50 |
+
state_dict[k] = model.state_dict()[k]
|
| 51 |
+
return state_dict
|
| 52 |
+
else:
|
| 53 |
+
return checkpoint
|
auto_detect_breast_mri/models/resnets.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from monai.networks import nets
|
| 2 |
+
|
| 3 |
+
LEN_SINGLE = 1
|
| 4 |
+
LEN_3 = 3
|
| 5 |
+
LEN_ABRV = 2
|
| 6 |
+
LEN_FULL = 6
|
| 7 |
+
|
| 8 |
+
resnet18_single = nets.ResNet('basic', [2, 2, 2, 2], [64, 128, 256, 512],
|
| 9 |
+
n_input_channels=LEN_SINGLE, num_classes=1)
|
| 10 |
+
resnet50_single = nets.ResNet('bottleneck', [3, 4, 6, 3], [64, 128, 256, 512],
|
| 11 |
+
n_input_channels=LEN_SINGLE, num_classes=1)
|
| 12 |
+
resnet18_abrv_t2 = nets.ResNet('basic', [2, 2, 2, 2], [64, 128, 256, 512],
|
| 13 |
+
n_input_channels=LEN_3, num_classes=1)
|
| 14 |
+
resnet18_abrv = nets.ResNet('basic', [2, 2, 2, 2], [64, 128, 256, 512],
|
| 15 |
+
n_input_channels=LEN_ABRV, num_classes=1)
|
| 16 |
+
resnet50_abrv = nets.ResNet('bottleneck', [3, 4, 6, 3], [64, 128, 256, 512],
|
| 17 |
+
n_input_channels=LEN_ABRV, num_classes=1)
|
| 18 |
+
resnet18_full = nets.ResNet('basic', [2, 2, 2, 2], [64, 128, 256, 512],
|
| 19 |
+
n_input_channels=LEN_FULL, num_classes=1)
|
| 20 |
+
resnet50_full = nets.ResNet('bottleneck', [3, 4, 6, 3], [64, 128, 256, 512],
|
| 21 |
+
n_input_channels=LEN_FULL, num_classes=1)
|
| 22 |
+
densenet_abrv = nets.DenseNet(spatial_dims=3, in_channels=LEN_ABRV, out_channels=1,)
|
| 23 |
+
|
| 24 |
+
model_names = {"resnet18_single": resnet18_single,
|
| 25 |
+
"resnet50_single": resnet50_single,
|
| 26 |
+
"resnet18_abrv": resnet18_abrv,
|
| 27 |
+
"resnet50_abrv": resnet50_abrv,
|
| 28 |
+
"resnet18_full": resnet18_full,
|
| 29 |
+
"resnet50_full": resnet50_full
|
| 30 |
+
}
|
auto_detect_breast_mri/models/unet_crop.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The pretrained UNet that crops air and thoracic cavity off a breast MRI."""
|
| 2 |
+
|
| 3 |
+
import gc
|
| 4 |
+
|
| 5 |
+
import matplotlib.pyplot as plt
|
| 6 |
+
import monai.losses
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from monai.networks import nets
|
| 10 |
+
from torch import nn
|
| 11 |
+
from torch.utils.data import DataLoader
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class UNet_Crop(object):
|
| 15 |
+
def __init__(self, path_to_unet='', saving_path='unet.pth', input_channel=3, running_device='cpu',
|
| 16 |
+
show_validation=False, criterion='dice_ce'):
|
| 17 |
+
"""
|
| 18 |
+
Crop an input image from top and bottom so that air and thoracic cavity are cut to save computational costs.
|
| 19 |
+
Use a trained UNet for this
|
| 20 |
+
:param path_to_unet: str
|
| 21 |
+
defines the path to the definition of the pretrained UNet to be used for cropping. Must be a .pth file.
|
| 22 |
+
if empty, the network will be trained and saved to unet.pth
|
| 23 |
+
:param criterion: str
|
| 24 |
+
defines the criterion to be used while training. Can be one of ['dice_ce', 'dice', 'ce']
|
| 25 |
+
"""
|
| 26 |
+
# init
|
| 27 |
+
if input_channel > 0:
|
| 28 |
+
self.in_channel = input_channel
|
| 29 |
+
else:
|
| 30 |
+
self.in_channel = 3
|
| 31 |
+
if running_device.__eq__('cpu') or running_device.__eq__('cuda'):
|
| 32 |
+
self.device = running_device
|
| 33 |
+
else:
|
| 34 |
+
raise AttributeError(
|
| 35 |
+
'Device {} is unknown. Please use an existing device (cpu or cuda).'.format(running_device))
|
| 36 |
+
unet_channel = [self.in_channel, 64, 128, 256, 512]
|
| 37 |
+
unet_strides = [2, 2, 2, 2]
|
| 38 |
+
# output: background and breast mask
|
| 39 |
+
self.cropping_unet = nets.Unet(3, self.in_channel, 2, unet_channel, unet_strides,
|
| 40 |
+
act='ReLU', adn_ordering="NAD")
|
| 41 |
+
self.show_validation_images = show_validation
|
| 42 |
+
print('Init Cropping Unet: done.')
|
| 43 |
+
|
| 44 |
+
if path_to_unet is not None and path_to_unet.endswith('.pth'):
|
| 45 |
+
checkpoint = torch.load(path_to_unet, weights_only=True)
|
| 46 |
+
self.cropping_unet.load_state_dict(checkpoint)
|
| 47 |
+
self.cropping_unet.eval()
|
| 48 |
+
if saving_path is not None and saving_path.endswith('.pth'):
|
| 49 |
+
self.best_path = saving_path.split('.pth')[0] + '_best.pth'
|
| 50 |
+
self.saving_path = saving_path
|
| 51 |
+
|
| 52 |
+
self.criterion = monai.losses.DiceCELoss(softmax=True, include_background=True)
|
| 53 |
+
self.set_criterion(criterion)
|
| 54 |
+
|
| 55 |
+
def __call__(self, image: torch.Tensor):
|
| 56 |
+
"""
|
| 57 |
+
Crop the given image so that air and thoracic cavity are reduced
|
| 58 |
+
:param image:
|
| 59 |
+
image to be cropped in shape (B, M, W, H, D) where
|
| 60 |
+
the first dimension (batch) is optional,
|
| 61 |
+
M refers to the MRI modes used (Appreviated protocol: M=2, Full protocol: M=6)
|
| 62 |
+
D defines depth, H height, and W width.
|
| 63 |
+
:return:
|
| 64 |
+
the cropped image with output shape (B, M, W, H/2, D); in case image was a 4D tensor, B will be 1 here.
|
| 65 |
+
tuple with (min, max) where min and max define the index range kept after cropping
|
| 66 |
+
the breast mask
|
| 67 |
+
"""
|
| 68 |
+
while len(image.shape) < 5:
|
| 69 |
+
wrapper = torch.empty((1,) + image.shape)
|
| 70 |
+
wrapper[0] = image
|
| 71 |
+
image = wrapper
|
| 72 |
+
if len(image.shape) != 5:
|
| 73 |
+
raise ValueError("Image must be a 5D Tensor or Tensor, which can be wrapped into 5D. But was {}D.".format(
|
| 74 |
+
len(image.shape)))
|
| 75 |
+
print("Input image shape to UNet_Crop: " + str(image.shape))
|
| 76 |
+
output = self.cropping_unet(image)
|
| 77 |
+
print("Output shape cropped image: " + str(output.shape))
|
| 78 |
+
# TODO actually we should compute breast mask for each input (output here) and not for all at once
|
| 79 |
+
mask, (w_min, w_max) = self.extract_breast_mask(output, int(output.shape[-2] / 2))
|
| 80 |
+
#if torch.is_tensor(w_min):
|
| 81 |
+
# w_min = int(w_min.item())
|
| 82 |
+
#if torch.is_tensor(w_max):
|
| 83 |
+
# w_max = int(w_max.item())
|
| 84 |
+
breast_mask = output[:, 1] - output[:, 0] # torch.ge(output[:, 1], output[:, 0]) # binary output
|
| 85 |
+
#breast_mask = breast_mask[0] if len(breast_mask.shape) == 5 else breast_mask
|
| 86 |
+
print("Breast mask: " + str(breast_mask.shape))
|
| 87 |
+
cropped_images = []
|
| 88 |
+
for b in range(image.shape[0]):
|
| 89 |
+
w_min_b = int(w_min[b].item())
|
| 90 |
+
w_max_b = int(w_max[b].item())
|
| 91 |
+
cropped_images.append(image[b, :, :, w_min_b:w_max_b, :])
|
| 92 |
+
|
| 93 |
+
cropped_images = torch.stack(cropped_images, dim=0)
|
| 94 |
+
return cropped_images, (w_min, w_max), breast_mask
|
| 95 |
+
|
| 96 |
+
def get_parameters(self):
|
| 97 |
+
return self.cropping_unet.parameters()
|
| 98 |
+
|
| 99 |
+
def set_criterion(self, criterion):
|
| 100 |
+
if criterion is not None:
|
| 101 |
+
if criterion == 'ce':
|
| 102 |
+
self.criterion = nn.CrossEntropyLoss()
|
| 103 |
+
elif criterion == 'dice':
|
| 104 |
+
self.criterion = monai.losses.DiceLoss(softmax=True, include_background=True)
|
| 105 |
+
elif criterion == 'dice_ce':
|
| 106 |
+
self.criterion = monai.losses.DiceCELoss(softmax=True, include_background=True)
|
| 107 |
+
else:
|
| 108 |
+
print('Criterion is unknown: {}. Expected any of [\'dice_ce\', \'dice\', \'ce\']. '
|
| 109 |
+
'Set to dice_ce as default.'.format(criterion))
|
| 110 |
+
else:
|
| 111 |
+
print('Criterion is None. Set to dice_ce as default.'.format(criterion))
|
| 112 |
+
|
| 113 |
+
def validate(self, n_epochs: int, validation_loader: DataLoader):
|
| 114 |
+
self.cropping_unet.eval()
|
| 115 |
+
depth = 15 # depth of MRI scan to visualize for validation
|
| 116 |
+
for epoch in range(n_epochs):
|
| 117 |
+
with torch.no_grad():
|
| 118 |
+
val_loss = 0
|
| 119 |
+
gc.collect()
|
| 120 |
+
# Calculate validation loss
|
| 121 |
+
for val_batch, item in enumerate(validation_loader):
|
| 122 |
+
if item.__class__() == dict().__class__():
|
| 123 |
+
# dataloader
|
| 124 |
+
val_target = item['image']['data']
|
| 125 |
+
val_mask = item['label']['data']
|
| 126 |
+
else:
|
| 127 |
+
# subjects loader
|
| 128 |
+
val_target = item.image.data
|
| 129 |
+
val_mask = item.label.data
|
| 130 |
+
|
| 131 |
+
# send to correct device
|
| 132 |
+
val_target.to(self.device)
|
| 133 |
+
val_mask.to(self.device)
|
| 134 |
+
|
| 135 |
+
# compute the mask
|
| 136 |
+
val_probs = self.cropping_unet(val_target)
|
| 137 |
+
|
| 138 |
+
if self.show_validation_images and val_batch % 10 == 0:
|
| 139 |
+
cropped_mask, (c_min, c_max) = self.extract_breast_mask(val_probs, int(val_mask.shape[-1] / 2))
|
| 140 |
+
|
| 141 |
+
cropped_image = val_target[0, 0, depth, int(c_min[0].item()):int(c_max[0].item()), :]
|
| 142 |
+
plt.imshow(torch.cat([val_target[0][0][depth],
|
| 143 |
+
val_mask[0][0][depth]]), cmap='gray', interpolation='none')
|
| 144 |
+
plt.show()
|
| 145 |
+
plt.imshow(torch.cat([cropped_image, cropped_mask[0][depth]]), cmap='gray',
|
| 146 |
+
interpolation='none')
|
| 147 |
+
plt.show()
|
| 148 |
+
|
| 149 |
+
loss = self.criterion(val_probs[:, 1, :, :, :][:, None], val_mask)
|
| 150 |
+
val_loss += loss.item()
|
| 151 |
+
print("Epoch [{}/{}] | UNet - Average Validation Loss: {:.5f}".format(epoch + 1, n_epochs,
|
| 152 |
+
val_loss / len(validation_loader)))
|
| 153 |
+
|
| 154 |
+
def train_unet(self, n_epochs, train_loader, optimizer):
|
| 155 |
+
self.cropping_unet.train(True)
|
| 156 |
+
best_loss = torch.inf
|
| 157 |
+
for epoch in range(n_epochs):
|
| 158 |
+
print('EPOCH {}:'.format(epoch + 1))
|
| 159 |
+
train_loss = 0
|
| 160 |
+
for batch_id, item in enumerate(train_loader):
|
| 161 |
+
if item.__class__() == dict().__class__():
|
| 162 |
+
# dataloader
|
| 163 |
+
data = item['image']['data']
|
| 164 |
+
mask = item['label']['data']
|
| 165 |
+
else:
|
| 166 |
+
# subjects loader
|
| 167 |
+
data = item.image.data
|
| 168 |
+
mask = item.label.data
|
| 169 |
+
|
| 170 |
+
data.to(self.device)
|
| 171 |
+
mask.to(self.device)
|
| 172 |
+
optimizer.zero_grad()
|
| 173 |
+
|
| 174 |
+
#pred_probs = self.cropping_unet(data)
|
| 175 |
+
(cropped_image, cropping_bounds, breast_mask) = self.__call__(data)
|
| 176 |
+
# intermediate validation
|
| 177 |
+
if (batch_id % 10) == 0 or batch_id == 0:
|
| 178 |
+
print(torch.cat([data[0][0][10], mask[0][0][10]], dim=-1).shape)
|
| 179 |
+
# TODO fix dimension issue
|
| 180 |
+
plt.imshow(torch.cat([data[0][0][10], mask[0][0][10]], dim=-1), cmap='gray', interpolation='none')
|
| 181 |
+
plt.show()
|
| 182 |
+
plt.imshow(breast_mask[0][10].detach().numpy(), cmap='gray', interpolation='none')
|
| 183 |
+
plt.show()
|
| 184 |
+
|
| 185 |
+
breast_mask = np.expand_dims(breast_mask.detach().numpy(), axis=1)
|
| 186 |
+
print("Subject data received by train loader:")
|
| 187 |
+
print(data.shape)
|
| 188 |
+
print("Subject label received by train loader:")
|
| 189 |
+
print(mask.shape)
|
| 190 |
+
print("UNet cropped data:")
|
| 191 |
+
print(breast_mask.shape)
|
| 192 |
+
|
| 193 |
+
loss = self.criterion(breast_mask, mask)
|
| 194 |
+
loss.backward()
|
| 195 |
+
|
| 196 |
+
optimizer.step()
|
| 197 |
+
|
| 198 |
+
train_loss += loss.item()
|
| 199 |
+
if train_loss / len(train_loader) < best_loss:
|
| 200 |
+
self.save_unet(self.best_path)
|
| 201 |
+
|
| 202 |
+
if epoch % n_epochs / 5 == 0 or epoch == n_epochs - 1:
|
| 203 |
+
self.save_unet(self.saving_path)
|
| 204 |
+
|
| 205 |
+
self.cropping_unet.train(False)
|
| 206 |
+
|
| 207 |
+
def save_unet(self, path: str):
|
| 208 |
+
"""
|
| 209 |
+
Save this unet to specified path. Otherwise, it is saved to default location unet.pth
|
| 210 |
+
:param path:
|
| 211 |
+
:return:
|
| 212 |
+
"""
|
| 213 |
+
if path is not None and len(path) > 0:
|
| 214 |
+
torch.save(self.cropping_unet.state_dict(), path)
|
| 215 |
+
else:
|
| 216 |
+
torch.save(self.cropping_unet.state_dict(), 'unet.pth')
|
| 217 |
+
|
| 218 |
+
def extract_breast_mask(self, probability_output: torch.Tensor, mask_width: int):
|
| 219 |
+
"""
|
| 220 |
+
Extract the breast mask from the given probability output tensor in which
|
| 221 |
+
the first dimension holds probabilities for each position to be background and
|
| 222 |
+
the second dimension holds those to be breast.
|
| 223 |
+
:param probability_output:
|
| 224 |
+
tensor of shape 2 x height x width x depth
|
| 225 |
+
:param mask_width:
|
| 226 |
+
output width for the breast mask. In case this defines a larger space than needed from the actual breast,
|
| 227 |
+
actual breast is found centered in the output mask within the according dimension.
|
| 228 |
+
If mask_width is smaller than the span of pixels defining the breast, the center of the breast is calculated
|
| 229 |
+
used to center the mask.
|
| 230 |
+
In other words, this parameter can be used to crop the mask to a certain width if it is a positive number.
|
| 231 |
+
:return:
|
| 232 |
+
3D tensor defining breast mask with according width if parameter is provided (depth x height/2 x mask_width)
|
| 233 |
+
otherwise output is of shape depth x height/2 x width
|
| 234 |
+
"""
|
| 235 |
+
input_shape = probability_output.shape
|
| 236 |
+
if len(input_shape) == 4:
|
| 237 |
+
probability_output = probability_output.unsqueeze(0)
|
| 238 |
+
input_shape = probability_output.shape
|
| 239 |
+
if len(probability_output.shape) != 5:
|
| 240 |
+
raise ValueError(f"Invalid input shape. Expected 4D or 5D tensor, but got {probability_output.shape}")
|
| 241 |
+
|
| 242 |
+
breast_mask = torch.ge(probability_output[:, 1], probability_output[:, 0])
|
| 243 |
+
if 0 < mask_width < input_shape[-2]:
|
| 244 |
+
# get indices of values that are not 0
|
| 245 |
+
breast_indices = torch.tensor(
|
| 246 |
+
[[[[j for k in range(input_shape[-1])] for j in range(input_shape[-2])] for i in
|
| 247 |
+
range(input_shape[-3])] for h in range(input_shape[0])])
|
| 248 |
+
breast_indices[breast_mask == 0] = 0
|
| 249 |
+
|
| 250 |
+
out_batch, out_width, out_height, out_depth = input_shape[0], input_shape[2], int(input_shape[3] / 2), \
|
| 251 |
+
input_shape[4]
|
| 252 |
+
batch_crop_range = torch.zeros(2, out_batch)
|
| 253 |
+
cropped_breast_mask = torch.zeros((out_batch, out_width, out_height, out_depth))
|
| 254 |
+
# compute center for each batch and each depth
|
| 255 |
+
for b in range(out_batch):
|
| 256 |
+
centers_per_depth = []
|
| 257 |
+
for d in range(out_depth):
|
| 258 |
+
# crop width of the mask in this depth
|
| 259 |
+
center_indices = []
|
| 260 |
+
for col in range(out_width):
|
| 261 |
+
indices_sum = sum(breast_indices[b, col, :, d])
|
| 262 |
+
if indices_sum > 0: # only count columns where a center could be calculated
|
| 263 |
+
len_non_zero_indices = torch.sum(torch.where(breast_indices[b, col, :, d] > 0, 1.0, 0.0))
|
| 264 |
+
center_indices.append(indices_sum / len_non_zero_indices)
|
| 265 |
+
if len(center_indices) > 0:
|
| 266 |
+
centers_per_depth.append(sum(center_indices) / len(center_indices))
|
| 267 |
+
|
| 268 |
+
# calculate center of this batch over all depth slices
|
| 269 |
+
hor_center = out_height / 2
|
| 270 |
+
if len(centers_per_depth) > 0:
|
| 271 |
+
hor_center = (sum(centers_per_depth) / len(centers_per_depth)).item()
|
| 272 |
+
hor_min = round(hor_center - mask_width / 2) # horizontal minimum
|
| 273 |
+
hor_max = round(hor_center + mask_width / 2) # horizontal maximum
|
| 274 |
+
|
| 275 |
+
# ensure that cropping area is valid
|
| 276 |
+
if hor_min < 0:
|
| 277 |
+
hor_min = 0
|
| 278 |
+
hor_max = mask_width
|
| 279 |
+
elif hor_max > input_shape[-2]:
|
| 280 |
+
hor_min = input_shape[-2] - mask_width
|
| 281 |
+
hor_max = input_shape[-2]
|
| 282 |
+
|
| 283 |
+
batch_crop_range[0][b] = hor_min
|
| 284 |
+
batch_crop_range[1][b] = hor_max
|
| 285 |
+
cropped_breast_mask[b] = breast_mask[b, :, hor_min:hor_max, :]
|
| 286 |
+
return cropped_breast_mask, (batch_crop_range[0], batch_crop_range[1])
|
| 287 |
+
else:
|
| 288 |
+
return breast_mask.float(), (0, input_shape[-2])
|
| 289 |
+
|
| 290 |
+
|
auto_detect_breast_mri/preprocessing/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Turning raw DICOM/NIfTI archives into the layout the datasets expect."""
|
auto_detect_breast_mri/preprocessing/anonymize_dicom.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import pydicom
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def anonymize(source_folder: str, destination_folder: str, mapping_file: str):
|
| 9 |
+
"""
|
| 10 |
+
====================
|
| 11 |
+
Anonymize DICOM data
|
| 12 |
+
====================
|
| 13 |
+
|
| 14 |
+
This example is a starting point to anonymize DICOM data.
|
| 15 |
+
|
| 16 |
+
It shows how to read data and replace tags: person names, patient id,
|
| 17 |
+
optionally remove curves and private tags, and write the results in a new file.
|
| 18 |
+
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
# authors : Guillaume Lemaitre <g.lemaitre58@gmail.com>
|
| 22 |
+
# license : MIT
|
| 23 |
+
tags_to_remove = ["InstanceCreationDate", "InstanceCreationTime", "StudyDate", "SeriesDate",
|
| 24 |
+
"AcquisitionDate", "ContentDate",
|
| 25 |
+
"OverlayDate", "CurveDate", "PatientsWeight" "AcquisitionDatetime", "StudyTime", "SeriesTime",
|
| 26 |
+
"AcquisitionTime", "ContentTime", "OverlayTime", "CurveTime",
|
| 27 |
+
"InstitutionName", "InstitutionAddress", "ReferringPhysiciansName", "ReferringPhysician'sAddress",
|
| 28 |
+
"ReferringPhysiciansTelephoneNumbers", "ReferringPhysicianIdentificationSequence",
|
| 29 |
+
"CodeValue", "CodingSchemeDesignator", "CodeMeaning", "StationName", "StudyDescription",
|
| 30 |
+
"ProcedureCodeSequence", "InstitutionalDepartmentName", "PhysiciansofRecord",
|
| 31 |
+
"PhysiciansofRecordIdentificationSequence", "PerformingPhysiciansName",
|
| 32 |
+
"PerformingPhysicianIdentificationSequence", "NameofPhysiciansReadingStudy",
|
| 33 |
+
"PhysiciansReadingStudyIdentificationSequence",
|
| 34 |
+
"OperatorsName", "AdmittingDiagnosesDescription", "ManufacturerModelName",
|
| 35 |
+
"ReferencedPerformedProcedureStepSequence", "ReferencedImageSequence", "DerivationDescription",
|
| 36 |
+
"IssuerofPatientID", "PatientBirthTime",
|
| 37 |
+
"PatientBirthDateInAlternativeCalendar", "PatientsDeathDateInAlternativeCalendar",
|
| 38 |
+
"PatientsAlternativeCalendar", "PatientSex",
|
| 39 |
+
"OtherPatientIDs", "OtherPatientNames", "OtherPatientIDsSequence", "PatientBirthName",
|
| 40 |
+
"PatientAge", "PatientSize", "PatientWeight", "PatientAddress", "PatientMothersBirthName",
|
| 41 |
+
"MedicalRecordLocator", "MedicalAlerts", "ContrastAllergies", "CountryofResidence",
|
| 42 |
+
"RegionofResidence", "PatientTelephoneNumbers", "EthnicGroup", "Occupation",
|
| 43 |
+
"AdditionalPatientHistory",
|
| 44 |
+
"PregnancyStatus", "ResponsibleOrganization", "PatientComments", "ClinicalTrialSponsorName",
|
| 45 |
+
"ClinicalTrialProtocolID", "ClinicalTrialProtocolName", "ClinicalTrialSiteID",
|
| 46 |
+
"ClinicalTrialSiteName",
|
| 47 |
+
"ClinicalTrialSubjectID", "ClinicalTrialSubjectReadingID", "ClinicalTrialTimePointID",
|
| 48 |
+
"ClinicalTrialTimePointDescription", "ClinicalTrialCoordinatingCenterName", "BodyPartExamined",
|
| 49 |
+
"DeviceSerialNumber",
|
| 50 |
+
"ImageComments", "RequestingPhysician", "RequestingService", "RequestedProcedureDescription",
|
| 51 |
+
"RequestedContrastAgent", "StudyComments", "SpecialNeeds",
|
| 52 |
+
"CurrentPatientLocation", "PatientsInstitutionResidence", "PatientState",
|
| 53 |
+
"ScheduledPerformingPhysiciansName", "PerformedStationAETitle", "PerformedStationName",
|
| 54 |
+
"PerformedLocation",
|
| 55 |
+
"PerformedProcedureStepStartDate", "PerformedProcedureStepStartTime",
|
| 56 |
+
"PerformedProcedureStepEndDate", "PerformedProcedureStepEndTime", "PerformedProcedureStepStatus",
|
| 57 |
+
"PerformedProcedureStepID",
|
| 58 |
+
"PerformedProcedureStepDescription", "PerformedProcedureTypeDescription",
|
| 59 |
+
"PerformedProtocolCodeSequence", "RequestAttributesSequence",
|
| 60 |
+
"CommentsonthePerformedProcedureStep", "RequestedProcedureID",
|
| 61 |
+
"ReasonfortheRequestedProcedure", "RequestedProcedurePriority", "PatientTransportArrangements",
|
| 62 |
+
"RequestedProcedureLocation", "RequestedProcedureComments", "ReasonfortheImagingServiceRequest",
|
| 63 |
+
"IssueDateofImagingServiceRequest", "IssueTimeofImagingServiceRequest", "OrderEnterersLocation",
|
| 64 |
+
"OrderCallbackPhoneNumber", "ImagingServiceRequestComments", "ConceptNameCodeSequence",
|
| 65 |
+
"VerifyingObserverSequence",
|
| 66 |
+
"DateTime", "Date", "Time", "PersonName", "InstanceCreatorUID", "UID", "ContentSequence", "SomeTag"]
|
| 67 |
+
|
| 68 |
+
###############################################################################
|
| 69 |
+
# Anonymize a single file
|
| 70 |
+
###############################################################################
|
| 71 |
+
|
| 72 |
+
mapping_df = pd.read_excel(mapping_file)
|
| 73 |
+
|
| 74 |
+
for source_id in mapping_df['UKA Study ID']:
|
| 75 |
+
# Create destination folder with according ID from mapping_df
|
| 76 |
+
dest_id = mapping_df[mapping_df['UKA Study ID'] == source_id]['ID'].iloc[0]
|
| 77 |
+
dest_pat_root = destination_folder + os.sep + dest_id
|
| 78 |
+
os.makedirs(dest_pat_root, exist_ok=True)
|
| 79 |
+
|
| 80 |
+
source_pat_root = source_folder + os.sep + str(source_id)
|
| 81 |
+
for root, dirs, files in os.walk(source_pat_root):
|
| 82 |
+
# folders with dyn: 'MR NCIdyn sense1.5', 'MR NCIdyn sense030413-2', 'MR sNCIdyn DEV', 'MR NCIdyn sense030413 SENSE', 'MR NCIdyn DEV', 'MR NCIdyn sense030413'
|
| 83 |
+
# folders with T2: 'MR NCIT2TSE HR ckbk', 'MR NCIT2TSE HR ckbk-2', 'MR T2 COR Silikon', 'MR T2 COR DEV', 'MR NCIT2TSE HR', 'MR NCIT2TSE HR ckbk-3', 'MR NCIT2TSE HR-2'
|
| 84 |
+
current_folder = root.split(os.sep)[-1]
|
| 85 |
+
if (('NCIdyn' in current_folder or 'MR Dyn' in current_folder or
|
| 86 |
+
'sNCIdyn' in current_folder or 'MR sDyn' in current_folder or
|
| 87 |
+
'MR MIP Orth' in current_folder or
|
| 88 |
+
'T2' in current_folder) and len(files) > 1):
|
| 89 |
+
stored_dirs = os.listdir(dest_pat_root)
|
| 90 |
+
if current_folder[-2] == '-':
|
| 91 |
+
# there are several versions of this sequence. Take the latest one.
|
| 92 |
+
prev_version_dir = None
|
| 93 |
+
if current_folder[:-2] in stored_dirs:
|
| 94 |
+
prev_version_dir = dest_pat_root + os.sep + current_folder[:-2]
|
| 95 |
+
if current_folder[:-1] + str(int(current_folder[-1]) - 1) in stored_dirs:
|
| 96 |
+
prev_version_dir = dest_pat_root + os.sep + current_folder[:-1] + str(
|
| 97 |
+
int(current_folder[-1]) - 1)
|
| 98 |
+
if prev_version_dir is not None:
|
| 99 |
+
shutil.rmtree(prev_version_dir)
|
| 100 |
+
dest_root = dest_pat_root + os.sep + current_folder
|
| 101 |
+
os.makedirs(dest_root, exist_ok=True)
|
| 102 |
+
|
| 103 |
+
scanning_sequence = None
|
| 104 |
+
for file in files:
|
| 105 |
+
dataset = pydicom.dcmread(root + os.sep + file)
|
| 106 |
+
|
| 107 |
+
###############################################################################
|
| 108 |
+
# We can define a callback function to find all tags corresponding to a person
|
| 109 |
+
# names inside the dataset.
|
| 110 |
+
|
| 111 |
+
def person_names_callback(dataset, data_element):
|
| 112 |
+
if data_element.VR == "PN":
|
| 113 |
+
data_element.value = "anonymous"
|
| 114 |
+
|
| 115 |
+
###############################################################################
|
| 116 |
+
# We can use the callback function to iterate through the dataset but
|
| 117 |
+
# also some other tags such that patient ID, etc.
|
| 118 |
+
|
| 119 |
+
dataset.PatientID = dest_id
|
| 120 |
+
if dest_id != dest_id or dataset.PatientID != dataset.PatientID or len(dest_id) < 1:
|
| 121 |
+
print("PatientID was not set for " + root + os.sep + file)
|
| 122 |
+
dataset.walk(person_names_callback)
|
| 123 |
+
|
| 124 |
+
###############################################################################
|
| 125 |
+
# pydicom allows to remove private tags using ``remove_private_tags`` method
|
| 126 |
+
|
| 127 |
+
dataset.remove_private_tags()
|
| 128 |
+
|
| 129 |
+
###############################################################################
|
| 130 |
+
# Anonymize some specific parameter
|
| 131 |
+
|
| 132 |
+
tag = 'PatientBirthDate'
|
| 133 |
+
if tag in dataset:
|
| 134 |
+
dataset.data_element(tag).value = '19000101' # allow-private-path: DICOM date placeholder
|
| 135 |
+
|
| 136 |
+
tag = 'AccessionNumber'
|
| 137 |
+
if tag in dataset:
|
| 138 |
+
dataset.data_element(tag).value = dest_id
|
| 139 |
+
|
| 140 |
+
tag = 'StudyID'
|
| 141 |
+
if tag in dataset:
|
| 142 |
+
dataset.data_element(tag).value = ''
|
| 143 |
+
|
| 144 |
+
if 'ScanningSequence' in dataset:
|
| 145 |
+
scanning_sequence = dataset.data_element("ScanningSequence")
|
| 146 |
+
elif scanning_sequence is not None:
|
| 147 |
+
# try to set Scanning sequence according to previous slices
|
| 148 |
+
dataset.add_new(scanning_sequence.tag, scanning_sequence.VR, scanning_sequence.value)
|
| 149 |
+
|
| 150 |
+
###############################################################################
|
| 151 |
+
# Delete all tags that are not required
|
| 152 |
+
deleted_tags = 0
|
| 153 |
+
for tag in tags_to_remove:
|
| 154 |
+
if tag in dataset:
|
| 155 |
+
deleted_tags = deleted_tags + 1
|
| 156 |
+
delattr(dataset, tag)
|
| 157 |
+
#print(f"Deleted {deleted_tags} out of {len(tags_to_remove)} tags.")
|
| 158 |
+
##############################################################################
|
| 159 |
+
# Finally, store the image
|
| 160 |
+
dataset.save_as(dest_root + os.sep + file)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def keep_four_folders(destination_folder):
|
| 164 |
+
# check that there are only four folders for each UK_n: 1: dyn folder with most files, 2: T2 folder with most files
|
| 165 |
+
for root, dirs, files in os.walk(destination_folder):
|
| 166 |
+
if root.split(os.sep)[-1].startswith('UKA_'):
|
| 167 |
+
if len(dirs) > 2:
|
| 168 |
+
# check if there is too many folders for dynamic or t2 weighted sequence
|
| 169 |
+
dyn_folder = []
|
| 170 |
+
sub_folder = []
|
| 171 |
+
t2_folder = []
|
| 172 |
+
for dir in dirs:
|
| 173 |
+
if 'SDYN' in dir.upper() or 'SNCIDYN' in dir.upper():
|
| 174 |
+
sub_folder.append(dir)
|
| 175 |
+
elif 'DYN' in dir.upper():
|
| 176 |
+
dyn_folder.append(dir)
|
| 177 |
+
elif 'T2' in dir.upper():
|
| 178 |
+
t2_folder.append(dir)
|
| 179 |
+
else:
|
| 180 |
+
print('Folder {} neither recognized as dynamic nor as T2 sequence folder.'.format(dir))
|
| 181 |
+
# remove the folder with less files
|
| 182 |
+
if len(dyn_folder) > 1:
|
| 183 |
+
prev_dyn = dyn_folder[0]
|
| 184 |
+
for dyn in dyn_folder:
|
| 185 |
+
if len(os.listdir(root + os.sep + dyn)) < len(os.listdir(root + os.sep + prev_dyn)):
|
| 186 |
+
shutil.rmtree(root + os.sep + dyn)
|
| 187 |
+
else:
|
| 188 |
+
if dyn is not prev_dyn:
|
| 189 |
+
shutil.rmtree(root + os.sep + prev_dyn)
|
| 190 |
+
prev_dyn = dyn
|
| 191 |
+
if len(sub_folder) > 1:
|
| 192 |
+
pre_sub = sub_folder[0]
|
| 193 |
+
for sub in sub_folder:
|
| 194 |
+
if len(os.listdir(root + os.sep + sub)) < len(os.listdir(root + os.sep + pre_sub)):
|
| 195 |
+
shutil.rmtree(root + os.sep + sub)
|
| 196 |
+
else:
|
| 197 |
+
if sub is not pre_sub:
|
| 198 |
+
shutil.rmtree(root + os.sep + pre_sub)
|
| 199 |
+
pre_sub = sub
|
| 200 |
+
if len(t2_folder) > 1:
|
| 201 |
+
prev_t2 = t2_folder[0]
|
| 202 |
+
for t2 in t2_folder:
|
| 203 |
+
if len(os.listdir(root + os.sep + t2)) < len(os.listdir(root + os.sep + prev_t2)) or ("COR" in t2 and t2 is not prev_t2):
|
| 204 |
+
# remove the folder with less files (do not use folders with COR)
|
| 205 |
+
shutil.rmtree(root + os.sep + t2)
|
| 206 |
+
else:
|
| 207 |
+
if t2 is not prev_t2:
|
| 208 |
+
shutil.rmtree(root + os.sep + prev_t2)
|
| 209 |
+
prev_t2 = t2
|
| 210 |
+
|
| 211 |
+
elif len(dirs) < 2:
|
| 212 |
+
print('Not enough folder for patient {}'.format(root))
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
if __name__ == "__main__":
|
| 216 |
+
"""
|
| 217 |
+
Anonymize a DICOM tree and normalise its sequence folder names.
|
| 218 |
+
|
| 219 |
+
python -m auto_detect_breast_mri.preprocessing.anonymize_dicom <source_folder> <destination_folder> <mapping_file.xlsx> \
|
| 220 |
+
--output-folder <folder for the renamed tree>
|
| 221 |
+
|
| 222 |
+
All paths must be given explicitly: this script touches patient data, so it must never carry a
|
| 223 |
+
built in default.
|
| 224 |
+
"""
|
| 225 |
+
import argparse
|
| 226 |
+
|
| 227 |
+
parser = argparse.ArgumentParser(description="Anonymize a DICOM tree and rename its sequences.")
|
| 228 |
+
parser.add_argument("source_folder", help="DICOM tree to read")
|
| 229 |
+
parser.add_argument("destination_folder", help="where the anonymized tree is written")
|
| 230 |
+
parser.add_argument("mapping_file", help="xlsx mapping original IDs to pseudonyms")
|
| 231 |
+
parser.add_argument("--output-folder", default=None,
|
| 232 |
+
help="where the sequence-renamed tree is written. "
|
| 233 |
+
"Default: <destination_folder>_renamed")
|
| 234 |
+
args = parser.parse_args()
|
| 235 |
+
|
| 236 |
+
source_folder = args.source_folder
|
| 237 |
+
destination_folder = args.destination_folder
|
| 238 |
+
mapping_file = args.mapping_file
|
| 239 |
+
anonymize(source_folder, destination_folder, mapping_file)
|
| 240 |
+
keep_four_folders(destination_folder)
|
| 241 |
+
|
| 242 |
+
output_folder = args.output_folder or (destination_folder.rstrip(os.sep) + "_renamed")
|
| 243 |
+
sequence_mapping = {'MR sNCIdyn DEV': 'Sub_T1',
|
| 244 |
+
'MR NCIdyn sense1.5': 'Dynamic_T1',
|
| 245 |
+
'MR NCIT2TSE HR ckbk': "T2"}
|
| 246 |
+
|
| 247 |
+
# sanitize remaining files and rename folders for consistency:
|
| 248 |
+
for root, dirs, files in os.walk(destination_folder):
|
| 249 |
+
uid_folder = root.replace(destination_folder, output_folder)
|
| 250 |
+
sequence = uid_folder.split(os.sep)[-1]
|
| 251 |
+
if sequence in sequence_mapping.keys():
|
| 252 |
+
sub_folder = uid_folder.replace(sequence, sequence_mapping.get(sequence))
|
| 253 |
+
elif sequence.startswith('MR NCIdyn') or sequence.startswith('MR Dyn'):
|
| 254 |
+
sub_folder = uid_folder.replace(sequence, 'Dynamic_T1')
|
| 255 |
+
print("Mapped sequence {sequence} to Dynamic_T1 for {root}".format(sequence=sequence, root=root))
|
| 256 |
+
elif sequence.startswith('MR sDyn'):
|
| 257 |
+
sub_folder = uid_folder.replace(sequence, 'Sub_T1')
|
| 258 |
+
print("Mapped sequence {sequence} to Sub_T1 for {root}".format(sequence=sequence, root=root))
|
| 259 |
+
elif sequence.startswith('MR NCIT2') or sequence.startswith("MR T2"):
|
| 260 |
+
sub_folder = uid_folder.replace(sequence, 'T2')
|
| 261 |
+
print("Mapped sequence {sequence} to T2 for {root}".format(sequence=sequence, root=root))
|
| 262 |
+
else:
|
| 263 |
+
sub_folder = uid_folder
|
| 264 |
+
os.makedirs(sub_folder, exist_ok=True)
|
| 265 |
+
for file in files:
|
| 266 |
+
dataset = pydicom.dcmread(root + os.sep + file)
|
| 267 |
+
if len(dataset.PatientID) < 1:
|
| 268 |
+
print("Patient ID was not set for " + root + os.sep + file)
|
| 269 |
+
shutil.copy(root + os.sep + file, sub_folder)
|
| 270 |
+
|
| 271 |
+
'''for root, dirs, files in os.walk(output_folder):
|
| 272 |
+
sequence_name = root.split(os.sep)[-1]
|
| 273 |
+
if 'SNCIDYN' in sequence_name.upper():
|
| 274 |
+
renamed_folder = root.replace(sequence_name, 'Sub_T1')
|
| 275 |
+
shutil.move(root, renamed_folder)
|
| 276 |
+
shutil.rmtree(root)
|
| 277 |
+
print(f"Renamed {sequence_name} to Sub_T1 in {root}")
|
| 278 |
+
elif 'DYN' in sequence_name.upper():
|
| 279 |
+
renamed_folder = root.replace(sequence_name, 'Dynamic_T1')
|
| 280 |
+
shutil.move(root, renamed_folder)
|
| 281 |
+
shutil.rmtree(root)
|
| 282 |
+
print(f"Renamed {sequence_name} to Dynamic_T1 in {root}")
|
| 283 |
+
elif 'T2' in sequence_name.upper():
|
| 284 |
+
renamed_folder = root.replace(sequence_name, 'T2')
|
| 285 |
+
shutil.move(root, renamed_folder)
|
| 286 |
+
shutil.rmtree(root)
|
| 287 |
+
print(f"Renamed {sequence_name} to T2 in {root}")'''
|
| 288 |
+
|
auto_detect_breast_mri/preprocessing/split_breasts.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
import pandas
|
| 5 |
+
import torchio as tio
|
| 6 |
+
|
| 7 |
+
from auto_detect_breast_mri.data.transforms import *
|
| 8 |
+
from auto_detect_breast_mri.models.unet_crop import UNet_Crop
|
| 9 |
+
import nibabel as nib
|
| 10 |
+
|
| 11 |
+
class Preprocessor:
|
| 12 |
+
"""
|
| 13 |
+
Preprocessing of Breast MRI data to be expected in a hierarchical folder structure within path_root.
|
| 14 |
+
All images_full_data are cropped according to the given pretrained UNet, normalized, split into right and left half and saved.
|
| 15 |
+
:param path_root: str
|
| 16 |
+
defines the path to the root folder in which the MRI images_full_data to be loaded into this dataset are stored.
|
| 17 |
+
path_root can either be
|
| 18 |
+
a single folder [Duke-Breast-Cancer-MRI] with folders [001, 002, ..., 00n]
|
| 19 |
+
or a pkl-file from which pickled dataset data is loaded. (in this case, path_features must also be a pkl file.)
|
| 20 |
+
:param shape:
|
| 21 |
+
5D tensor specifying (number of sample loaded * 2, channel, depth, height, width).
|
| 22 |
+
Number of samples is the number of folders named Breast_MRI_xxn. It is multiplied by 2 in shape because images_full_data
|
| 23 |
+
are split into left and right half.
|
| 24 |
+
:param cropping_net
|
| 25 |
+
defines the pretrained model used for cropping if cropping shall be performed. Must be a UNet_Crop
|
| 26 |
+
:param protocol: list of strings
|
| 27 |
+
all protocol files for which preprocessing shall be performed 'pre'-images_full_data are always processed.
|
| 28 |
+
must be one or several of
|
| 29 |
+
'post_1', 'post_2', 'post_3', 'post_4', or 'T1'
|
| 30 |
+
default is all of the above.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, path_root: str,
|
| 34 |
+
path_feature: str,
|
| 35 |
+
cropping_net,
|
| 36 |
+
shape=(32, 512, 512),
|
| 37 |
+
target_spacing=(0.7, 0.7, 3),
|
| 38 |
+
protocol=['pre', 'post_1', 'post_2', 'post_3', 'post_4', 'T2'],
|
| 39 |
+
post_prefix="post_"):
|
| 40 |
+
self.available_protocols = ['pre', 'post_1', 'post_2', 'post_3', 'post_4', 'T2_resampled', 'sub']
|
| 41 |
+
self.prefix = 'post_'
|
| 42 |
+
if not self.prefix.__eq__(post_prefix):
|
| 43 |
+
self.available_protocols[0] = post_prefix + '0'
|
| 44 |
+
self.available_protocols[-1] = 'Sub'
|
| 45 |
+
for i, prot in enumerate(self.available_protocols):
|
| 46 |
+
if prot.__contains__(self.prefix):
|
| 47 |
+
self.available_protocols[i] = self.available_protocols[i].replace(self.prefix, post_prefix)
|
| 48 |
+
self.prefix = post_prefix
|
| 49 |
+
# init parameters
|
| 50 |
+
if path_root:
|
| 51 |
+
self.path_root = Path(path_root)
|
| 52 |
+
else:
|
| 53 |
+
raise AttributeError('A None root path can not instance a dataset object. '
|
| 54 |
+
'Please specify the path where to find Breast Cancer MRIs from the Duke Dataset.')
|
| 55 |
+
if path_feature and len(path_feature):
|
| 56 |
+
self.path_feature = Path(path_feature)
|
| 57 |
+
else:
|
| 58 |
+
raise AttributeError('A None feature path can not instance a dataset object. '
|
| 59 |
+
'Please specify the path where to find labels for this dataset.')
|
| 60 |
+
if shape and all(dim > 0 for dim in shape):
|
| 61 |
+
(self.depth, self.height, self.width) = shape
|
| 62 |
+
else:
|
| 63 |
+
(self.depth, self.height, self.width) = (32, 448, 448)
|
| 64 |
+
if target_spacing and all(dim > 0 for dim in target_spacing):
|
| 65 |
+
self.target_spacing = target_spacing
|
| 66 |
+
else:
|
| 67 |
+
self.target_spacing = (0.7, 0.7, 3)
|
| 68 |
+
self.right_split = tio.Crop((int(self.width / 2), 0, 0, 0, 0, 0))
|
| 69 |
+
self.left_split = tio.Crop((0, int(self.width / 2), 0, 0, 0, 0))
|
| 70 |
+
|
| 71 |
+
if protocol:
|
| 72 |
+
valid_protocol = [True if val in self.available_protocols else False for val in protocol]
|
| 73 |
+
if all(valid_protocol):
|
| 74 |
+
if not protocol.__contains__(self.available_protocols[0]):
|
| 75 |
+
self.mri_protocol = [self.available_protocols[0]]
|
| 76 |
+
for prot in protocol:
|
| 77 |
+
self.mri_protocol.append(prot)
|
| 78 |
+
else:
|
| 79 |
+
self.mri_protocol = protocol
|
| 80 |
+
else:
|
| 81 |
+
print(
|
| 82 |
+
'{} is not a protocol supported. Please check. For now the following sequences are considered: {}'.format(
|
| 83 |
+
protocol[valid_protocol.index(False)], self.available_protocols))
|
| 84 |
+
self.mri_protocol = self.available_protocols
|
| 85 |
+
|
| 86 |
+
self.in_channel = 1 if self.mri_protocol.__contains__('Sub') else len(self.mri_protocol)
|
| 87 |
+
|
| 88 |
+
# shape of a 3D half (right or left) of a breast MRI
|
| 89 |
+
self.single_half_shape = (self.in_channel, self.depth, int(self.height / 2), self.width)
|
| 90 |
+
|
| 91 |
+
if cropping_net is not None:
|
| 92 |
+
self.cropping_unet = cropping_net
|
| 93 |
+
else:
|
| 94 |
+
raise ValueError('Cropping UNet can not be None.')
|
| 95 |
+
|
| 96 |
+
if os.path.exists(self.path_root):
|
| 97 |
+
#malignity = pandas.read_excel(path_feature)
|
| 98 |
+
self.file_label_mapping = {'Path': [], 'Label': []}
|
| 99 |
+
if path_feature.endswith('.xlsx') or path_feature.endswith('.xls'):
|
| 100 |
+
self.label_container = pandas.read_excel(path_feature)
|
| 101 |
+
elif path_feature.endswith('.csv'):
|
| 102 |
+
self.label_container = pandas.read_csv(path_feature)
|
| 103 |
+
else:
|
| 104 |
+
raise ValueError('File type not supported.')
|
| 105 |
+
|
| 106 |
+
for c_channel, (root, _, files) in enumerate(os.walk(self.path_root)):
|
| 107 |
+
self.run_preprocessing_pipeline(root, files, self.label_container)
|
| 108 |
+
print(root + ' is done.')
|
| 109 |
+
|
| 110 |
+
# save filepath - label mapping as pickled file
|
| 111 |
+
io_saver_loader.Pickle_Loader.store_pickle(self.file_label_mapping,
|
| 112 |
+
feature_path + 'filepath_label_mapping.pkl')
|
| 113 |
+
|
| 114 |
+
else:
|
| 115 |
+
raise FileNotFoundError('Path root does not exist: ' + str(self.path_root)
|
| 116 |
+
+ '. Dataset could not be initialized.')
|
| 117 |
+
|
| 118 |
+
def run_preprocessing_pipeline(self, pat_folder, nifti_files, malignity):
|
| 119 |
+
"""
|
| 120 |
+
Run preprocessing pipeline for all nifti files within pat_folder.
|
| 121 |
+
The preprocessing pipeline includes:
|
| 122 |
+
reading nifti files, crop, normalize, split into right and left half, save images_full_data
|
| 123 |
+
According mapping between file paths and malignity labels is appended to this class dictionary
|
| 124 |
+
:param pat_folder: str
|
| 125 |
+
defines the path to the current root folder in which patient files might be found
|
| 126 |
+
this name is equal to the patient ID
|
| 127 |
+
:param nifti_files: list
|
| 128 |
+
all nii.gz-file names within root, might be empty if there are no such files
|
| 129 |
+
:param malignity: numpy.DataFrame
|
| 130 |
+
contains 2 columns:
|
| 131 |
+
First contains patientID concatenated with _ and one out of [left, right], e.g. 001_left
|
| 132 |
+
Second contains the actual label for this breast [True, False]
|
| 133 |
+
If there are no labels for this patient ID as in pat_folder, those nifti_files are skipped.
|
| 134 |
+
:return:
|
| 135 |
+
"""
|
| 136 |
+
pat_ID = pat_folder.split(os.sep)[-1]
|
| 137 |
+
crop_range = int(self.width * 0.25), int(self.width * 0.75), int(self.width * 0.25), int(self.width * 0.75)
|
| 138 |
+
filenames = []
|
| 139 |
+
left_halves = []
|
| 140 |
+
right_halves = []
|
| 141 |
+
path_sceleton = pat_ID + '/{}'
|
| 142 |
+
out_pat_folder = pat_folder.replace(replacing_folder, 'UKA_cropped_sanitized')
|
| 143 |
+
out_root = out_pat_folder.replace(pat_ID, '')
|
| 144 |
+
out_folder_exists = Path(out_pat_folder).exists()
|
| 145 |
+
|
| 146 |
+
pre_filename = 'Dyn_0.nii.gz'
|
| 147 |
+
if not out_folder_exists or (out_folder_exists and len(os.listdir(out_pat_folder)) < len(nifti_files)):
|
| 148 |
+
reference_img = tio.Resample(self.target_spacing)(tio.ScalarImage(pat_folder + os.sep + pre_filename))
|
| 149 |
+
transform = tio.Compose([
|
| 150 |
+
tio.Resample(reference_img),
|
| 151 |
+
tio.CropOrPad((self.height, self.width, self.depth), padding_mode=0),
|
| 152 |
+
tio.ToCanonical()
|
| 153 |
+
])
|
| 154 |
+
|
| 155 |
+
# process pre file first
|
| 156 |
+
if pre_filename in nifti_files:
|
| 157 |
+
filepath = os.path.join(pat_folder, pre_filename)
|
| 158 |
+
(pre_left, pre_right), crop_range = self.perform_preprocessing(filepath, transform, None, reference_img)
|
| 159 |
+
assert len(pre_left.data.unique()) > 1 # ensure that there is no image in one color
|
| 160 |
+
assert len(pre_right.data.unique()) > 1
|
| 161 |
+
|
| 162 |
+
filenames.append(pre_filename)
|
| 163 |
+
left_halves.append(pre_left)
|
| 164 |
+
right_halves.append(pre_right)
|
| 165 |
+
|
| 166 |
+
calc_sub_image = True
|
| 167 |
+
else:
|
| 168 |
+
calc_sub_image = False
|
| 169 |
+
|
| 170 |
+
# duplication is needed to ensure that pre.nii.gz is found before
|
| 171 |
+
for c_depth, filename in enumerate(nifti_files):
|
| 172 |
+
if str(filename).endswith('.nii.gz') and filename != pre_filename:
|
| 173 |
+
mode = filename.split('.nii.gz')[0]
|
| 174 |
+
filepath = os.path.join(pat_folder, filename)
|
| 175 |
+
# crop all other files accordingly
|
| 176 |
+
if mode.startswith('mask_breast') or mode in self.mri_protocol:
|
| 177 |
+
(left_half, right_half), _ = self.perform_preprocessing(filepath, transform, crop_range,
|
| 178 |
+
reference_img)
|
| 179 |
+
filenames.append(filename)
|
| 180 |
+
|
| 181 |
+
if len(left_half.data.unique()) > 1 and len(right_half.data.unique()) > 1:
|
| 182 |
+
left_halves.append(left_half)
|
| 183 |
+
right_halves.append(right_half)
|
| 184 |
+
|
| 185 |
+
# create subtraction image
|
| 186 |
+
if calc_sub_image and mode.startswith(self.prefix):
|
| 187 |
+
sub_reference_img = tio.Resample(self.target_spacing)(pre_left)
|
| 188 |
+
sub_transform = tio.Compose([
|
| 189 |
+
tio.Resample(sub_reference_img),
|
| 190 |
+
tio.CropOrPad((int(self.height / 2), int(self.width / 2), self.depth),
|
| 191 |
+
padding_mode=0),
|
| 192 |
+
tio.ToCanonical()
|
| 193 |
+
])
|
| 194 |
+
# calc subtraction image
|
| 195 |
+
sub_left = left_half.data - pre_left.data
|
| 196 |
+
sub_right = right_half.data - pre_right.data
|
| 197 |
+
sub_filename = path_sceleton + mode.replace(self.prefix, 'Sub') + '.nii.gz'
|
| 198 |
+
self.save_as_ScalarImage(sub_left, sub_right, out_root, sub_filename, sub_transform,
|
| 199 |
+
sub_reference_img.affine)
|
| 200 |
+
else:
|
| 201 |
+
tmp = "Right" if len(left_half.data.unique()) > 1 else "Left"
|
| 202 |
+
print(tmp + " half of " + str(filepath) + " is empty.")
|
| 203 |
+
|
| 204 |
+
if len(left_halves) > 0 and len(right_halves) > 0:
|
| 205 |
+
self.save_scalar_images_and_mapping(left_halves, right_halves,
|
| 206 |
+
out_root, path_sceleton, filenames)
|
| 207 |
+
|
| 208 |
+
def perform_preprocessing(self, path: str, transform, crop_range: tuple | None, reference_img: tio.ScalarImage):
|
| 209 |
+
"""
|
| 210 |
+
Read the nifti file at path, transform it according to given transform, split into left and right halves
|
| 211 |
+
:param path: str
|
| 212 |
+
File path to the nifti file to be cropped
|
| 213 |
+
:param crop_range: tuple
|
| 214 |
+
range to which width is cropped. if it is None, pretrained Unet of this class is used for cropping
|
| 215 |
+
otherwise crop_range is supposed to be tuple of length 4 -> (min_left, max_left, min_right, max_right)
|
| 216 |
+
:return:
|
| 217 |
+
two tuple of length two each:
|
| 218 |
+
first containing left and right preprocessed images_full_data as torchio.ScalarImage
|
| 219 |
+
second containing cropping range (this only differs from crop_range if it has been None.)
|
| 220 |
+
If path is not a valid file, this method returns None instead of the tuples
|
| 221 |
+
"""
|
| 222 |
+
if Path(path).is_file():
|
| 223 |
+
# read nifti
|
| 224 |
+
nifti = tio.ScalarImage(path)
|
| 225 |
+
nifti = transform(nifti)
|
| 226 |
+
# split left, right
|
| 227 |
+
nifti_left = self.left_split(nifti)
|
| 228 |
+
nifti_right = self.right_split(nifti)
|
| 229 |
+
|
| 230 |
+
# crop
|
| 231 |
+
if crop_range is None:
|
| 232 |
+
# TODO change to use CropOrPad from torchio with segmentation mask from UNet
|
| 233 |
+
'''
|
| 234 |
+
left_mask = self.cropping_unet.__call__(nifti_left.data)
|
| 235 |
+
left = tio.Subject(
|
| 236 |
+
image = nifti_left,
|
| 237 |
+
cropping_mask = tio.ToCanonical()(tio.LabelMap(tensor=left_mask.to(torch.int64), affine=reference_img.affine))
|
| 238 |
+
)
|
| 239 |
+
crop_left = tio.CropOrPad((nifti_left.shape[0], int(nifti_left.shape[1]/2), nifti_left.shape[2]), mask_name='cropping_mask')
|
| 240 |
+
cropped_left = crop_left(left)
|
| 241 |
+
|
| 242 |
+
right_mask = self.cropping_unet.__call__(nifti_right.data)
|
| 243 |
+
right = tio.Subject(
|
| 244 |
+
image = nifti_right,
|
| 245 |
+
cropping_mask = tio.LabelMap(tensor=right_mask, affine=reference_img.affine)
|
| 246 |
+
)
|
| 247 |
+
crop_right = tio.CropOrPad((nifti_left.shape[0], int(nifti_left.shape[1]/2), nifti_left.shape[2]), mask_name='cropping_mask')
|
| 248 |
+
cropped_right = crop_right(right)
|
| 249 |
+
'''
|
| 250 |
+
_, (left_min, left_max) = self.cropping_unet.__call__(nifti_left.data)
|
| 251 |
+
_, (right_min, right_max) = self.cropping_unet.__call__(nifti_right.data)
|
| 252 |
+
else:
|
| 253 |
+
(left_min, left_max, right_min, right_max) = crop_range
|
| 254 |
+
cropped_left = tio.Crop((0, 0, left_min, nifti_left.data.shape[2] - left_max, 0, 0))(nifti_left)
|
| 255 |
+
cropped_right = tio.Crop((0, 0, right_min, nifti_right.data.shape[2] - right_max, 0, 0))(nifti_right)
|
| 256 |
+
|
| 257 |
+
return (cropped_left, cropped_right), (left_min, left_max, right_min, right_max)
|
| 258 |
+
return None, None
|
| 259 |
+
|
| 260 |
+
def save_scalar_images_and_mapping(self, left: torch.Tensor, right: torch.Tensor, path_root: str,
|
| 261 |
+
path_sceleton: str, filenames=[]):
|
| 262 |
+
n_images = len(left)
|
| 263 |
+
# left and right are tensors of 3D image tensors -> filenames holds one filename for each image.
|
| 264 |
+
for pos in range(n_images):
|
| 265 |
+
path = path_sceleton + filenames[pos]
|
| 266 |
+
# create folder if not existing
|
| 267 |
+
folder = path_sceleton.replace(path_sceleton.split(os.sep)[-1], '')
|
| 268 |
+
if not os.path.exists(path_root + folder):
|
| 269 |
+
os.makedirs(path_root + folder)
|
| 270 |
+
|
| 271 |
+
left[pos].save(path_root + path.format('L-'))
|
| 272 |
+
right[pos].save(path_root + path.format('R-'))
|
| 273 |
+
|
| 274 |
+
def save_as_ScalarImage(self, left: torch.Tensor, right: torch.Tensor,
|
| 275 |
+
path_root: str, path_sceleton: str, transform, affine):
|
| 276 |
+
left_image = tio.ScalarImage(tensor=left, affine=affine)
|
| 277 |
+
left_image = transform(left_image)
|
| 278 |
+
right_image = tio.ScalarImage(tensor=right, affine=affine)
|
| 279 |
+
right_image = transform(right_image)
|
| 280 |
+
|
| 281 |
+
left_path = path_sceleton.format('L-')
|
| 282 |
+
right_path = path_sceleton.format('R-')
|
| 283 |
+
l_folder = path_root + left_path.replace(left_path.split(os.sep)[-1], '')
|
| 284 |
+
r_folder = path_root + right_path.replace(right_path.split(os.sep)[-1], '')
|
| 285 |
+
|
| 286 |
+
# create folder if not existing
|
| 287 |
+
if not os.path.exists(l_folder):
|
| 288 |
+
os.makedirs(l_folder)
|
| 289 |
+
if not os.path.exists(r_folder):
|
| 290 |
+
os.makedirs(r_folder)
|
| 291 |
+
# save images_full_data
|
| 292 |
+
left_image.save(path_root + left_path)
|
| 293 |
+
right_image.save(path_root + right_path)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == '__main__':
|
| 297 |
+
"""
|
| 298 |
+
Crop every NIfTI under the image root with the trained cropping UNet and split it into a left
|
| 299 |
+
and a right breast volume.
|
| 300 |
+
|
| 301 |
+
python -m auto_detect_breast_mri.preprocessing.split_breasts --unet-path <unet_best.pth>
|
| 302 |
+
|
| 303 |
+
Paths not given on the command line come from the site config (see config.example.yaml).
|
| 304 |
+
"""
|
| 305 |
+
import argparse
|
| 306 |
+
|
| 307 |
+
from auto_detect_breast_mri.config import resolve_path
|
| 308 |
+
|
| 309 |
+
parser = argparse.ArgumentParser(description="Crop and split breast MRIs into left/right halves.")
|
| 310 |
+
parser.add_argument("--unet-path", required=True, help="pth file of the trained cropping UNet")
|
| 311 |
+
parser.add_argument("--data-path", default=None,
|
| 312 |
+
help="image root to preprocess in place. Config key: data_root")
|
| 313 |
+
parser.add_argument("--shape", type=int, nargs=3, default=(512, 512, 32),
|
| 314 |
+
metavar=("HEIGHT", "WIDTH", "DEPTH"),
|
| 315 |
+
help="shape every volume is cropped/padded to. Default: 512 512 32")
|
| 316 |
+
parser.add_argument("--save_cropped_image", action="store_true", default=False, help="If --save_cropped_image is specified, a debug image is printed for investigation of the split.")
|
| 317 |
+
args = parser.parse_args()
|
| 318 |
+
|
| 319 |
+
path_root = resolve_path(args.data_path, "data_root", "image root to preprocess")
|
| 320 |
+
CropNet = UNet_Crop(path_to_unet=args.unet_path, input_channel=1)
|
| 321 |
+
crop = tio.CropOrPad(tuple(args.shape), padding_mode=0) # crop or pad to same size because UNet_Crop crops by half size
|
| 322 |
+
canonic = tio.ToCanonical()
|
| 323 |
+
printed_debug = False
|
| 324 |
+
for c_channel, (root, _, files) in enumerate(os.walk(path_root)):
|
| 325 |
+
for file in files:
|
| 326 |
+
image_data = tio.ScalarImage(root + os.sep + file)
|
| 327 |
+
image_data = crop(image_data)
|
| 328 |
+
image_data = canonic(image_data)
|
| 329 |
+
cropped_image = CropNet.__call__(image_data.data)
|
| 330 |
+
if not printed_debug and args.save_cropped_image:
|
| 331 |
+
output_root = root.replace('nifti', 'outputs')
|
| 332 |
+
os.makedirs(output_root, exist_ok=True)
|
| 333 |
+
output_path = f"{output_root + os.sep + file}"
|
| 334 |
+
print(cropped_image)
|
| 335 |
+
nib.save(nib.Nifti1Image(cropped_image[0].numpy().astype(np.float32), affine=np.eye(4)), output_path)
|
| 336 |
+
printed_debug = True
|
| 337 |
+
print(f"saved cropped image to: {output_path}")
|
auto_detect_breast_mri/training/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Training and evaluation loops, LR schedules and the shared CLI."""
|
auto_detect_breast_mri/training/cli.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The command line interface shared by the training and comparison scripts."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def generate_parser():
|
| 7 |
+
'''
|
| 8 |
+
Generate the parser for this project, which includes parameters for
|
| 9 |
+
batch size, number of epochs, patience, fraction of training data, learning rate, fold, augmentation,
|
| 10 |
+
model path for pretrained networks, and flags 'train' and 'mixed precision'
|
| 11 |
+
:return: the ArgumentParser.
|
| 12 |
+
'''
|
| 13 |
+
train_fraction = [0.05, 0.1, 0.25, 0.5, 1.0] # use all fractions if none is specified
|
| 14 |
+
parser = argparse.ArgumentParser(
|
| 15 |
+
prog="aiMRI",
|
| 16 |
+
description="Train a certain ResNet to classify malignity of Breast MRIs."
|
| 17 |
+
)
|
| 18 |
+
# required arguments
|
| 19 |
+
parser.add_argument("model_name",
|
| 20 |
+
help="Enter the models name to be used.", type=str) # e.g. "resnet50_full", "resnet18_abrv", "resnet18" (for comparison)
|
| 21 |
+
# The three paths are optional here and fall back to the site config (config.example.yaml);
|
| 22 |
+
# giving them on the command line works exactly as before and always wins.
|
| 23 |
+
parser.add_argument("data_path", nargs='?', default=None,
|
| 24 |
+
help="Path to the root folder of the (to the size 32x256x256) cropped MRIs. "
|
| 25 |
+
"Config key: data_root.")
|
| 26 |
+
parser.add_argument("feature_path", nargs='?', default=None,
|
| 27 |
+
help="Path to .xlsx/.csv file that contains label information for the desired criteria and for "
|
| 28 |
+
"each data in data_path. Config key: metadata_file.")
|
| 29 |
+
parser.add_argument("split_files_folder", nargs='?', default=None,
|
| 30 |
+
help="Path to folder containing the per-fold split .csv files with examination ID, label "
|
| 31 |
+
"and side (see README, 'Data contract'). Config key: split_root.")
|
| 32 |
+
# optional arguments
|
| 33 |
+
parser.add_argument("-a", "--augmentation", type=str, choices=['basic', 'intensity'])
|
| 34 |
+
parser.add_argument("-b", "--batch_size", type=int, help="Specifies batch size to be user. Default is 4.")
|
| 35 |
+
parser.add_argument("-c", "--test_fold", type=int) # c for cross validation (t already used)
|
| 36 |
+
parser.add_argument("-d", "--validation_fold", type=int) # inner cross validation
|
| 37 |
+
parser.add_argument("--dataloader", type=str,
|
| 38 |
+
help="Type of dataloader. Only 'subjects' is supported.")
|
| 39 |
+
parser.add_argument("-e", "--evaluation", action="store_true",
|
| 40 |
+
help="If tag is added, evaluation will be performed."
|
| 41 |
+
"Otherwise, only test (or train and test) will be performed.")
|
| 42 |
+
parser.add_argument("-f", "--fraction_of_data", type=float, choices=train_fraction,
|
| 43 |
+
help="Fraction of data to be used for training. Default is to run once for all possible fractions.")
|
| 44 |
+
|
| 45 |
+
parser.add_argument("-l", "--learning_rate", type=float,
|
| 46 |
+
help="Learning rate to be used as initial value. Default is 0.0001.")
|
| 47 |
+
parser.add_argument("-m", "--mixed_precision", action="store_true")
|
| 48 |
+
parser.add_argument("-n", "--number_of_epochs", type=int,
|
| 49 |
+
help="Number of epochs to used for training. Default is 80.")
|
| 50 |
+
parser.add_argument("--output_path", type=str, default=None,
|
| 51 |
+
help="Folder that checkpoints, ROC plots and result files are written to. "
|
| 52 |
+
"Config key: output_root.")
|
| 53 |
+
parser.add_argument("-o", "--model_path", type=str, help="Path to pth-file of the pretrained model. "
|
| 54 |
+
"Architecture must match the one defined by model_name.")
|
| 55 |
+
parser.add_argument("-p", "--patience", type=int,
|
| 56 |
+
help="Patience for early stopping. Must be smaller than number_of_epochs; Default is 0.4 * "
|
| 57 |
+
"number_of_epochs.")
|
| 58 |
+
parser.add_argument("-s", "--scheduler", type=str,
|
| 59 |
+
help="Specify here which Learning rate scheduler should be used: Linear, ReduceLROnPlateau, Step, Multistep, CoscineAnnealing")
|
| 60 |
+
parser.add_argument("-t", "--train", action="store_true",
|
| 61 |
+
help="If tag is added, training will be performed. "
|
| 62 |
+
"Otherwise, parameter model_path must be specified or testing will evaluate untrained model.")
|
| 63 |
+
return parser
|
| 64 |
+
|
| 65 |
+
|
auto_detect_breast_mri/training/loops.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
One epoch of training and of evaluation over a torchio SubjectsLoader, plus the wandb/metric
|
| 3 |
+
bookkeeping around them. Batches are Subject dicts: batch['image']['data'], batch['label'],
|
| 4 |
+
batch['path'].
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gc
|
| 8 |
+
import linecache
|
| 9 |
+
import time
|
| 10 |
+
import tracemalloc
|
| 11 |
+
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pylab
|
| 15 |
+
import torch
|
| 16 |
+
import wandb
|
| 17 |
+
from torch import nn
|
| 18 |
+
from torchmetrics import AUROC, Accuracy, Precision, Specificity, ROC
|
| 19 |
+
|
| 20 |
+
CPU = "cpu"
|
| 21 |
+
GPU = "cuda"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def display_top(snapshot, key_type='lineno', limit=10, title=""):
|
| 25 |
+
"""
|
| 26 |
+
Helper function to see top processes consumin most of the RAM for the current timepoint.
|
| 27 |
+
Limit can be used to define the number of entries to see. Default is 10, so top 10 entries are displayed.
|
| 28 |
+
"""
|
| 29 |
+
snapshot = snapshot.filter_traces((
|
| 30 |
+
tracemalloc.Filter(False, "<frozen importlib._bootstrap>"),
|
| 31 |
+
tracemalloc.Filter(False, "<unknown>"),
|
| 32 |
+
))
|
| 33 |
+
top_stats = snapshot.statistics(key_type)
|
| 34 |
+
|
| 35 |
+
print(f"Top {limit} lines {title}")
|
| 36 |
+
for index, stat in enumerate(top_stats[:limit], 1):
|
| 37 |
+
frame = stat.traceback[0]
|
| 38 |
+
print("#%s: %s:%s: %.1f KiB"
|
| 39 |
+
% (index, frame.filename, frame.lineno, stat.size / 1024))
|
| 40 |
+
line = linecache.getline(frame.filename, frame.lineno).strip()
|
| 41 |
+
if line:
|
| 42 |
+
print(' %s' % line)
|
| 43 |
+
|
| 44 |
+
other = top_stats[limit:]
|
| 45 |
+
if other:
|
| 46 |
+
size = sum(stat.size for stat in other)
|
| 47 |
+
print("%s other: %.1f KiB" % (len(other), size / 1024))
|
| 48 |
+
total = sum(stat.size for stat in top_stats)
|
| 49 |
+
print("Total allocated size: %.1f KiB" % (total / 1024))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def plot_auc(roc_dict, figure_path, suffix=None):
|
| 53 |
+
wandb_suffix = suffix[:-1] if suffix.endswith('_') else suffix
|
| 54 |
+
fpr, tpr, thresholds, auc = roc_dict["fpr"], roc_dict["tpr"], roc_dict["thresholds"], roc_dict["auc"]
|
| 55 |
+
|
| 56 |
+
# Plot tpr vs 1-fpr
|
| 57 |
+
fig, ax = pylab.subplots()
|
| 58 |
+
pylab.plot(fpr, tpr, 'b', label="AUC = %0.3f" % auc)
|
| 59 |
+
pylab.legend(loc='lower right')
|
| 60 |
+
pylab.plot()
|
| 61 |
+
pylab.plot([0, 1], [0, 1], 'r--')
|
| 62 |
+
pylab.xlim([0, 1])
|
| 63 |
+
pylab.ylim([0, 1])
|
| 64 |
+
pylab.ylabel('True Positive Rate')
|
| 65 |
+
pylab.xlabel('False Positive Rate')
|
| 66 |
+
pylab.title(f'Receiver Operating Characteristic - {wandb_suffix} ')
|
| 67 |
+
pylab.show()
|
| 68 |
+
best_index = np.argmax(tpr - fpr)
|
| 69 |
+
if wandb_suffix is not None:
|
| 70 |
+
wandb.log({
|
| 71 |
+
f"ROC/{wandb_suffix}": fig,
|
| 72 |
+
f"Specificity (Youden)/{wandb_suffix}": 1 - fpr[best_index],
|
| 73 |
+
f"Sensitivity (Youden)/{wandb_suffix}": tpr[best_index]
|
| 74 |
+
})
|
| 75 |
+
roc_dict['Spec [i]'] = fpr[best_index]
|
| 76 |
+
roc_dict['Sens [i]'] = tpr[best_index]
|
| 77 |
+
roc_dict['i'] = best_index
|
| 78 |
+
pylab.savefig(figure_path)
|
| 79 |
+
pylab.close(fig)
|
| 80 |
+
return roc_dict
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def train_epoch_subjects(model, optimizer, criterion, data_loader, device, values, suffix="train_", show_figs=False,
|
| 84 |
+
scaler=None, title_suffix="_"):
|
| 85 |
+
"""
|
| 86 |
+
Training loop for one epoch when using subjects loader.
|
| 87 |
+
Similar to train_epoch but handles subjects that contain data itself, label and path.
|
| 88 |
+
:param show_figs
|
| 89 |
+
Set to true if you want to see figures of the transformed data received by this function.
|
| 90 |
+
This can be useful for debugging purposes.
|
| 91 |
+
"""
|
| 92 |
+
start = time.time()
|
| 93 |
+
model.train()
|
| 94 |
+
torch.set_grad_enabled(True)
|
| 95 |
+
epoch_loss = 0
|
| 96 |
+
counter = -1
|
| 97 |
+
wandb_suffix = suffix[:-1] if suffix.endswith('_') else suffix
|
| 98 |
+
|
| 99 |
+
for batch in data_loader:
|
| 100 |
+
data = batch['image']['data']
|
| 101 |
+
ground_truth = batch['label']
|
| 102 |
+
path = batch['path']
|
| 103 |
+
counter += 1
|
| 104 |
+
gc.collect()
|
| 105 |
+
if counter % 10 == 0:
|
| 106 |
+
if show_figs:
|
| 107 |
+
snapshot = tracemalloc.take_snapshot()
|
| 108 |
+
display_top(snapshot=snapshot, title=f"bath {counter}")
|
| 109 |
+
|
| 110 |
+
# Create a figure and axis objects for depth dimension (images of size 256x256)
|
| 111 |
+
fig, axs = plt.subplots(4, 8, figsize=(12, 6))
|
| 112 |
+
|
| 113 |
+
# Plot each slice of the tensor
|
| 114 |
+
for i in range(4):
|
| 115 |
+
for j in range(8):
|
| 116 |
+
index = i * 8 + j
|
| 117 |
+
axs[i, j].imshow(torch.clip(data[0, 0, index], 0, 100), cmap='gray', interpolation=None)
|
| 118 |
+
axs[i, j].axis('off')
|
| 119 |
+
title = path[0] if i == 0 and j == 0 else f"Slice {index}"
|
| 120 |
+
axs[i, j].set_title(title)
|
| 121 |
+
|
| 122 |
+
# Adjust layout and display the plot
|
| 123 |
+
plt.tight_layout()
|
| 124 |
+
plt.show()
|
| 125 |
+
plt.close()
|
| 126 |
+
|
| 127 |
+
data = data.to(device)
|
| 128 |
+
if not torch.is_tensor(ground_truth):
|
| 129 |
+
ground_truth = torch.tensor(ground_truth)
|
| 130 |
+
ground_truth = ground_truth.to(device)
|
| 131 |
+
optimizer.zero_grad()
|
| 132 |
+
|
| 133 |
+
if scaler is not None:
|
| 134 |
+
with torch.autocast(device_type=device, dtype=torch.float16):
|
| 135 |
+
pred_probs = model(data.float())
|
| 136 |
+
loss = criterion(pred_probs[:, 0].float(), ground_truth.float())
|
| 137 |
+
|
| 138 |
+
scaler.scale(loss).backward()
|
| 139 |
+
scaler.step(optimizer)
|
| 140 |
+
scaler.update()
|
| 141 |
+
else:
|
| 142 |
+
pred_probs = model(data.float())
|
| 143 |
+
loss = criterion(pred_probs[:, 0].float(), ground_truth.float())
|
| 144 |
+
loss.backward()
|
| 145 |
+
optimizer.step()
|
| 146 |
+
|
| 147 |
+
if values is not None:
|
| 148 |
+
update_evaluation_metrics(suffix, pred_probs, ground_truth, values)
|
| 149 |
+
|
| 150 |
+
wandb.log({f"loss/{wandb_suffix}": loss.item()}, commit=False)
|
| 151 |
+
epoch_loss += loss.item()
|
| 152 |
+
print('Train step loss: {}'.format(loss.item()))
|
| 153 |
+
|
| 154 |
+
avg_epoch_loss = epoch_loss / len(data_loader)
|
| 155 |
+
end = time.time()
|
| 156 |
+
print('Train {}: Duration = {} sec\n\tAverage Loss: {:.6f}'.format(
|
| 157 |
+
title_suffix,
|
| 158 |
+
end - start,
|
| 159 |
+
avg_epoch_loss))
|
| 160 |
+
|
| 161 |
+
if values is not None:
|
| 162 |
+
log_evaluation(suffix, values, False)
|
| 163 |
+
return avg_epoch_loss
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def update_evaluation_metrics(suffix: str, pred_probs, ground_truth, values):
|
| 167 |
+
auc_roc, roc, acc, sens, spec = values
|
| 168 |
+
for metric in [auc_roc, roc, acc, sens, spec]:
|
| 169 |
+
metric[suffix].update(pred_probs[:, 0], ground_truth)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def log_evaluation(suffix: str, values, plot_tpr_fpr_path=None):
|
| 173 |
+
'''
|
| 174 |
+
Log evaluation metrices with weights and biases.
|
| 175 |
+
@param suffix:
|
| 176 |
+
suffix is the key for all dictionaries of the metrices in values. Also it will be used as suffix for the wandb logging. In case the suffix ends with _, the last character is omitted.
|
| 177 |
+
@param values:
|
| 178 |
+
metric values to be logged supposed to be auc_roc, roc, accuracy, sensitivity and specificity in this order.
|
| 179 |
+
@param plot_tpr_fpr_path:
|
| 180 |
+
Path for figure to save the plotted tpr against fpr. In case it is None, the figure will not be created.
|
| 181 |
+
return
|
| 182 |
+
A dictionary containing values from auc computation as part of the evaluation. Returned values include
|
| 183 |
+
auc value, false positive rate, true positive rate an thresholds.
|
| 184 |
+
Dictionary keys are ['auc', 'fpr', 'tpr' and 'thresholds'] respectively.
|
| 185 |
+
'''
|
| 186 |
+
wandb_suffix = suffix[:-1] if suffix.endswith('_') else suffix
|
| 187 |
+
auc_roc, roc, acc, sens, spec = values
|
| 188 |
+
wandb_dict = {}
|
| 189 |
+
results = {}
|
| 190 |
+
auc = -1
|
| 191 |
+
for name, value in [("Accuracy", acc[suffix]), ("AUC_ROC", auc_roc[suffix]),
|
| 192 |
+
("sensitivity", sens[suffix]), ("specificity", spec[suffix])]:
|
| 193 |
+
print(name + " " + value)
|
| 194 |
+
wandb_dict[f"{name}/{wandb_suffix}"] = value.compute()
|
| 195 |
+
if name == "AUC_ROC":
|
| 196 |
+
results['auc'] = wandb_dict[f"{name}/{wandb_suffix}"]
|
| 197 |
+
value.reset()
|
| 198 |
+
|
| 199 |
+
if plot_tpr_fpr_path:
|
| 200 |
+
fpr, tpr, thresholds = roc[suffix].compute()
|
| 201 |
+
results["fpr"] = fpr
|
| 202 |
+
results["tpr"] = tpr
|
| 203 |
+
results["thresholds"] = thresholds
|
| 204 |
+
results = plot_auc(results, plot_tpr_fpr_path, suffix)
|
| 205 |
+
|
| 206 |
+
wandb.log(wandb_dict)
|
| 207 |
+
return results
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def eval_epoch_subjects(model, criterion, data_loader, device, values, suffix: str, plot_tpr_fpr=None, scaler=None,
|
| 211 |
+
title_suffix="_"):
|
| 212 |
+
"""
|
| 213 |
+
Evaluation loop for one epoch when using subjects loader; handles subjects that contain data itself, label and path.
|
| 214 |
+
:param model
|
| 215 |
+
the model to evaluate
|
| 216 |
+
:criterion
|
| 217 |
+
criterion with which to evaluate
|
| 218 |
+
:data_loader
|
| 219 |
+
Subjects loader providing the subjects containing data, label (which can be mask or integer) and path.
|
| 220 |
+
:device
|
| 221 |
+
the device on which data shall be evaluated
|
| 222 |
+
:values
|
| 223 |
+
Module dictionaries for each metric that shall be evaluated. Should contain auc, roc, accuracy, sensitivity, specificity in the given order.
|
| 224 |
+
:param suffix
|
| 225 |
+
used for printouts and also key to update evaluation metrices as roc, auc, and others.
|
| 226 |
+
:param plot_tpr_fpr
|
| 227 |
+
Indicates weither to plot roc curve in case this parameter is not None. If it is None, no figure will be created.
|
| 228 |
+
Otherwise this parameter should contain a string defining the path to the file as which the plot will be saved.
|
| 229 |
+
:param scaler
|
| 230 |
+
scaler to be used otherwise, None
|
| 231 |
+
:title_suffix
|
| 232 |
+
string for printout to indicate weither this is an evaluation or test epoch.
|
| 233 |
+
"""
|
| 234 |
+
start = time.time()
|
| 235 |
+
model.eval()
|
| 236 |
+
torch.set_grad_enabled(False)
|
| 237 |
+
epoch_loss = 0
|
| 238 |
+
wandb_suffix = suffix[:-1] if suffix.endswith('_') else suffix
|
| 239 |
+
|
| 240 |
+
set_length = len(data_loader)
|
| 241 |
+
batch_size = data_loader.batch_size or 1
|
| 242 |
+
y_pred = torch.zeros((set_length, batch_size), dtype=torch.int) # predicted labels
|
| 243 |
+
y_true = torch.zeros((set_length, batch_size), dtype=torch.int) # true labels
|
| 244 |
+
y_probs = torch.zeros((set_length, batch_size), dtype=torch.float) # predicted probabilities for cancer
|
| 245 |
+
for batch_id, batch in enumerate(data_loader):
|
| 246 |
+
data = batch['image']['data']
|
| 247 |
+
ground_truth = batch['label']
|
| 248 |
+
data = data.to(device)
|
| 249 |
+
if not torch.is_tensor(ground_truth):
|
| 250 |
+
ground_truth = torch.tensor(ground_truth)
|
| 251 |
+
ground_truth = ground_truth.to(device)
|
| 252 |
+
|
| 253 |
+
if scaler is not None:
|
| 254 |
+
with torch.autocast(device_type=device, dtype=torch.float16):
|
| 255 |
+
pred_probs = model(data.float())
|
| 256 |
+
loss = criterion(pred_probs[:, 0].float(), ground_truth.float())
|
| 257 |
+
else:
|
| 258 |
+
pred_probs = model(data.float())
|
| 259 |
+
loss = criterion(pred_probs[:, 0].float(), ground_truth.float())
|
| 260 |
+
|
| 261 |
+
prediction = pred_probs[:, 0] > 0
|
| 262 |
+
# store prediction results for ROC
|
| 263 |
+
y_true[batch_id][0:len(ground_truth)] = ground_truth
|
| 264 |
+
y_pred[batch_id][0:len(ground_truth)] = prediction
|
| 265 |
+
y_probs[batch_id][0:len(ground_truth)] = pred_probs[:, 0]
|
| 266 |
+
|
| 267 |
+
if values is not None:
|
| 268 |
+
update_evaluation_metrics(suffix, pred_probs, ground_truth, values)
|
| 269 |
+
|
| 270 |
+
wandb.log({"loss/" + wandb_suffix: loss.item()}, commit=False)
|
| 271 |
+
epoch_loss += loss.item()
|
| 272 |
+
|
| 273 |
+
avg_epoch_loss = epoch_loss / len(data_loader)
|
| 274 |
+
end = time.time()
|
| 275 |
+
print('Validation ({}) {}: Duration = {} sec\n\tAverage Loss: {:.6f}'.format(
|
| 276 |
+
suffix,
|
| 277 |
+
title_suffix,
|
| 278 |
+
end - start,
|
| 279 |
+
avg_epoch_loss))
|
| 280 |
+
|
| 281 |
+
results = {"y_pred": y_pred, "y_prob": y_probs, "y_true": y_true}
|
| 282 |
+
if values is not None:
|
| 283 |
+
auc_results = log_evaluation(suffix, values, plot_tpr_fpr)
|
| 284 |
+
for key, value in auc_results:
|
| 285 |
+
results[
|
| 286 |
+
key] = value #"nn_auc": auc_results["auc"], "fpr": auc_results["fpr"], "tpr": auc_results["tpr"], "thesholds": auc_results["thresholds"]}
|
| 287 |
+
return avg_epoch_loss, results
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def get_evaluation_metrics(device=CPU):
|
| 291 |
+
auc_roc = nn.ModuleDict({state: AUROC(**{"task": "binary"}).to(device) for state in ["train_", "eval_", "Test_"]})
|
| 292 |
+
roc = nn.ModuleDict({state: ROC(**{"task": "binary"}).to(device) for state in ["train_", "eval_", "Test_"]})
|
| 293 |
+
acc = nn.ModuleDict({state: Accuracy(**{"task": "binary"}).to(device) for state in ["train_", "eval_", "Test_"]})
|
| 294 |
+
sens = nn.ModuleDict({state: Precision(**{"task": "binary"}).to(device) for state in ["train_", "eval_", "Test_"]})
|
| 295 |
+
spec = nn.ModuleDict(
|
| 296 |
+
{state: Specificity(**{"task": "binary"}).to(device) for state in ["train_", "eval_", "Test_"]})
|
| 297 |
+
values = (auc_roc, roc, acc, sens, spec)
|
| 298 |
+
return values
|
| 299 |
+
|
| 300 |
+
|
auto_detect_breast_mri/training/lr_scheduler.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
|
| 3 |
+
from torch.optim.lr_scheduler import StepLR, LinearLR, MultiStepLR, CosineAnnealingLR, ReduceLROnPlateau
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class LRScheduler_Manager(object):
|
| 7 |
+
def __init__(self, optimizer, num_of_epochs):
|
| 8 |
+
if optimizer is not None:
|
| 9 |
+
self.optimizer = optimizer
|
| 10 |
+
else:
|
| 11 |
+
raise ValueError("LRScheduler_Manager requires an optimizer. Can not be None")
|
| 12 |
+
|
| 13 |
+
self.num_of_epochs = num_of_epochs
|
| 14 |
+
|
| 15 |
+
def get_scheduler(self, scheduler_name: str):
|
| 16 |
+
print("get scheduler " + scheduler_name)
|
| 17 |
+
if scheduler_name == "StepLR":
|
| 18 |
+
return self.get_stepLR_scheduler()
|
| 19 |
+
elif scheduler_name == "LinearLR":
|
| 20 |
+
return self.get_linearLR_scheduler()
|
| 21 |
+
elif scheduler_name == "MultiStepLR":
|
| 22 |
+
return self.get_multistepLR_scheduler()
|
| 23 |
+
elif scheduler_name == "CosineAnnealingLR":
|
| 24 |
+
return self.get_cosineAnnealingLR_scheduler()
|
| 25 |
+
elif scheduler_name == "ReduceLROnPlateau":
|
| 26 |
+
return self.get_ReduceLROnPlateau_scheduler()
|
| 27 |
+
|
| 28 |
+
def get_stepLR_scheduler(self):
|
| 29 |
+
step_size = 10
|
| 30 |
+
gamma = 0.7
|
| 31 |
+
step_scheduler = StepLR(self.optimizer, step_size=step_size, gamma=gamma)
|
| 32 |
+
parameters = "StepLR: step_size=" + str(step_size) + ", gamma=" + str(gamma)
|
| 33 |
+
return step_scheduler, parameters
|
| 34 |
+
|
| 35 |
+
def get_linearLR_scheduler(self):
|
| 36 |
+
start_factor = 1.0
|
| 37 |
+
end_factor = 0.000001
|
| 38 |
+
scheduler = LinearLR(self.optimizer, start_factor=start_factor, end_factor=end_factor,
|
| 39 |
+
total_iters=self.num_of_epochs)
|
| 40 |
+
parameters = "LinearLR: start_factor=" + str(start_factor) + ", end_factor=" + str(
|
| 41 |
+
end_factor) + ", total_iters=" + str(self.num_of_epochs)
|
| 42 |
+
return scheduler, parameters
|
| 43 |
+
|
| 44 |
+
def get_multistepLR_scheduler(self):
|
| 45 |
+
milestones = [math.floor(self.num_of_epochs * 0.3), math.floor(self.num_of_epochs * 0.4),
|
| 46 |
+
math.floor(self.num_of_epochs * 0.6),
|
| 47 |
+
math.floor(self.num_of_epochs * 0.75),
|
| 48 |
+
math.floor(self.num_of_epochs * 0.9),
|
| 49 |
+
math.floor(self.num_of_epochs * 0.95), math.floor(self.num_of_epochs * 0.975),
|
| 50 |
+
math.floor(self.num_of_epochs * 0.99)]
|
| 51 |
+
gamma = 0.7
|
| 52 |
+
scheduler = MultiStepLR(self.optimizer, milestones=milestones, gamma=gamma)
|
| 53 |
+
parameters = "MultiStepLR: milestones=" + str(milestones) + ", gamma=" + str(gamma)
|
| 54 |
+
return scheduler, parameters
|
| 55 |
+
|
| 56 |
+
def get_cosineAnnealingLR_scheduler(self):
|
| 57 |
+
scheduler = CosineAnnealingLR(self.optimizer, T_max=self.num_of_epochs)
|
| 58 |
+
return scheduler, ""
|
| 59 |
+
|
| 60 |
+
def get_ReduceLROnPlateau_scheduler(self):
|
| 61 |
+
factor = 0.1
|
| 62 |
+
patience = 5
|
| 63 |
+
scheduler = ReduceLROnPlateau(self.optimizer, mode='min', factor=factor, patience=patience)
|
| 64 |
+
return scheduler, f"ReduceLROnPlateau: mode={str(scheduler.mode)}, factor={factor}, patience={patience}"
|
| 65 |
+
|
config.example.yaml
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Site configuration for auto_detect_breast_mri.
|
| 2 |
+
#
|
| 3 |
+
# cp config.example.yaml config.yaml # config*.yaml is gitignored, this example is not
|
| 4 |
+
# export AIMRI_CONFIG=/absolute/path/to/config.yaml
|
| 5 |
+
#
|
| 6 |
+
# Every value is optional: command line arguments always take precedence, so a fully specified
|
| 7 |
+
# command line needs no config file at all. Keep the real file out of the repository -- it is the
|
| 8 |
+
# one place where paths of your own machine or cluster are allowed to appear.
|
| 9 |
+
|
| 10 |
+
# --- paths -------------------------------------------------------------------------------------
|
| 11 |
+
# Root folder of the NIfTI data, i.e. the folder holding one subfolder per examination.
|
| 12 |
+
# See README.md ("Data contract") for the two supported folder layouts.
|
| 13 |
+
data_root: /path/to/nifti_root
|
| 14 |
+
|
| 15 |
+
# Metadata export (.csv or .xlsx) with one row per examination and the label columns configured
|
| 16 |
+
# under `columns:` below.
|
| 17 |
+
metadata_file: /path/to/metadata.csv
|
| 18 |
+
|
| 19 |
+
# Folder holding the cross validation split files, i.e. the parent of fold0/, fold1/, ...
|
| 20 |
+
# Expected file names are documented in README.md ("Split files").
|
| 21 |
+
split_root: /path/to/split_files
|
| 22 |
+
|
| 23 |
+
# Where runs write checkpoints, ROC plots, heatmaps, prediction tables and pickled results.
|
| 24 |
+
output_root: /path/to/outputs
|
| 25 |
+
|
| 26 |
+
# Where already trained checkpoints are read from. Defaults to output_root when unset.
|
| 27 |
+
model_root: /path/to/checkpoints
|
| 28 |
+
|
| 29 |
+
# --- weights & biases --------------------------------------------------------------------------
|
| 30 |
+
# Project and (optional) team the runs are logged to. Set wandb_mode to 'offline' to keep runs
|
| 31 |
+
# local, or 'disabled' to turn logging off entirely -- useful when running on data that must not
|
| 32 |
+
# leave the machine, since run names and captions can contain case identifiers.
|
| 33 |
+
wandb_project: my-breast-mri-project
|
| 34 |
+
wandb_entity: my-team
|
| 35 |
+
wandb_mode: online
|
| 36 |
+
|
| 37 |
+
# --- CSV column names --------------------------------------------------------------------------
|
| 38 |
+
# Names of the columns in your metadata export and split files. Override what differs in your data. Values in the side
|
| 39 |
+
# column are matched case insensitively. Unset keys fall back to the defaults in
|
| 40 |
+
# auto_detect_breast_mri/config.py (DEFAULT_COLUMNS), which are the original UKA export's names.
|
| 41 |
+
#
|
| 42 |
+
# tools/make_synthetic_dataset.py reads this block to name the columns of the CSVs it writes and
|
| 43 |
+
# copies it into the config it generates, so a renamed column only has to be changed here.
|
| 44 |
+
columns:
|
| 45 |
+
examination_id: exam_id # one MRI examination, in the split CSVs
|
| 46 |
+
metadata_examination_id: exam_id # the same ID in the metadata export
|
| 47 |
+
patient_id: pat_id # patient; splits are grouped by this so that no
|
| 48 |
+
# patient appears in two folds
|
| 49 |
+
side: side # breast side in the split CSVs
|
| 50 |
+
metadata_side: side # breast side in the metadata export
|
| 51 |
+
criterion: Malign # training label, one row per breast
|
| 52 |
+
criterion_left: Malign_Left # per-side label in the wide metadata export
|
| 53 |
+
criterion_right: Malign_Right
|
| 54 |
+
side_left: left # the two values found in the side column
|
| 55 |
+
side_right: right
|
| 56 |
+
indication_left: Indication_Left # per-side indication code in the metadata export
|
| 57 |
+
indication_right: Indication_Right
|
pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "auto-detect-breast-mri"
|
| 3 |
+
description = "Malignancy classification on breast MRI: abbreviated (AP) against full dynamic (FDP) protocol"
|
| 4 |
+
readme = "README.md"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
authors = [{ name = "Debora Jutz" }] # allow-private-path: package author, published deliberately
|
| 7 |
+
|
| 8 |
+
# The version stays in auto_detect_breast_mri/__init__.py and the dependency list in
|
| 9 |
+
# requirements.txt, so conda users, CI and uv all read the same two files.
|
| 10 |
+
dynamic = ["version", "dependencies"]
|
| 11 |
+
|
| 12 |
+
[project.urls]
|
| 13 |
+
Repository = "https://github.com/deboraJ1/auto_detect_breast_mri" # allow-private-path: public URL
|
| 14 |
+
|
| 15 |
+
[build-system]
|
| 16 |
+
requires = ["setuptools>=61"]
|
| 17 |
+
build-backend = "setuptools.build_meta"
|
| 18 |
+
|
| 19 |
+
[tool.setuptools.dynamic]
|
| 20 |
+
version = { attr = "auto_detect_breast_mri.__version__" }
|
| 21 |
+
dependencies = { file = ["requirements.txt"] }
|
| 22 |
+
|
| 23 |
+
# Only the importable package is shipped; scripts/, jobs/, tools/ and tests/ are repository content.
|
| 24 |
+
[tool.setuptools.packages.find]
|
| 25 |
+
include = ["auto_detect_breast_mri*"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy~=1.26.4
|
| 2 |
+
matplotlib~=3.9.2
|
| 3 |
+
pandas~=2.2.2
|
| 4 |
+
monai
|
| 5 |
+
setuptools~=75.5.0
|
| 6 |
+
torchvision~=0.19.1
|
| 7 |
+
torchio
|
| 8 |
+
wandb~=0.18.0
|
| 9 |
+
scikit-learn~=1.5.2
|
| 10 |
+
scikit-image~=0.24.0
|
| 11 |
+
pillow~=11.0.0
|
| 12 |
+
nibabel
|
| 13 |
+
openpyxl
|
| 14 |
+
|
| 15 |
+
torch~=2.4.1
|
| 16 |
+
pydicom~=2.4.3
|
| 17 |
+
SimpleITK~=2.4.0
|
| 18 |
+
tqdm~=4.67.0
|
| 19 |
+
scipy~=1.14.1
|
| 20 |
+
sympy~=1.13.2
|
| 21 |
+
tcia_utils~=2.2.1
|
| 22 |
+
imageio~=2.36.0
|
| 23 |
+
opencv-python~=4.10.0.84
|
| 24 |
+
torchmetrics~=1.4.2
|
| 25 |
+
grad-cam~=1.5.4
|
| 26 |
+
PyYAML~=6.0
|
tests/test_augmentation.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import matplotlib.pyplot as plt
|
| 4 |
+
import numpy
|
| 5 |
+
import torch
|
| 6 |
+
import cv2
|
| 7 |
+
|
| 8 |
+
from auto_detect_breast_mri.data.transforms import *
|
| 9 |
+
from auto_detect_breast_mri.models.unet_crop import UNet_Crop
|
| 10 |
+
from auto_detect_breast_mri.data.loaders import get_multiple_subjects_dataloader
|
| 11 |
+
from auto_detect_breast_mri.config import resolve_path
|
| 12 |
+
from auto_detect_breast_mri.data.metadata import get_uka_metatensor
|
| 13 |
+
import unittest
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestAugmentation(unittest.TestCase):
|
| 17 |
+
def setUp(self) -> None:
|
| 18 |
+
# Parameters that can be adapted:
|
| 19 |
+
self.batch_size = 1
|
| 20 |
+
# data locations come from the site config; point $AIMRI_CONFIG at the synthetic dataset
|
| 21 |
+
# (tools/make_synthetic_dataset.py) to run this without any patient data
|
| 22 |
+
feature_path = resolve_path(None, "metadata_file", "metadata export")
|
| 23 |
+
path_base = resolve_path(None, "data_root", "root folder of the NIfTI data")
|
| 24 |
+
split_files_folder = resolve_path(None, "split_root", "folder holding the split files")
|
| 25 |
+
pretrained_model = str(Path(resolve_path(None, "model_root", "checkpoint folder")) / "unet_best.pth")
|
| 26 |
+
protocol = ['pre']
|
| 27 |
+
pre_image_shape = (32, 512, 512)
|
| 28 |
+
|
| 29 |
+
# transformations
|
| 30 |
+
self.norm = ZNormalization()
|
| 31 |
+
self.crop = UNet_Crop(input_channel=len(protocol), path_to_unet=pretrained_model)
|
| 32 |
+
self.mirror = tio.RandomFlip((0,1,2), flip_probability=0.5)
|
| 33 |
+
self.scale = tio.RandomAffine(scales=0.2, degrees=0, center='image', default_pad_value='minimum', check_shape=True)
|
| 34 |
+
self.rotate = tio.RandomAffine(scales=0, degrees=25, center='image', default_pad_value='minimum', check_shape=True)
|
| 35 |
+
transform = tio.Compose([
|
| 36 |
+
self.scale,
|
| 37 |
+
self.mirror,
|
| 38 |
+
self.crop,
|
| 39 |
+
self.norm,
|
| 40 |
+
ImageToTensor()
|
| 41 |
+
])
|
| 42 |
+
|
| 43 |
+
# Dataset and DataLoader
|
| 44 |
+
feature_dataframe = get_uka_metatensor(0, feature_path)
|
| 45 |
+
self.train_loader, self.eval_loader, self.test_loader = get_multiple_subjects_dataloader(path_base,
|
| 46 |
+
feature_dataframe,
|
| 47 |
+
pre_image_shape,
|
| 48 |
+
protocol=protocol,
|
| 49 |
+
set_folder=split_files_folder,
|
| 50 |
+
transform=transform,
|
| 51 |
+
batch_size=self.batch_size,
|
| 52 |
+
segmentation_task=True,
|
| 53 |
+
stratified=True)
|
| 54 |
+
self.dataset = self.train_loader.dataset
|
| 55 |
+
|
| 56 |
+
self.circle_image = numpy.zeros(pre_image_shape)
|
| 57 |
+
self.circle_center = (112, 357) # col, row
|
| 58 |
+
self.radius = 84
|
| 59 |
+
self.mask_width = 256
|
| 60 |
+
for d in range(pre_image_shape[0]):
|
| 61 |
+
half = pre_image_shape[0] / 2
|
| 62 |
+
radius = self.radius * d / pre_image_shape[0] if d < half else self.radius * (pre_image_shape[0] - d) / \
|
| 63 |
+
pre_image_shape[0]
|
| 64 |
+
radius = int(radius)
|
| 65 |
+
cv2.circle(self.circle_image[d], self.circle_center, radius, (1, 1, 1), -1)
|
| 66 |
+
|
| 67 |
+
self.probability_tensor = torch.ones((2,) + pre_image_shape)
|
| 68 |
+
self.probability_tensor[1][self.circle_image == 1] = 2.2
|
| 69 |
+
self.probability_tensor[0][self.circle_image == 1] = 0.1
|
| 70 |
+
self.probability_tensor[1][self.circle_image != 1] = 0.3
|
| 71 |
+
self.probability_tensor[0][self.circle_image != 1] = 0.7
|
| 72 |
+
|
| 73 |
+
plt.imshow(self.circle_image[16], cmap='gray', interpolation='none')
|
| 74 |
+
plt.show()
|
| 75 |
+
|
| 76 |
+
def test_mask_extraction(self):
|
| 77 |
+
height_position = self.circle_center[1] # radius is defined as x, y (x is horizontal)
|
| 78 |
+
height_min = height_position - int(self.mask_width / 2)
|
| 79 |
+
height_max = height_position + int(self.mask_width / 2)
|
| 80 |
+
if height_min < 0:
|
| 81 |
+
# cropping area would move out of image to the left
|
| 82 |
+
height_min = 0
|
| 83 |
+
height_max = self.mask_width
|
| 84 |
+
elif height_max > self.circle_image.shape[-1]:
|
| 85 |
+
# cropping area would move out of image to the right
|
| 86 |
+
height_min = self.mask_width
|
| 87 |
+
height_max = self.circle_image.shape[-1]
|
| 88 |
+
|
| 89 |
+
expected_output = torch.Tensor(self.circle_image)[:, height_min:height_max, :]
|
| 90 |
+
plt.imshow(expected_output[16], cmap='gray', interpolation='none')
|
| 91 |
+
plt.show()
|
| 92 |
+
|
| 93 |
+
cropped_mask, (comp_min, comp_max) = self.crop.extract_breast_mask(self.probability_tensor, self.mask_width)
|
| 94 |
+
calculated_output = cropped_mask[0]
|
| 95 |
+
plt.imshow(cropped_mask[0, 16], cmap='gray', interpolation='none')
|
| 96 |
+
plt.show()
|
| 97 |
+
|
| 98 |
+
self.assertTrue(torch.all(calculated_output.eq(expected_output)))
|
| 99 |
+
|
| 100 |
+
def test_augmentation_steps_visually(self):
|
| 101 |
+
for batch_id, batch in enumerate(self.train_loader):
|
| 102 |
+
data = batch['image']['data']
|
| 103 |
+
mask = batch['label']['data']
|
| 104 |
+
# image (one half)
|
| 105 |
+
plt.imshow(data[0][0][21], cmap='gray', interpolation='none')
|
| 106 |
+
plt.show()
|
| 107 |
+
# mask
|
| 108 |
+
plt.imshow(mask[0][1][21], cmap='gray', interpolation='none')
|
| 109 |
+
plt.show()
|
| 110 |
+
|
| 111 |
+
# Preprocessing:
|
| 112 |
+
# 0. norm images_full_data to be between 0 and 1
|
| 113 |
+
preprocessed_input = self.norm.__call__(data[0])
|
| 114 |
+
# image (one half)
|
| 115 |
+
plt.imshow(preprocessed_input[0][21], cmap='gray', interpolation='none')
|
| 116 |
+
plt.show()
|
| 117 |
+
|
| 118 |
+
# 1. crop images_full_data with unet
|
| 119 |
+
cropped_image, (w_min, w_max), _ = self.crop.__call__(preprocessed_input)
|
| 120 |
+
cropped_mask = mask[0, :, :, :, w_min:w_max]
|
| 121 |
+
plt.imshow(torch.cat([cropped_image[0][21], cropped_mask[1][21]]), cmap='gray', interpolation='none')
|
| 122 |
+
plt.show()
|
| 123 |
+
|
| 124 |
+
transformation_tensor = torch.cat([cropped_image, cropped_mask], dim=0)
|
| 125 |
+
# 2. Mirror randomly
|
| 126 |
+
flipped_image = self.mirror.__call__(transformation_tensor)
|
| 127 |
+
plt.imshow(torch.cat([flipped_image[0][21], flipped_image[4][21]]), cmap='gray', interpolation='none')
|
| 128 |
+
plt.show()
|
| 129 |
+
|
| 130 |
+
# 3. Random rotation between [-25, 25]
|
| 131 |
+
rotated_image = self.rotate.__call__(flipped_image)
|
| 132 |
+
plt.imshow(torch.cat([rotated_image[0][21], rotated_image[4][21]]), cmap='gray', interpolation='none')
|
| 133 |
+
plt.show()
|
| 134 |
+
|
| 135 |
+
# 4. Rescale between [0.9, 1.1]
|
| 136 |
+
rescaled_image = self.scale.__call__(rotated_image)
|
| 137 |
+
plt.imshow(torch.cat([rescaled_image[0][21], rescaled_image[4][21]]), cmap='gray', interpolation='none')
|
| 138 |
+
plt.show()
|
| 139 |
+
|
| 140 |
+
self.assertEqual(batch_id, len(self.train_loader)/self.batch_size)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
unittest.main()
|
tests/test_dim_comparison.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import torch
|
| 3 |
+
from auto_detect_breast_mri.data.dim_comparison import *
|
| 4 |
+
class TestDimComparison(unittest.TestCase):
|
| 5 |
+
def setUp(self) -> None:
|
| 6 |
+
# prepare input tuples to be compared
|
| 7 |
+
## tuple 1
|
| 8 |
+
self.dim1 = torch.randint(1, 5, (1,))
|
| 9 |
+
|
| 10 |
+
self.shape_short = (torch.randint(1, 512, (1,)),)
|
| 11 |
+
while len(self.shape_short) < self.dim1:
|
| 12 |
+
self.shape_short = (self.shape_short + (torch.randint(1, 512, (1,)),))
|
| 13 |
+
|
| 14 |
+
## tuple 2: longer shape and having value being greater in the longer part
|
| 15 |
+
self.dim2 = torch.randint(self.dim1, 10, (1,))
|
| 16 |
+
self.shape_long_greater = (self.shape_short + (700,)) # 700 as value being for sure greater than max value 512
|
| 17 |
+
|
| 18 |
+
## tuple 3: longer shape and having value being smaller in the longer part
|
| 19 |
+
self.shape_long_smaller = (self.shape_short + (0,))
|
| 20 |
+
|
| 21 |
+
## tuple 4: same length but all values smaller
|
| 22 |
+
self.shape_short_smaller = (self.shape_short[0] - 1,)
|
| 23 |
+
length = len(self.shape_short_smaller)
|
| 24 |
+
while length < self.dim1:
|
| 25 |
+
self.shape_short_smaller = (self.shape_short_smaller + (self.shape_short[length] - 1,))
|
| 26 |
+
length += 1
|
| 27 |
+
|
| 28 |
+
## tuple 5: same length but all values greater
|
| 29 |
+
self.shape_short_greater = (self.shape_short[0] + 1,)
|
| 30 |
+
length = len(self.shape_short_greater)
|
| 31 |
+
while length < self.dim1:
|
| 32 |
+
self.shape_short_greater = (self.shape_short_greater + (self.shape_short[length] + 1,))
|
| 33 |
+
length += 1
|
| 34 |
+
|
| 35 |
+
def test_is_any_dim_greater_allSmaller(self):
|
| 36 |
+
# compare with tuple with same size, all values being smaller
|
| 37 |
+
self.assertEqual(is_any_dim_greater(self.shape_short, self.shape_short_smaller), True)
|
| 38 |
+
|
| 39 |
+
def test_is_any_dim_greater_allEqual(self):
|
| 40 |
+
self.assertEqual(is_any_dim_greater(self.shape_short, self.shape_short), False)
|
| 41 |
+
|
| 42 |
+
def test_is_any_dim_greater_SmallerLong(self):
|
| 43 |
+
# compare with tuple with longer size, values being out of bound being smaller
|
| 44 |
+
self.assertEqual(is_any_dim_greater(self.shape_short, self.shape_long_smaller), False)
|
| 45 |
+
|
| 46 |
+
def test_is_any_dim_greater_GreaterLong(self):
|
| 47 |
+
# compare with tuple with smaller size, value out of bounds being greater
|
| 48 |
+
self.assertEqual(is_any_dim_greater(self.shape_long_greater, self.shape_short), False)
|
| 49 |
+
|
| 50 |
+
def test_is_any_dim_greater_GreaterLong2(self):
|
| 51 |
+
# compare with tuple with longer size, out of dimensions value being greater
|
| 52 |
+
self.assertEqual(is_any_dim_greater(self.shape_short, self.shape_long_greater), False)
|
| 53 |
+
|
| 54 |
+
def test_is_any_dim_greater_Greater(self):
|
| 55 |
+
# compare with tuple with same size, single value being greater
|
| 56 |
+
random_pos = torch.randint(0, self.dim1 - 1)
|
| 57 |
+
greater = (self.shape_short[:random_pos] + (self.shape_short[random_pos] + 1,) + self.shape_short[random_pos:])
|
| 58 |
+
self.assertEqual(is_any_dim_greater(greater, self.shape_short), True)
|
| 59 |
+
|
| 60 |
+
def test_is_any_dim_smaller_allGreater(self):
|
| 61 |
+
# all positions being smaller
|
| 62 |
+
self.assertEqual(is_any_dim_smaller(self.shape_short, self.shape_short_greater), True)
|
| 63 |
+
|
| 64 |
+
def test_is_any_dim_smaller_allEqual(self):
|
| 65 |
+
self.assertEqual(is_any_dim_smaller(self.shape_short, self.shape_short), False)
|
| 66 |
+
|
| 67 |
+
def test_is_any_dim_smaller_SmallerLong(self):
|
| 68 |
+
# compare with tuple with same size, all values being greater
|
| 69 |
+
self.assertEqual(is_any_dim_smaller(self.shape_short, self.shape_short_smaller), False)
|
| 70 |
+
|
| 71 |
+
def test_is_any_dim_smaller_SmallerLong2(self):
|
| 72 |
+
# compare with tuple with same size, all values being smaller
|
| 73 |
+
self.assertEqual(is_any_dim_smaller(self.shape_short_smaller, self.shape_short), True)
|
| 74 |
+
|
| 75 |
+
def test_is_any_dim_smaller_GreaterLong2(self):
|
| 76 |
+
self.assertEqual(is_any_dim_smaller(self.shape_short, self.shape_long_greater), False)
|
| 77 |
+
|
| 78 |
+
def test_is_any_dim_smaller_Smaller(self):
|
| 79 |
+
# single position has smaller value
|
| 80 |
+
random_pos = torch.randint(0, self.dim1 - 1)
|
| 81 |
+
smaller = (self.shape_short[:random_pos] + (self.shape_short[random_pos] - 1,) + self.shape_short[random_pos:])
|
| 82 |
+
|
| 83 |
+
self.assertEqual(is_any_dim_smaller(smaller, self.shape_short), True)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
unittest.main()
|
tests/test_training.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Placeholder for unit tests of the training and evaluation loops in
|
| 3 |
+
auto_detect_breast_mri.training.loops.
|
| 4 |
+
|
| 5 |
+
TODO: test one training epoch and one evaluation epoch against the synthetic dataset
|
| 6 |
+
(tools/make_synthetic_dataset.py), so the loops are covered without any patient data.
|
| 7 |
+
"""
|
tools/check_no_private_paths.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Fail if a tracked file contains something that must not be published: an absolute path of somebody's
|
| 4 |
+
machine, a cluster account, a personal name, or a bare identifier that could be a patient or
|
| 5 |
+
examination ID.
|
| 6 |
+
|
| 7 |
+
python tools/check_no_private_paths.py # scan the whole repository
|
| 8 |
+
python tools/check_no_private_paths.py a.py b.py # scan the given files (pre-commit passes these)
|
| 9 |
+
|
| 10 |
+
Exit status is 1 if anything was found, so this works as a pre-commit hook and as a CI step.
|
| 11 |
+
|
| 12 |
+
Two escape hatches, both deliberate and both visible in review:
|
| 13 |
+
* put `# allow-private-path` (or `allow-private-path` in any comment syntax) on the offending line
|
| 14 |
+
* add the file to ALLOWED_FILES below
|
| 15 |
+
|
| 16 |
+
Digits are only reported inside string literals of Python files and in plain text elsewhere, so
|
| 17 |
+
seeds, random states and version numbers written as integers do not trip the check.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import ast
|
| 22 |
+
import re
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 27 |
+
|
| 28 |
+
# Directories that are never scanned.
|
| 29 |
+
SKIP_DIRS = {".git", "__pycache__", ".venv", "venv", "env", "build", "dist", ".pytest_cache",
|
| 30 |
+
".mypy_cache", ".ruff_cache", ".idea", ".vscode", "outputs", "wandb", "synthetic_data"}
|
| 31 |
+
|
| 32 |
+
# Only these extensions are scanned; everything else (images, checkpoints, data) is either
|
| 33 |
+
# gitignored or not text.
|
| 34 |
+
SCAN_SUFFIXES = {".py", ".sh", ".sbatch", ".example", ".yaml", ".yml", ".cfg", ".ini", ".toml",
|
| 35 |
+
".md", ".txt", ".env", ".json", ".zsh", ".bash"}
|
| 36 |
+
|
| 37 |
+
# Files exempt from the whole check. Keep this list empty if you can.
|
| 38 |
+
ALLOWED_FILES = {
|
| 39 |
+
"tools/check_no_private_paths.py", # this file necessarily contains the patterns themselves
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
# The untracked site configuration: these files exist precisely to hold local paths, and .gitignore
|
| 43 |
+
# keeps them out of the repository. Mirrors the first section of .gitignore -- keep both in sync.
|
| 44 |
+
SKIP_GLOBS = ["config*.yaml", "config*.yml", "jobs/site.env", "*.sbatch", ".env"]
|
| 45 |
+
SKIP_GLOB_EXCEPTIONS = ["config.example.yaml", "*.sbatch.example", "*.env.example"]
|
| 46 |
+
|
| 47 |
+
ESCAPE_MARKER = "allow-private-path"
|
| 48 |
+
|
| 49 |
+
# Personal names that must not appear anywhere. Extend when people join or leave the project.
|
| 50 |
+
NAME_DENYLIST = ["debora", "gustav", "it336446"]
|
| 51 |
+
|
| 52 |
+
# (name, compiled pattern, explanation) applied line by line to every scanned file.
|
| 53 |
+
LINE_PATTERNS = [
|
| 54 |
+
("unix-home", re.compile(r"/home/[A-Za-z0-9._-]+"),
|
| 55 |
+
"absolute path in somebody's home directory"),
|
| 56 |
+
("hpc-scratch", re.compile(r"/(?:hpcwork|work|rwthfs|mnt|media)/[A-Za-z0-9._/-]+"),
|
| 57 |
+
"absolute path on a cluster or mounted filesystem"),
|
| 58 |
+
("windows-drive", re.compile(r"\b[A-Za-z]:[\\/]{1,2}[A-Za-z0-9._\\/-]+"),
|
| 59 |
+
"absolute Windows path"),
|
| 60 |
+
("cluster-account", re.compile(r"\bp00\d{5}\b"),
|
| 61 |
+
"cluster accounting project"),
|
| 62 |
+
("cluster-project", re.compile(r"\brwth\d{3,}\b", re.IGNORECASE),
|
| 63 |
+
"cluster project id"),
|
| 64 |
+
("personal-name", re.compile("|".join(re.escape(name) for name in NAME_DENYLIST), re.IGNORECASE),
|
| 65 |
+
"personal name or user account"),
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
# Bare 7 to 10 digit numbers look like examination or patient IDs. Applied to Python string
|
| 69 |
+
# literals and to non-Python text, never to Python integer literals.
|
| 70 |
+
ID_PATTERN = re.compile(r"(?<!\d)\d{7,10}(?!\d)")
|
| 71 |
+
ID_REASON = "bare 7-10 digit number, which may be a patient or examination identifier"
|
| 72 |
+
|
| 73 |
+
# Digits inside a URL are page ids, dataset ids or versions, never a local patient identifier.
|
| 74 |
+
URL_PATTERN = re.compile(r"https?://")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class Finding:
|
| 78 |
+
def __init__(self, path, line_no, rule, reason, excerpt):
|
| 79 |
+
self.path, self.line_no, self.rule, self.reason, self.excerpt = path, line_no, rule, reason, excerpt
|
| 80 |
+
|
| 81 |
+
def __str__(self):
|
| 82 |
+
return f"{self.path}:{self.line_no}: [{self.rule}] {self.reason}\n {self.excerpt.strip()[:160]}"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def is_untracked_site_file(path: Path) -> bool:
|
| 86 |
+
"""True for the gitignored files whose whole purpose is to hold local paths."""
|
| 87 |
+
if any(path.match(pattern) for pattern in SKIP_GLOB_EXCEPTIONS):
|
| 88 |
+
return False
|
| 89 |
+
return any(path.match(pattern) or path.name == pattern for pattern in SKIP_GLOBS)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def is_scannable(path: Path) -> bool:
|
| 93 |
+
if any(part in SKIP_DIRS for part in path.parts):
|
| 94 |
+
return False
|
| 95 |
+
if is_untracked_site_file(path):
|
| 96 |
+
return False
|
| 97 |
+
if path.name.startswith("."):
|
| 98 |
+
# dotfiles that are still worth scanning
|
| 99 |
+
return path.name in {".gitignore", ".pre-commit-config.yaml"}
|
| 100 |
+
return path.suffix in SCAN_SUFFIXES
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def iter_files(explicit):
|
| 104 |
+
if explicit:
|
| 105 |
+
for name in explicit:
|
| 106 |
+
path = Path(name)
|
| 107 |
+
if path.is_file() and is_scannable(path):
|
| 108 |
+
yield path
|
| 109 |
+
return
|
| 110 |
+
for path in sorted(REPO_ROOT.rglob("*")):
|
| 111 |
+
if path.is_file() and is_scannable(path.relative_to(REPO_ROOT)):
|
| 112 |
+
yield path.relative_to(REPO_ROOT)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def string_literal_lines(source: str):
|
| 116 |
+
"""
|
| 117 |
+
Python string literals as (first line, last line, value), for the ID check.
|
| 118 |
+
|
| 119 |
+
The line span matters for two reasons: a multi-line literal is reported at its first line, and
|
| 120 |
+
an escape marker anywhere inside it (or on its first line) must silence it.
|
| 121 |
+
"""
|
| 122 |
+
try:
|
| 123 |
+
tree = ast.parse(source)
|
| 124 |
+
except SyntaxError:
|
| 125 |
+
return []
|
| 126 |
+
found = []
|
| 127 |
+
for node in ast.walk(tree):
|
| 128 |
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
| 129 |
+
found.append((node.lineno, getattr(node, "end_lineno", node.lineno) or node.lineno, node.value))
|
| 130 |
+
return found
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def scan(path: Path):
|
| 134 |
+
relative = path.as_posix()
|
| 135 |
+
if relative in ALLOWED_FILES:
|
| 136 |
+
return []
|
| 137 |
+
try:
|
| 138 |
+
source = (REPO_ROOT / path).read_text(encoding="utf-8", errors="replace")
|
| 139 |
+
except OSError as exc:
|
| 140 |
+
print(f"WARNING: cannot read {path}: {exc}", file=sys.stderr)
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
lines = source.splitlines()
|
| 144 |
+
findings = []
|
| 145 |
+
for line_no, line in enumerate(lines, start=1):
|
| 146 |
+
if ESCAPE_MARKER in line:
|
| 147 |
+
continue
|
| 148 |
+
for rule, pattern, reason in LINE_PATTERNS:
|
| 149 |
+
if pattern.search(line):
|
| 150 |
+
findings.append(Finding(relative, line_no, rule, reason, line))
|
| 151 |
+
|
| 152 |
+
if path.suffix == ".py":
|
| 153 |
+
for first_line, last_line, value in string_literal_lines(source):
|
| 154 |
+
span = lines[first_line - 1:last_line]
|
| 155 |
+
if any(ESCAPE_MARKER in line for line in span):
|
| 156 |
+
continue
|
| 157 |
+
for value_line in value.splitlines() or [value]:
|
| 158 |
+
if URL_PATTERN.search(value_line):
|
| 159 |
+
continue
|
| 160 |
+
if ID_PATTERN.search(value_line):
|
| 161 |
+
excerpt = lines[first_line - 1] if first_line <= len(lines) else value_line
|
| 162 |
+
findings.append(Finding(relative, first_line, "possible-identifier", ID_REASON,
|
| 163 |
+
value_line if len(span) > 1 else excerpt))
|
| 164 |
+
break
|
| 165 |
+
else:
|
| 166 |
+
for line_no, line in enumerate(lines, start=1):
|
| 167 |
+
if ESCAPE_MARKER in line or URL_PATTERN.search(line):
|
| 168 |
+
continue
|
| 169 |
+
if ID_PATTERN.search(line):
|
| 170 |
+
findings.append(Finding(relative, line_no, "possible-identifier", ID_REASON, line))
|
| 171 |
+
|
| 172 |
+
return findings
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def main():
|
| 176 |
+
parser = argparse.ArgumentParser(description=__doc__,
|
| 177 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 178 |
+
parser.add_argument("files", nargs="*", help="files to scan; default is the whole repository")
|
| 179 |
+
parser.add_argument("-q", "--quiet", action="store_true", help="only print the summary")
|
| 180 |
+
args = parser.parse_args()
|
| 181 |
+
|
| 182 |
+
findings = []
|
| 183 |
+
scanned = 0
|
| 184 |
+
for path in iter_files(args.files):
|
| 185 |
+
scanned += 1
|
| 186 |
+
findings.extend(scan(path))
|
| 187 |
+
|
| 188 |
+
if findings and not args.quiet:
|
| 189 |
+
print("Found content that must not be published:\n")
|
| 190 |
+
for finding in findings:
|
| 191 |
+
print(f" {finding}")
|
| 192 |
+
print()
|
| 193 |
+
print("Fix by moving the value into your untracked config.yaml / jobs/site.env and")
|
| 194 |
+
print("reading it via auto_detect_breast_mri.config, or -- if the match is a false positive -- append")
|
| 195 |
+
print(f"'# {ESCAPE_MARKER}' to that line.")
|
| 196 |
+
|
| 197 |
+
print(f"{'FAIL' if findings else 'OK'}: {len(findings)} finding(s) in {scanned} scanned file(s).")
|
| 198 |
+
return 1 if findings else 0
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
sys.exit(main())
|
tools/check_split_fractions.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Verify the nesting guarantee of the training fraction files: every smaller fraction must be a
|
| 4 |
+
subset of every larger one, for every fold.
|
| 5 |
+
|
| 6 |
+
python tools/check_split_fractions.py [--split-root <folder>] [--folds 5]
|
| 7 |
+
|
| 8 |
+
The split root comes from the site config when it is not given (see config.example.yaml).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
from auto_detect_breast_mri.config import column, resolve_path
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def fraction_of(filename: str) -> float:
|
| 21 |
+
"""0.05 from '..._frac0.05.csv'."""
|
| 22 |
+
return float(filename.split("_frac")[-1].rsplit(".csv", 1)[0])
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def check_fold(fold_dir: Path, id_column: str, expected_files: int) -> bool:
|
| 26 |
+
"""True if every fraction file in fold_dir is nested in all larger ones."""
|
| 27 |
+
fraction_files = sorted(fold_dir.glob("*_frac0.*.csv"), key=lambda path: fraction_of(path.name))
|
| 28 |
+
if len(fraction_files) != expected_files:
|
| 29 |
+
print(f"WARNING: {fold_dir.name}: expected {expected_files} fraction files, "
|
| 30 |
+
f"found {len(fraction_files)}: {[path.name for path in fraction_files]}", file=sys.stderr)
|
| 31 |
+
ids = {fraction_of(path.name): set(pd.read_csv(path)[id_column]) for path in fraction_files}
|
| 32 |
+
|
| 33 |
+
ok = True
|
| 34 |
+
for child, child_ids in sorted(ids.items()):
|
| 35 |
+
for parent, parent_ids in sorted(ids.items()):
|
| 36 |
+
if parent <= child:
|
| 37 |
+
# a larger set can not be expected inside a smaller one; also skips parent == child
|
| 38 |
+
continue
|
| 39 |
+
missing = child_ids - parent_ids
|
| 40 |
+
if missing:
|
| 41 |
+
ok = False
|
| 42 |
+
print(f"ERROR: {fold_dir.name}: fraction {child} is NOT contained in {parent}: "
|
| 43 |
+
f"{len(missing)} entries missing from the parent.", file=sys.stderr)
|
| 44 |
+
else:
|
| 45 |
+
print(f" {fold_dir.name}: fraction {child} is fully covered by {parent}.")
|
| 46 |
+
return ok
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main() -> None:
|
| 50 |
+
parser = argparse.ArgumentParser(description=__doc__,
|
| 51 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 52 |
+
parser.add_argument("--split-root", default=None,
|
| 53 |
+
help="folder holding fold<k>/. Config key: split_root")
|
| 54 |
+
parser.add_argument("--folds", type=int, default=5, help="number of folds to check. Default: 5")
|
| 55 |
+
parser.add_argument("--expected-files", type=int, default=4,
|
| 56 |
+
help="fraction files expected per fold. Default: 4")
|
| 57 |
+
args = parser.parse_args()
|
| 58 |
+
|
| 59 |
+
split_root = Path(resolve_path(args.split_root, "split_root", "folder with the split files"))
|
| 60 |
+
if not split_root.exists():
|
| 61 |
+
sys.exit(f"ERROR: directory {split_root} does not exist.")
|
| 62 |
+
|
| 63 |
+
id_column = column("examination_id")
|
| 64 |
+
all_ok = True
|
| 65 |
+
for fold in range(args.folds):
|
| 66 |
+
fold_dir = split_root / f"fold{fold}"
|
| 67 |
+
if not fold_dir.exists():
|
| 68 |
+
print(f"WARNING: {fold_dir} does not exist, skipping.", file=sys.stderr)
|
| 69 |
+
continue
|
| 70 |
+
print(f"Investigating {fold_dir}")
|
| 71 |
+
all_ok &= check_fold(fold_dir, id_column, args.expected_files)
|
| 72 |
+
|
| 73 |
+
print("OK: all fractions are nested." if all_ok else "FAIL: nesting is violated, see above.")
|
| 74 |
+
sys.exit(0 if all_ok else 1)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
if __name__ == "__main__":
|
| 78 |
+
main()
|
tools/make_synthetic_dataset.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Build a small synthetic dataset so the repository can be run and tested without any patient data.
|
| 4 |
+
|
| 5 |
+
python tools/make_synthetic_dataset.py --out synthetic_data
|
| 6 |
+
export AIMRI_CONFIG=synthetic_data/config.yaml
|
| 7 |
+
python scripts/train/train.py resnet18_abrv -b 2 -n 1 -c 0 -d 0 -t -e
|
| 8 |
+
|
| 9 |
+
What is written (see README.md, "Data contract", for the meaning of every part):
|
| 10 |
+
|
| 11 |
+
<out>/nifti/ image root, both supported folder layouts side by side
|
| 12 |
+
<exam_id>/ bilateral layout: side is encoded in the file name
|
| 13 |
+
L-Dyn_0.nii.gz L-Sub_1.nii.gz L-Sub_2.nii.gz L-Sub_3.nii.gz L-Sub_4.nii.gz L-T2.nii.gz
|
| 14 |
+
R-Dyn_0.nii.gz ...
|
| 15 |
+
L-mask_breast_nn.nii.gz R-mask_breast_nn.nii.gz (only with --masks)
|
| 16 |
+
<exam_id>_left/ unilateral layout: side is the folder, 'Pre' means Dyn_0
|
| 17 |
+
Pre.nii.gz Post_1.nii.gz Sub_1.nii.gz Sub_2.nii.gz Sub_3.nii.gz Sub_4.nii.gz T2.nii.gz
|
| 18 |
+
<exam_id>_right/ ...
|
| 19 |
+
<out>/metadata.csv one row per examination, wide (per-side label and indication
|
| 20 |
+
columns)
|
| 21 |
+
<out>/split_files/fold<k>/ stratified_{training,evaluation,test}_set-f<k>[-<subfold>].csv
|
| 22 |
+
<out>/config.yaml a ready to use site config pointing at the above, including
|
| 23 |
+
the `columns:` block describing the generated CSVs
|
| 24 |
+
|
| 25 |
+
Column names and side values are not defined here: they are read from config.example.yaml, so a
|
| 26 |
+
renamed column has to be changed in that one file only.
|
| 27 |
+
|
| 28 |
+
Every file holds a single breast, the one its name or folder says: `L-*.nii.gz` and
|
| 29 |
+
`<exam_id>_left/*` are the left breast, `R-*.nii.gz` and `<exam_id>_right/*` the right one. Nothing
|
| 30 |
+
in the pipeline splits a volume into two halves, so a volume showing both breasts would be wrong.
|
| 31 |
+
|
| 32 |
+
The volumes are a coarse breast phantom: dark air, a bright chest wall slab at the posterior end
|
| 33 |
+
and one bright breast in front of it, plus a small lesion whose intensity correlates with the
|
| 34 |
+
label. The breast sits off the midline towards the patient's midline side, mirrored between left
|
| 35 |
+
and right, so the two sides are not identical volumes. That gives the cropping code the geometry it
|
| 36 |
+
looks for and lets a model overfit the volumes, so a training run produces a non-degenerate ROC.
|
| 37 |
+
They are not images of anything and contain no information about any person.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
import argparse
|
| 41 |
+
import sys
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
|
| 44 |
+
import numpy as np
|
| 45 |
+
|
| 46 |
+
# Canonical sequence names of the two layouts. Keep in sync with
|
| 47 |
+
# auto_detect_breast_mri/data/breast_mri_dataset.py (available_protocols / unilateral_mode_mapping).
|
| 48 |
+
BILATERAL_MODES = ["Dyn_0", "Sub_1", "Sub_2", "Sub_3", "Sub_4", "T2"]
|
| 49 |
+
UNILATERAL_FILES = ["Pre", "Post_1", "Sub_1", "Sub_2", "Sub_3", "Sub_4", "T2"]
|
| 50 |
+
MASK_NAME = "mask_breast_nn"
|
| 51 |
+
|
| 52 |
+
# The `columns:` block of config.example.yaml names the columns of every CSV written here and is
|
| 53 |
+
# copied into the generated config.yaml. It is deliberately the only place those names exist: this
|
| 54 |
+
# generator has no defaults of its own.
|
| 55 |
+
EXAMPLE_CONFIG = Path(__file__).resolve().parent.parent / "config.example.yaml"
|
| 56 |
+
|
| 57 |
+
# Column keys this generator needs in order to write its CSVs. The example config may list more
|
| 58 |
+
# (metadata_side is only used by the .xlsx metadata modes); all of them are copied to the output.
|
| 59 |
+
REQUIRED_COLUMN_KEYS = ("examination_id", "metadata_examination_id", "patient_id", "side",
|
| 60 |
+
"criterion", "criterion_left", "criterion_right",
|
| 61 |
+
"side_left", "side_right", "indication_left", "indication_right")
|
| 62 |
+
|
| 63 |
+
# Indication codes written into the metadata, so the per-indication subgroup analysis has something
|
| 64 |
+
# to group by. Deliberately unbalanced, like a real indication distribution: two large subgroups and
|
| 65 |
+
# a tail of small ones, one of which is too small to bootstrap.
|
| 66 |
+
INDICATION_CODES = (0, 0, 0, 1, 1, 1, 1, 2, 3, 6)
|
| 67 |
+
|
| 68 |
+
# Sides as this script talks about them internally. The configured values of columns.side_left and
|
| 69 |
+
# columns.side_right are used when writing them to a CSV, the folder/file layout uses these.
|
| 70 |
+
SIDES = ("left", "right")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def load_columns(example_config: Path = EXAMPLE_CONFIG) -> dict:
|
| 74 |
+
"""
|
| 75 |
+
Read the `columns:` block of config.example.yaml, the single definition of the column names.
|
| 76 |
+
|
| 77 |
+
The generated config.yaml repeats the block, so the synthetic dataset describes itself and does
|
| 78 |
+
not depend on DEFAULT_COLUMNS in auto_detect_breast_mri/config.py (which carries the original
|
| 79 |
+
UKA names and would not match these CSVs).
|
| 80 |
+
"""
|
| 81 |
+
try:
|
| 82 |
+
import yaml
|
| 83 |
+
except ImportError: # pragma: no cover - depends on the environment
|
| 84 |
+
sys.exit("ERROR: reading config.example.yaml needs PyYAML: pip install pyyaml")
|
| 85 |
+
if not example_config.exists():
|
| 86 |
+
sys.exit(f"ERROR: {example_config} not found; it defines the column names to write.")
|
| 87 |
+
|
| 88 |
+
values = yaml.safe_load(example_config.read_text()) or {}
|
| 89 |
+
columns = values.get("columns") if isinstance(values, dict) else None
|
| 90 |
+
if not isinstance(columns, dict):
|
| 91 |
+
sys.exit(f"ERROR: {example_config} has no 'columns:' mapping to take the column names from.")
|
| 92 |
+
|
| 93 |
+
missing = [key for key in REQUIRED_COLUMN_KEYS if not columns.get(key)]
|
| 94 |
+
if missing:
|
| 95 |
+
sys.exit(f"ERROR: 'columns:' in {example_config} is missing {', '.join(missing)}.")
|
| 96 |
+
for side in SIDES:
|
| 97 |
+
value = str(columns[f"side_{side}"])
|
| 98 |
+
if value.lower() != side:
|
| 99 |
+
sys.exit(f"ERROR: columns.side_{side} in {example_config} is '{value}', but the side of "
|
| 100 |
+
f"a synthetic case is encoded as '{side}' in the folder and file names. The "
|
| 101 |
+
f"loaders match the side column against those names case insensitively, so "
|
| 102 |
+
f"the value has to lower case to '{side}'.")
|
| 103 |
+
return {str(key): str(value) for key, value in columns.items() if value}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def write_nifti(path: Path, volume: np.ndarray) -> None:
|
| 107 |
+
"""Write one 3D volume as .nii.gz with an identity affine."""
|
| 108 |
+
try:
|
| 109 |
+
import nibabel as nib
|
| 110 |
+
except ImportError: # pragma: no cover - depends on the environment
|
| 111 |
+
sys.exit("ERROR: writing NIfTI files needs nibabel (a torchio dependency): pip install nibabel")
|
| 112 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
nib.save(nib.Nifti1Image(volume.astype(np.float32), affine=np.eye(4)), str(path))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def breast_phantom(shape, side: str = "left"):
|
| 117 |
+
"""
|
| 118 |
+
Geometry of one synthetic breast, as boolean masks over a (depth, height, width) volume.
|
| 119 |
+
|
| 120 |
+
A volume holds the single breast of `side`, exactly as the real data does: the side is carried
|
| 121 |
+
by the file name (`L-`/`R-`) or the folder name (`<exam_id>_left`), never by one half of a
|
| 122 |
+
two-breast volume. The axes follow the data contract of the pipeline, so a phantom is croppable
|
| 123 |
+
the same way a real examination is:
|
| 124 |
+
axis 0 (depth) slices
|
| 125 |
+
axis 1 (height) anterior (index 0, breast) to posterior (last index, chest wall);
|
| 126 |
+
this is the axis UNet_Crop crops along
|
| 127 |
+
axis 2 (width) the two sides are mirrored through the middle of this axis, so the left
|
| 128 |
+
and right volume of an examination are not the same array
|
| 129 |
+
|
| 130 |
+
:return: (body, breast, lesion) boolean arrays; `breast` is the half ellipsoid the breast mask
|
| 131 |
+
holds and the lesion sits inside it.
|
| 132 |
+
"""
|
| 133 |
+
depth, height, width = shape
|
| 134 |
+
z, y, x = np.ogrid[:depth, :height, :width]
|
| 135 |
+
|
| 136 |
+
# chest wall: a slab filling the posterior part of the volume
|
| 137 |
+
chest = y >= int(0.55 * height)
|
| 138 |
+
|
| 139 |
+
# one half ellipsoid, centred on the chest wall and reaching into the anterior air. It is
|
| 140 |
+
# off centre towards the patient's midline, and that offset is mirrored between the sides.
|
| 141 |
+
breast_y = 0.55 * height
|
| 142 |
+
breast_radius_y = 0.45 * height
|
| 143 |
+
breast_radius_x = 0.30 * width
|
| 144 |
+
breast_radius_z = 0.40 * depth
|
| 145 |
+
centre_z = (depth - 1) / 2
|
| 146 |
+
centre_x = 0.40 * width if side == "left" else 0.60 * width
|
| 147 |
+
ellipsoid = (((z - centre_z) / breast_radius_z) ** 2
|
| 148 |
+
+ ((y - breast_y) / breast_radius_y) ** 2
|
| 149 |
+
+ ((x - centre_x) / breast_radius_x) ** 2) <= 1.0
|
| 150 |
+
breast = ellipsoid & (y <= breast_y) # anterior half only, the rest is chest wall
|
| 151 |
+
|
| 152 |
+
# a small lesion inside the breast
|
| 153 |
+
lesion_radius = max(2.0, 0.05 * width)
|
| 154 |
+
lesion = (((z - centre_z) / max(1.0, 0.5 * lesion_radius)) ** 2
|
| 155 |
+
+ ((y - 0.35 * height) / lesion_radius) ** 2
|
| 156 |
+
+ ((x - centre_x) / lesion_radius) ** 2) <= 1.0
|
| 157 |
+
return chest | breast, breast, lesion
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def make_volume(rng: np.random.Generator, shape, label: int, side: str = "left",
|
| 161 |
+
blob: bool = True) -> np.ndarray:
|
| 162 |
+
"""
|
| 163 |
+
A coarse breast MRI phantom: dark air, a bright chest wall slab and the breast of `side`.
|
| 164 |
+
|
| 165 |
+
It is not an image of anything, it only has the large scale geometry the cropping code looks
|
| 166 |
+
for: air anterior of the breast to crop away, and the single breast the file stands for. The
|
| 167 |
+
lesion inside it is brighter for a positive label, so a model can still overfit the volumes.
|
| 168 |
+
"""
|
| 169 |
+
body, breast, lesion = breast_phantom(shape, side)
|
| 170 |
+
volume = rng.normal(loc=10.0, scale=3.0, size=shape) # air
|
| 171 |
+
volume[body] += 90.0 # chest wall and breast
|
| 172 |
+
volume[breast] += 30.0 # breast a little brighter than the chest
|
| 173 |
+
if blob:
|
| 174 |
+
volume[lesion] += 60.0 if label else 15.0
|
| 175 |
+
return volume
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def make_mask(shape, side: str = "left") -> np.ndarray:
|
| 179 |
+
"""The breast of `side` as a binary mask (1 inside, 0 outside)."""
|
| 180 |
+
_, breast, _ = breast_phantom(shape, side)
|
| 181 |
+
return breast.astype(np.float32)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def build_cases(n_examinations: int, first_id: int, n_unilateral: int):
|
| 185 |
+
"""
|
| 186 |
+
One entry per breast: (examination_id, patient_id, side, label, layout), side one of SIDES.
|
| 187 |
+
|
| 188 |
+
Two examinations share a patient ID, so the patient-grouped split has something to group.
|
| 189 |
+
"""
|
| 190 |
+
cases = []
|
| 191 |
+
for index in range(n_examinations):
|
| 192 |
+
examination_id = str(first_id + index)
|
| 193 |
+
patient_id = str(first_id + index // 2) # two examinations per patient
|
| 194 |
+
layout = "unilateral" if index >= n_examinations - n_unilateral else "bilateral"
|
| 195 |
+
for side_index, side in enumerate(SIDES):
|
| 196 |
+
# deterministic, roughly balanced labels
|
| 197 |
+
label = (index + side_index) % 2
|
| 198 |
+
cases.append((examination_id, patient_id, side, label, layout))
|
| 199 |
+
return cases
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def write_images(out_root: Path, cases, shape, with_masks: bool, seed: int) -> None:
|
| 203 |
+
rng = np.random.default_rng(seed)
|
| 204 |
+
nifti_root = out_root / "nifti"
|
| 205 |
+
for examination_id, _, side, label, layout in cases:
|
| 206 |
+
if layout == "bilateral":
|
| 207 |
+
prefix = "L" if side == "left" else "R"
|
| 208 |
+
folder = nifti_root / examination_id
|
| 209 |
+
for mode in BILATERAL_MODES:
|
| 210 |
+
write_nifti(folder / f"{prefix}-{mode}.nii.gz", make_volume(rng, shape, label, side))
|
| 211 |
+
if with_masks:
|
| 212 |
+
write_nifti(folder / f"{prefix}-{MASK_NAME}.nii.gz", make_mask(shape, side))
|
| 213 |
+
else:
|
| 214 |
+
folder = nifti_root / f"{examination_id}_{side}"
|
| 215 |
+
for name in UNILATERAL_FILES:
|
| 216 |
+
write_nifti(folder / f"{name}.nii.gz", make_volume(rng, shape, label, side))
|
| 217 |
+
if with_masks:
|
| 218 |
+
write_nifti(folder / f"{MASK_NAME}.nii.gz", make_mask(shape, side))
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def write_metadata(out_root: Path, cases, columns: dict) -> Path:
|
| 222 |
+
"""Wide metadata: one row per examination with a per-side label column."""
|
| 223 |
+
import pandas as pd
|
| 224 |
+
|
| 225 |
+
criterion_of_side = {side: columns[f"criterion_{side}"] for side in SIDES}
|
| 226 |
+
indication_of_side = {side: columns[f"indication_{side}"] for side in SIDES}
|
| 227 |
+
rows = {}
|
| 228 |
+
for index, (examination_id, patient_id, side, label, _) in enumerate(cases):
|
| 229 |
+
row = rows.setdefault(examination_id, {columns["metadata_examination_id"]: examination_id,
|
| 230 |
+
columns["examination_id"]: examination_id,
|
| 231 |
+
columns["patient_id"]: patient_id})
|
| 232 |
+
row[criterion_of_side[side]] = label
|
| 233 |
+
# deterministic, so a regenerated dataset gives the same subgroups
|
| 234 |
+
row[indication_of_side[side]] = float(INDICATION_CODES[index % len(INDICATION_CODES)])
|
| 235 |
+
path = out_root / "metadata.csv"
|
| 236 |
+
pd.DataFrame(list(rows.values())).to_csv(path, index=False)
|
| 237 |
+
return path
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def write_splits(out_root: Path, cases, columns: dict, n_folds: int, subfold: int) -> Path:
|
| 241 |
+
"""
|
| 242 |
+
Long split files, one row per breast, in the layout the loaders build their file names from:
|
| 243 |
+
<split_root>/fold<k>/stratified_training_set-f<k>-<subfold>.csv
|
| 244 |
+
<split_root>/fold<k>/stratified_evaluation_set-f<k>-<subfold>.csv
|
| 245 |
+
<split_root>/fold<k>/stratified_test_set-f<k>.csv
|
| 246 |
+
Split by patient, so no patient appears in two of the three files of a fold.
|
| 247 |
+
"""
|
| 248 |
+
import pandas as pd
|
| 249 |
+
|
| 250 |
+
split_root = out_root / "split_files"
|
| 251 |
+
# the examination ID has to stay the first column: the loaders rename it by position
|
| 252 |
+
frame = pd.DataFrame([{columns["examination_id"]: examination_id,
|
| 253 |
+
columns["patient_id"]: patient_id,
|
| 254 |
+
columns["side"]: columns[f"side_{side}"],
|
| 255 |
+
columns["criterion"]: label}
|
| 256 |
+
for examination_id, patient_id, side, label, _ in cases])
|
| 257 |
+
|
| 258 |
+
patients = sorted(frame[columns["patient_id"]].unique())
|
| 259 |
+
# Every patient is the test patient of exactly one fold: the folds take consecutive blocks, and
|
| 260 |
+
# the leftover of an uneven division is spread over the first folds. Rotating by a single
|
| 261 |
+
# patient instead would make the test sets of neighbouring folds overlap, which breaks the
|
| 262 |
+
# out-of-fold analyses (a woman would sit in several test folds).
|
| 263 |
+
block, leftover = divmod(len(patients), n_folds)
|
| 264 |
+
bounds = [0]
|
| 265 |
+
for fold in range(n_folds):
|
| 266 |
+
bounds.append(bounds[-1] + block + (1 if fold < leftover else 0))
|
| 267 |
+
for fold in range(n_folds):
|
| 268 |
+
test_patients = patients[bounds[fold]:bounds[fold + 1]]
|
| 269 |
+
rest = [patient for patient in patients if patient not in set(test_patients)]
|
| 270 |
+
n_eval = max(1, len(rest) // 4)
|
| 271 |
+
eval_patients, train_patients = rest[:n_eval], rest[n_eval:]
|
| 272 |
+
|
| 273 |
+
fold_dir = split_root / f"fold{fold}"
|
| 274 |
+
fold_dir.mkdir(parents=True, exist_ok=True)
|
| 275 |
+
for name, selected in (("training_set", train_patients),
|
| 276 |
+
("evaluation_set", eval_patients),
|
| 277 |
+
("test_set", test_patients)):
|
| 278 |
+
suffix = f"-f{fold}" if name == "test_set" else f"-f{fold}-{subfold}"
|
| 279 |
+
part = frame[frame[columns["patient_id"]].isin(selected)]
|
| 280 |
+
part.to_csv(fold_dir / f"stratified_{name}{suffix}.csv", index=False)
|
| 281 |
+
return split_root
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def write_config(out_root: Path, metadata_file: Path, split_root: Path, columns: dict) -> Path:
|
| 285 |
+
"""
|
| 286 |
+
A site config pointing at the generated data, usable as $AIMRI_CONFIG.
|
| 287 |
+
|
| 288 |
+
The `columns:` block is the one read from config.example.yaml, so the config names exactly the
|
| 289 |
+
columns that were written, whatever they are called there.
|
| 290 |
+
"""
|
| 291 |
+
path = out_root / "config.yaml"
|
| 292 |
+
lines = [
|
| 293 |
+
"# Generated by tools/make_synthetic_dataset.py -- synthetic data, safe to share.",
|
| 294 |
+
f"data_root: {(out_root / 'nifti').as_posix()}",
|
| 295 |
+
f"metadata_file: {metadata_file.as_posix()}",
|
| 296 |
+
f"split_root: {split_root.as_posix()}/",
|
| 297 |
+
f"output_root: {(out_root / 'outputs').as_posix()}",
|
| 298 |
+
f"model_root: {(out_root / 'outputs').as_posix()}",
|
| 299 |
+
"wandb_mode: disabled",
|
| 300 |
+
"",
|
| 301 |
+
"# Column names of the CSVs above, copied from config.example.yaml.",
|
| 302 |
+
"columns:",
|
| 303 |
+
]
|
| 304 |
+
lines += [f" {key}: {value}" for key, value in columns.items()]
|
| 305 |
+
path.write_text("\n".join(lines) + "\n")
|
| 306 |
+
(out_root / "outputs").mkdir(parents=True, exist_ok=True)
|
| 307 |
+
return path
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def build_parser():
|
| 311 |
+
parser = argparse.ArgumentParser(description=__doc__,
|
| 312 |
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 313 |
+
parser.add_argument("--out", default="synthetic_data", type=Path,
|
| 314 |
+
help="output folder. Default: synthetic_data (gitignored)")
|
| 315 |
+
parser.add_argument("--examinations", type=int, default=8,
|
| 316 |
+
help="number of examinations; each yields two breasts. Default: 8")
|
| 317 |
+
parser.add_argument("--unilateral", type=int, default=4,
|
| 318 |
+
help="how many of them use the unilateral folder layout. Default: 4")
|
| 319 |
+
parser.add_argument("--shape", type=int, nargs=3, default=(32, 512, 512),
|
| 320 |
+
metavar=("DEPTH", "HEIGHT", "WIDTH"),
|
| 321 |
+
help="volume shape. Default: 16 64 64 (small on purpose)")
|
| 322 |
+
parser.add_argument("--folds", type=int, default=2, help="number of outer folds. Default: 2")
|
| 323 |
+
parser.add_argument("--subfold", type=int, default=0, help="inner fold index. Default: 0")
|
| 324 |
+
parser.add_argument("--first-id", type=int, default=1000001, # allow-private-path
|
| 325 |
+
help="first synthetic examination ID. Default: %(default)s")
|
| 326 |
+
parser.add_argument("--example-config", type=Path, default=EXAMPLE_CONFIG,
|
| 327 |
+
help="config file to take the `columns:` block from. Default: %(default)s")
|
| 328 |
+
parser.add_argument("--masks", action="store_true",
|
| 329 |
+
help="also write breast masks, needed for the segmentation/cropping code")
|
| 330 |
+
parser.add_argument("--seed", type=int, default=0)
|
| 331 |
+
return parser
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def main():
|
| 335 |
+
args = build_parser().parse_args()
|
| 336 |
+
if args.unilateral > args.examinations:
|
| 337 |
+
sys.exit("ERROR: --unilateral cannot exceed --examinations.")
|
| 338 |
+
|
| 339 |
+
out_root = args.out.resolve()
|
| 340 |
+
columns = load_columns(args.example_config)
|
| 341 |
+
cases = build_cases(args.examinations, args.first_id, args.unilateral)
|
| 342 |
+
|
| 343 |
+
print(f"Writing {len(cases)} breasts of {args.examinations} examinations to {out_root}")
|
| 344 |
+
print(f" column names from {args.example_config}")
|
| 345 |
+
write_images(out_root, cases, tuple(args.shape), args.masks, args.seed)
|
| 346 |
+
metadata_file = write_metadata(out_root, cases, columns)
|
| 347 |
+
split_root = write_splits(out_root, cases, columns, args.folds, args.subfold)
|
| 348 |
+
config_file = write_config(out_root, metadata_file, split_root, columns)
|
| 349 |
+
|
| 350 |
+
print(f" images {out_root / 'nifti'}")
|
| 351 |
+
print(f" metadata {metadata_file}")
|
| 352 |
+
print(f" splits {split_root}")
|
| 353 |
+
print(f" config {config_file}")
|
| 354 |
+
print(f"\nUse it with:\n export AIMRI_CONFIG={config_file}")
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
main()
|