deboraJ23 commited on
Commit
e1aa2e9
·
verified ·
1 Parent(s): ffbb236
.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
- license: apache-2.0
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()