deboraJ23 commited on
Commit
64fd08f
·
verified ·
1 Parent(s): c4669d1

upload training_code

Browse files
.gitignore ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ #.idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
training_code/model/resnet.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ from monai.networks.nets import ResNet
4
+ from monai.networks.nets.resnet import ResNetBlock, ResNetBottleneck
5
+
6
+
7
+ class Resnet(nn.Module):
8
+
9
+ def __init__(self, model_name: str, num_classes: int, norm: str = "batch"):
10
+ super().__init__()
11
+
12
+ self.model_name = model_name
13
+ valid_model_names = ["resnet10", "resnet18", "resnet34", "resnet50", "resnet101", "resnet152", "resnet200"]
14
+ assert self.model_name in valid_model_names, f"Model name must be one of {valid_model_names}"
15
+
16
+ # Get default resnet parameters
17
+ # >> model_name: (block, layers, shortcut_type, bias_downsample)
18
+ resnet_params = {
19
+ "resnet10": ("basic", [1, 1, 1, 1], "B", False),
20
+ "resnet18": ("basic", [2, 2, 2, 2], "A", True),
21
+ "resnet34": ("basic", [3, 4, 6, 3], "A", True),
22
+ "resnet50": ("bottleneck", [3, 4, 6, 3], "B", False),
23
+ "resnet101": ("bottleneck", [3, 4, 23, 3], "B", False),
24
+ "resnet152": ("bottleneck", [3, 8, 36, 3], "B", False),
25
+ "resnet200": ("bottleneck", [3, 24, 36, 3], "B", False),
26
+ }
27
+ block, layers, shortcut_type, bias_downsample = resnet_params[self.model_name]
28
+ block = ResNetBlock if block == "basic" else ResNetBottleneck
29
+
30
+ # Get default norm parameters
31
+ norm_mapper = {
32
+ "group": ("group", {"num_groups": 8}),
33
+ "instance": ("instance", {"affine": True}),
34
+ "batch": "batch"
35
+ }
36
+ _norm = norm_mapper[norm]
37
+
38
+ # Create resnet model
39
+ self.net = ResNet(
40
+ block=block,
41
+ layers=layers,
42
+ block_inplanes=[64, 128, 256, 512],
43
+ spatial_dims=3,
44
+ n_input_channels=1,
45
+ num_classes=num_classes,
46
+ bias_downsample=bias_downsample,
47
+ shortcut_type=shortcut_type,
48
+ norm=_norm
49
+ )
50
+
51
+ return
52
+
53
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
54
+ return self.net(x)
training_code/train.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import pandas as pd
4
+ import numpy as np
5
+ from tqdm import tqdm
6
+ from pathlib import Path
7
+ import torch
8
+ import torch.nn as nn
9
+ import matplotlib
10
+ matplotlib.use("Agg")
11
+ import random
12
+ import warnings
13
+ warnings.filterwarnings("ignore", category=UserWarning, module="torchio.data.image")
14
+ import os
15
+ import gc
16
+
17
+ from torch.optim.lr_scheduler import CosineAnnealingLR
18
+ from torch.amp import GradScaler, autocast
19
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
20
+ torch.multiprocessing.set_sharing_strategy("file_system")
21
+
22
+ from model.resnet import Resnet
23
+ from utils.dataloader import prepare_loaders, prepare_batch_single_scan
24
+ from utils.metrics import compute_metrics_mc
25
+ from utils.plots import plot_training_progress_classification, plot_pred_summary_mc
26
+ from utils.utils import compute_class_weights
27
+
28
+
29
+ def collect_arguments():
30
+ """
31
+ Collects arguments from the command line.
32
+ """
33
+
34
+ parser = argparse.ArgumentParser()
35
+ parser.add_argument("--df", type=Path, required=True)
36
+ parser.add_argument("--savedir", type=Path, required=True)
37
+ parser.add_argument("--model_name", type=str, default="resnet18", required=True)
38
+ parser.add_argument("--optimizer", type=str, choices=["adamw", "sgd"], default="adamw", required=True)
39
+ parser.add_argument("--lr", type=float, default=1e-5, required=True)
40
+ parser.add_argument("--augment", action="store_true", default=False)
41
+ parser.add_argument("--accum_steps", type=int, default=4, required=True)
42
+ parser.add_argument("--batch_size", type=int, default=4, required=True)
43
+ parser.add_argument("--cnn_wd", type=float, default=1e-3, required=True)
44
+ parser.add_argument("--debug_subset", action="store_true", default=False)
45
+ parser.add_argument("--balanced_sampling", action="store_true", default=False)
46
+ parser.add_argument("--val_fold", type=int, default=4, required=True)
47
+ args = parser.parse_args()
48
+
49
+ assert args.df.exists(), "Dataset file does not exist"
50
+
51
+ return args.df, args.savedir, args.model_name, args.optimizer, args.lr, args.augment, args.accum_steps, args.batch_size, args.cnn_wd, args.debug_subset, args.balanced_sampling, args.val_fold
52
+
53
+
54
+ def main():
55
+ """
56
+ Baseline method that uses a simple resnet with a single scan as input.
57
+ """
58
+
59
+ df, savedir, model_name, optimizer_name, lr, do_augmentation, accum_steps, batch_size, cnn_wd, debug_subset, balanced_sampling, val_fold = collect_arguments()
60
+
61
+ run_id = ''.join([random.choice('0123456789abcdef') for _ in range(6)])
62
+ save_dir = Path(str(savedir).replace("run_id", run_id))
63
+ save_dir.mkdir(parents=True, exist_ok=True)
64
+ save_dir.joinpath("preds").mkdir(parents=True, exist_ok=True)
65
+ save_dir.joinpath("model_weights").mkdir(parents=True, exist_ok=True)
66
+ print(f"Saving results to {save_dir}")
67
+
68
+ # Load label file, only use odelia for now
69
+ df = pd.read_excel(df)
70
+ df = df[(df["scan_number"] == "mip") & (df["breast_label"] != "n.a.")]
71
+ df.to_excel(save_dir.joinpath("train_data.xlsx"), index=False)
72
+
73
+ # Get tio datasets and loaders
74
+ train_loader, val_loader, _, _ = prepare_loaders(
75
+ df=df,
76
+ do_augmentation=do_augmentation,
77
+ mode="training",
78
+ batch_size=batch_size,
79
+ all_scans=False,
80
+ max_num_scans=1,
81
+ finetune_label="breast_label",
82
+ debug_subset=debug_subset,
83
+ balanced_sampling=balanced_sampling,
84
+ num_classes=3,
85
+ val_fold=val_fold
86
+ )
87
+
88
+ # Loss function and optimizer.
89
+ n_epochs = 200
90
+ if not balanced_sampling:
91
+ class_weights = compute_class_weights(df=df, label="breast_label", dataset="odelia", all_scans=False)
92
+ criterion = nn.CrossEntropyLoss(weight=class_weights.half())
93
+ else:
94
+ criterion = nn.CrossEntropyLoss()
95
+
96
+ model = Resnet(model_name=model_name, num_classes=3, norm="batch")
97
+ model.to("cuda")
98
+
99
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=cnn_wd)
100
+ scheduler = CosineAnnealingLR(optimizer, T_max=n_epochs, eta_min=lr/10)
101
+
102
+ all_train_loss, all_val_loss = [], []
103
+ all_train_acc, all_val_acc = [], []
104
+ all_train_auc, all_val_auc = [], []
105
+
106
+ # Same some results for later debugging
107
+ training_config = {
108
+ "model_name": model_name,
109
+ "optimizer_name": optimizer_name,
110
+ "scheduler": "cosine",
111
+ "start_lr": lr,
112
+ "end_lr": lr/10,
113
+ "n_epochs": n_epochs,
114
+ "do_augmentation": do_augmentation,
115
+ "batch_size": batch_size,
116
+ "run_id": run_id,
117
+ "balanced_sampling": balanced_sampling,
118
+ "accum_steps": accum_steps,
119
+ "cnn_wd": cnn_wd,
120
+ "finetune_label": "breast_label",
121
+ "use_amp": True,
122
+ "mode": "training",
123
+ "norm": "batch",
124
+ "val_fold": val_fold
125
+ }
126
+ with open(save_dir.joinpath("training_config.json"), "w") as f:
127
+ json.dump(training_config, f, indent=4, sort_keys=True)
128
+
129
+ scaler = GradScaler("cuda")
130
+ for epoch in range(n_epochs):
131
+
132
+ model.train()
133
+
134
+ train_epoch_loss = 0
135
+ train_epoch_preds, train_epoch_probs, train_epoch_gt = [], [], []
136
+
137
+ for batch_idx, batch in enumerate(tqdm(train_loader, desc=f"Epoch {epoch+1}/{n_epochs}")):
138
+
139
+ # Forward pass
140
+ x, y = prepare_batch_single_scan(batch)
141
+
142
+ with autocast(device_type='cuda'):
143
+ y_pred = model(x)
144
+ train_loss = criterion(y_pred, y)
145
+ train_loss /= accum_steps
146
+
147
+ scaler.scale(train_loss).backward()
148
+
149
+ # Gradient accumulation step
150
+ if (batch_idx + 1) % accum_steps == 0:
151
+ scaler.step(optimizer)
152
+ scaler.update()
153
+ optimizer.zero_grad()
154
+
155
+ probs = torch.softmax(y_pred, dim=1).detach().cpu().numpy()
156
+ preds = np.argmax(probs, axis=1).tolist()
157
+ gts = y.detach().cpu().numpy().tolist()
158
+
159
+ train_epoch_probs.extend(probs.tolist())
160
+ train_epoch_preds.extend(preds)
161
+ train_epoch_gt.extend(gts)
162
+
163
+ train_epoch_loss += train_loss.item() * accum_steps
164
+
165
+ # Handle remaining gradients
166
+ if len(train_loader) % accum_steps != 0:
167
+ scaler.step(optimizer)
168
+ scaler.update()
169
+ optimizer.zero_grad()
170
+
171
+ all_train_loss.append(train_epoch_loss / len(train_loader.dataset))
172
+ train_metrics = compute_metrics_mc(labels=train_epoch_gt, preds=train_epoch_preds, probs=train_epoch_probs, num_classes=3)
173
+ all_train_acc.append(train_metrics["balanced_accuracy"])
174
+ all_train_auc.append(train_metrics["auc"])
175
+
176
+ # Validation
177
+ model.eval()
178
+ val_epoch_loss = 0
179
+ val_epoch_preds, val_epoch_probs, val_epoch_gt = [], [], []
180
+
181
+ with torch.no_grad():
182
+ for batch in tqdm(val_loader, desc=f"Epoch {epoch+1}/{n_epochs} - Val"):
183
+
184
+ x, y = prepare_batch_single_scan(batch)
185
+
186
+ with autocast(device_type='cuda'):
187
+ y_pred = model(x)
188
+ val_loss = criterion(y_pred, y)
189
+
190
+ probs = torch.softmax(y_pred, dim=1).detach().cpu().numpy()
191
+ preds = np.argmax(probs, axis=1).tolist()
192
+ gts = y.detach().cpu().numpy().tolist()
193
+
194
+ val_epoch_loss += val_loss.item()
195
+ val_epoch_probs.extend(probs.tolist())
196
+ val_epoch_preds.extend(preds)
197
+ val_epoch_gt.extend(gts)
198
+
199
+ all_val_loss.append(val_epoch_loss / len(val_loader.dataset))
200
+ val_metrics = compute_metrics_mc(labels=val_epoch_gt, preds=val_epoch_preds, probs=val_epoch_probs, num_classes=3)
201
+ all_val_acc.append(val_metrics["balanced_accuracy"])
202
+ all_val_auc.append(val_metrics["auc"])
203
+
204
+ scheduler.step()
205
+
206
+ # Update training progress
207
+ save_path = save_dir.joinpath(f"progress.png")
208
+ plot_training_progress_classification(
209
+ all_train_loss=all_train_loss,
210
+ all_val_loss=all_val_loss,
211
+ all_train_acc=all_train_acc,
212
+ all_val_acc=all_val_acc,
213
+ all_train_auc=all_train_auc,
214
+ all_val_auc=all_val_auc,
215
+ save_path=save_path
216
+ )
217
+
218
+ if all_val_auc[-1] == max(all_val_auc):
219
+ torch.save(model.state_dict(), save_dir.joinpath("model_weights", "best_model.pth"))
220
+
221
+ if epoch % 5 == 0:
222
+ torch.save(model.state_dict(), save_dir.joinpath("model_weights", f"epoch_{str(epoch).zfill(3)}_model.pth"))
223
+ plot_pred_summary_mc(
224
+ preds=train_epoch_preds,
225
+ probs=train_epoch_probs,
226
+ gts=train_epoch_gt,
227
+ n_classes=3,
228
+ save_path=save_dir.joinpath("preds", f"epoch_{str(epoch).zfill(3)}_train_preds.png")
229
+ )
230
+ plot_pred_summary_mc(
231
+ preds=val_epoch_preds,
232
+ probs=val_epoch_probs,
233
+ gts=val_epoch_gt,
234
+ n_classes=3,
235
+ save_path=save_dir.joinpath("preds", f"epoch_{str(epoch).zfill(3)}_val_preds.png")
236
+ )
237
+
238
+ metric_df = pd.DataFrame({
239
+ "epoch": list(range(1, epoch+2)),
240
+ "train_loss": all_train_loss,
241
+ "val_loss": all_val_loss,
242
+ "train_acc": all_train_acc,
243
+ "val_acc": all_val_acc,
244
+ "train_auc": all_train_auc,
245
+ "val_auc": all_val_auc
246
+ })
247
+ metric_df.to_excel(save_dir.joinpath("metrics.xlsx"), index=False)
248
+
249
+ print(f"Epoch {epoch+1}/{n_epochs}: > Train Loss: {all_train_loss[-1]:.4f} - Val Loss: {all_val_loss[-1]:.4f}")
250
+ print(f"Epoch {epoch+1}/{n_epochs}: > Train Acc: {all_train_acc[-1]:.4f} - Val Acc: {all_val_acc[-1]:.4f}")
251
+ print(f"Epoch {epoch+1}/{n_epochs}: > Train AUC: {all_train_auc[-1]:.4f} - Val AUC: {all_val_auc[-1]:.4f}\n")
252
+
253
+ # Clean up
254
+ gc.collect()
255
+ torch.cuda.empty_cache()
256
+
257
+ return
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
training_code/utils/dataloader.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ from tqdm import tqdm
4
+ from pathlib import Path
5
+ import torch
6
+ import torchio as tio
7
+ import SimpleITK as sitk
8
+ from typing import List, Tuple
9
+
10
+ from utils.transforms import get_image_transforms
11
+
12
+
13
+ def create_subjects_list(df: pd.DataFrame, all_scans: bool = False, finetune_label: str = None, dataset: str = "odelia", debug_subset: bool = False) -> List[tio.Subject]:
14
+ """
15
+ Create a list of subjects from a dataframe of file paths and labels.
16
+ """
17
+
18
+ subjects = []
19
+ patient_ids = df["patient_id"].unique().tolist()
20
+
21
+ # Sample 5 patients from each class in debug mode
22
+ if debug_subset:
23
+ if finetune_label is not None:
24
+ patient_ids = []
25
+ for cls in df[finetune_label].dropna().unique().tolist():
26
+ cls_patients = df[df[finetune_label] == cls]["patient_id"].unique()
27
+ n_select = min(5, len(cls_patients))
28
+ patient_ids.extend(np.random.choice(cls_patients, size=n_select, replace=False).tolist())
29
+ else:
30
+ patient_ids = patient_ids[:10]
31
+
32
+ if dataset == "odelia":
33
+ odelia_mapper = {"normal": 0, "benign": 1, "malignant": 2}
34
+
35
+ for patient_id in tqdm(patient_ids, total=len(patient_ids)):
36
+
37
+ pt_df = df[df["patient_id"] == patient_id]
38
+
39
+ # Get all breast volumes per patient
40
+ for side in pt_df["side"].unique().tolist():
41
+ side_df = pt_df[pt_df["side"] == side]
42
+ side_df = side_df.sort_values(by="filepath")
43
+
44
+ if finetune_label == "breast_label":
45
+ side_label = torch.tensor(odelia_mapper[side_df["breast_label"].values[0]], dtype=torch.float32)
46
+ else:
47
+ side_label = "n.a."
48
+
49
+ # Use a single scan per patient
50
+ if not all_scans:
51
+ side_images = [tio.ScalarImage(side_df["filepath"].values[0])]
52
+ side_image_keys = ["image_1"]
53
+
54
+ side_subject_dict = tio.Subject(name=f"{patient_id}_{side}", **{key: value for key, value in zip(side_image_keys, side_images)}, label=side_label)
55
+ subjects.append(side_subject_dict)
56
+
57
+ return subjects
58
+
59
+
60
+ def run_sanity_check_single_image_dataloader(dataloader: tio.data.SubjectsLoader, num_samples: int, save_dir: Path):
61
+ """
62
+ Sanity check the dataloader.
63
+ """
64
+
65
+ save_dir.mkdir(parents=True, exist_ok=True)
66
+
67
+ # Extract samples
68
+ examples = []
69
+
70
+ for batch in dataloader:
71
+ examples.extend(batch)
72
+ if len(examples) == num_samples:
73
+ break
74
+
75
+ # Save them to visually inspect train transforms
76
+ for example in examples:
77
+ image = sitk.GetImageFromArray(example["image_1"][tio.DATA].squeeze(0).numpy())
78
+ label = int(example["label"].numpy())
79
+ sitk.WriteImage(image, save_dir.joinpath(f"{example['name']}_(gt={label}).nii.gz"))
80
+
81
+ return
82
+
83
+
84
+ def create_balanced_sampler(subjects, num_classes: int = 3):
85
+ """
86
+ Create a balanced sampler to handle class imbalance.
87
+ """
88
+
89
+ labels = [subject["label"].item() for subject in subjects]
90
+ unique, counts = np.unique(labels, return_counts=True)
91
+
92
+ if len(unique) != num_classes:
93
+ print(f"WARNING: Number of classes ({len(unique)}) does not match expected number of classes ({num_classes}) in balanced sampler")
94
+
95
+ # Calculate weights for each sample
96
+ class_weights = {label: 1.0 / count for label, count in zip(unique, counts)}
97
+ sample_weights = [class_weights[label] for label in labels]
98
+
99
+ sampler = torch.utils.data.WeightedRandomSampler(
100
+ weights=sample_weights,
101
+ num_samples=len(sample_weights),
102
+ replacement=True
103
+ )
104
+
105
+ return sampler
106
+
107
+
108
+ def prepare_batch_single_scan(batch: List[tio.Subject]) -> Tuple[torch.Tensor, torch.Tensor]:
109
+ """
110
+ Helper function to prepare a batch of subjects for training.
111
+ """
112
+
113
+ x = batch["image_1"][tio.DATA].to(device='cuda', dtype=torch.float16, non_blocking=True)
114
+ y = batch["label"].to(device='cuda', dtype=torch.long, non_blocking=True)
115
+
116
+ return x, y
117
+
118
+
119
+ def prepare_loaders(
120
+ df: pd.DataFrame,
121
+ do_augmentation: bool,
122
+ mode: str,
123
+ batch_size: int,
124
+ all_scans: bool,
125
+ max_num_scans: int,
126
+ finetune_label: str = None,
127
+ debug_subset: bool = False,
128
+ balanced_sampling: bool = False,
129
+ num_classes: int = None,
130
+ val_fold: int = 0
131
+ ):
132
+ """
133
+ Helper function to prepare data loaders.
134
+ """
135
+
136
+ # Get transforms
137
+ train_transforms, val_transforms = get_image_transforms(do_augmentation=do_augmentation)
138
+
139
+ # Get data loaders
140
+ print(f"Preparing data loaders...")
141
+
142
+ all_folds = [0, 1, 2, 3, 4]
143
+ train_folds = [i for i in all_folds if i != val_fold]
144
+
145
+ train_subjects = create_subjects_list(df[df["fold"].isin(train_folds)], all_scans=all_scans, finetune_label=finetune_label, debug_subset=debug_subset)
146
+ train_subjects_names = [i["name"] for i in train_subjects]
147
+ train_dataset = tio.data.SubjectsDataset(train_subjects, transform=train_transforms, load_getitem=False)
148
+
149
+ if balanced_sampling and num_classes is not None:
150
+ train_sampler = create_balanced_sampler(train_subjects, num_classes=num_classes)
151
+ train_loader = tio.data.SubjectsLoader(train_dataset, batch_size=batch_size, sampler=train_sampler, num_workers=6, prefetch_factor=4, persistent_workers=True)
152
+ else:
153
+ train_loader = tio.data.SubjectsLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=6, prefetch_factor=4, persistent_workers=True)
154
+
155
+ val_subjects = create_subjects_list(df[df["fold"].isin([val_fold])], all_scans=all_scans, finetune_label=finetune_label, debug_subset=debug_subset)
156
+ val_dataset = tio.data.SubjectsDataset(val_subjects, transform=val_transforms, load_getitem=False)
157
+ val_loader = tio.data.SubjectsLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=6, prefetch_factor=4, persistent_workers=True)
158
+
159
+ # Sanity loader to fetch attention weights from some training and val cases
160
+ sanity_subjects = np.random.choice(train_subjects, size=5, replace=False).tolist() + np.random.choice(val_subjects, size=5, replace=False).tolist()
161
+ sanity_dataset = tio.data.SubjectsDataset(sanity_subjects, transform=train_transforms, load_getitem=False)
162
+ sanity_loader = tio.data.SubjectsLoader(sanity_dataset, batch_size=batch_size, shuffle=False, num_workers=6, prefetch_factor=4, persistent_workers=True)
163
+
164
+ return train_loader, val_loader, sanity_loader, train_subjects_names
165
+
training_code/utils/metrics.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from typing import Dict, Any
3
+ from sklearn.metrics import balanced_accuracy_score, roc_auc_score, roc_curve, confusion_matrix
4
+ from sklearn.preprocessing import label_binarize
5
+
6
+
7
+ def compute_metrics_bc(labels: np.ndarray, preds: np.ndarray, probs: np.ndarray) -> Dict[str, Any]:
8
+ """
9
+ Compute all relevant metrics for binary classification.
10
+
11
+ Args:
12
+ labels: Ground truth binary labels (0 or 1)
13
+ preds: Binary predictions (0 or 1)
14
+ probs: Prediction probabilities (0-1)
15
+
16
+ Returns:
17
+ Dictionary containing all calculated metrics
18
+ """
19
+
20
+ metrics = {}
21
+
22
+ # Balanced accuracy
23
+ metrics['balanced_accuracy'] = balanced_accuracy_score(labels, preds)
24
+
25
+ # AUC
26
+ metrics['auc'] = roc_auc_score(labels, probs)
27
+
28
+ # Confusion matrix for sensitivity and specificity
29
+ cm = confusion_matrix(labels, preds)
30
+ metrics['confusion_matrix'] = cm
31
+ tn, fp, fn, tp = cm.ravel()
32
+
33
+ # Sensitivity and specificity
34
+ metrics['sensitivity'] = tp / (tp + fn) if (tp + fn) > 0 else 0
35
+ metrics['specificity'] = tn / (tn + fp) if (tn + fp) > 0 else 0
36
+
37
+ # ROC curve data
38
+ metrics['roc_curve'] = roc_curve(labels, probs)
39
+
40
+ return metrics
41
+
42
+
43
+ def compute_metrics_mc(labels: np.ndarray, preds: np.ndarray, probs: np.ndarray, num_classes: int = 3) -> Dict[str, Any]:
44
+ """
45
+ Compute relevant metrics for multiclass classification (default: 3 classes).
46
+
47
+ Args:
48
+ labels: Ground-truth class indices with shape (N,), values in \{0, ..., num_classes-1\}
49
+ preds: Predicted class indices with shape (N,)
50
+ probs: Predicted class probabilities with shape (N, num_classes)
51
+ num_classes: Number of classes present in the problem (default 3)
52
+
53
+ Returns:
54
+ Dictionary mapping metric names to their computed values.
55
+ """
56
+
57
+ metrics = {}
58
+
59
+ # Normalize probabilities to handle floating point precision errors from mixed precision training
60
+ probs = np.array(probs)
61
+ probs = probs / probs.sum(axis=1, keepdims=True)
62
+ labels = np.array(labels)
63
+
64
+ # Overall balanced accuracy (macro-averaged recall)
65
+ metrics["balanced_accuracy"] = balanced_accuracy_score(labels, preds)
66
+
67
+ # Macro-averaged ROC-AUC using the one-vs-rest strategy
68
+ labels_auc = label_binarize(labels, classes=list(range(num_classes)))
69
+ metrics["auc"] = roc_auc_score(labels_auc, probs, average="micro")
70
+
71
+ # Confusion matrix (shape: num_classes × num_classes)
72
+ cm = confusion_matrix(labels, preds, labels=list(range(num_classes)))
73
+ metrics["confusion_matrix"] = cm
74
+
75
+ # Per-class sensitivity (recall) and specificity
76
+ sensitivities = []
77
+ specificities = []
78
+ total = cm.sum()
79
+ for c in range(num_classes):
80
+ tp = cm[c, c]
81
+ fn = cm[c, :].sum() - tp
82
+ fp = cm[:, c].sum() - tp
83
+ tn = total - (tp + fp + fn)
84
+
85
+ sens = tp / (tp + fn) if (tp + fn) > 0 else 0.0
86
+ spec = tn / (tn + fp) if (tn + fp) > 0 else 0.0
87
+
88
+ sensitivities.append(sens)
89
+ specificities.append(spec)
90
+
91
+ metrics["sensitivity"] = np.array(sensitivities)
92
+ metrics["specificity"] = np.array(specificities)
93
+
94
+ # ROC curve per class (fpr, tpr, thresholds for each class)
95
+ roc_curves = []
96
+ for c in range(num_classes):
97
+ try:
98
+ fpr, tpr, thresholds = roc_curve((np.array(labels) == c).astype(int), np.array(probs)[:, c])
99
+ roc_curves.append((fpr, tpr, thresholds))
100
+ except ValueError:
101
+ roc_curves.append((np.array([]), np.array([]), np.array([])))
102
+ metrics["roc_curve"] = roc_curves
103
+
104
+ return metrics
training_code/utils/plots.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import matplotlib.pyplot as plt
3
+ from sklearn.metrics import balanced_accuracy_score, roc_auc_score, roc_curve, confusion_matrix, auc
4
+
5
+
6
+ def moving_average(data, window_size=5):
7
+ """
8
+ Compute moving average over specified window size.
9
+ """
10
+
11
+ if len(data) < window_size:
12
+ return data
13
+
14
+ return np.convolve(data, np.ones(window_size)/window_size, mode='valid')
15
+
16
+
17
+ def plot_training_progress_classification(all_train_loss, all_val_loss, all_train_acc, all_val_acc,
18
+ all_train_auc, all_val_auc, save_path, window_size=5):
19
+ """
20
+ Plot training progress with both raw epoch-by-epoch data and moving averages.
21
+
22
+ Args:
23
+ all_train_loss: List of training losses per epoch
24
+ all_val_loss: List of validation losses per epoch
25
+ all_train_acc: List of training accuracies per epoch
26
+ all_val_acc: List of validation accuracies per epoch
27
+ all_train_auc: List of training AUCs per epoch
28
+ all_val_auc: List of validation AUCs per epoch
29
+ save_path: Path to save the plot
30
+ window_size: Window size for moving average (default: 5)
31
+ """
32
+
33
+ plt.figure(figsize=(10, 5))
34
+ epochs = np.arange(1, len(all_train_loss) + 1)
35
+
36
+ # Loss subplot
37
+ plt.subplot(1, 3, 1)
38
+ train_loss_line = plt.plot(epochs, all_train_loss, '--', alpha=0.6, label="Train Loss (raw)")[0]
39
+ val_loss_line = plt.plot(epochs, all_val_loss, '--', alpha=0.6, label="Val Loss (raw)")[0]
40
+ if len(all_train_loss) >= window_size:
41
+ ma_epochs = np.arange(window_size, len(all_train_loss) + 1)
42
+ plt.plot(ma_epochs, moving_average(all_train_loss, window_size), '-',
43
+ color=train_loss_line.get_color(), label=f"Train Loss (MA-{window_size})")
44
+ plt.plot(ma_epochs, moving_average(all_val_loss, window_size), '-',
45
+ color=val_loss_line.get_color(), label=f"Val Loss (MA-{window_size})")
46
+ plt.legend()
47
+ plt.xlabel("Epoch")
48
+ plt.ylabel("Loss")
49
+
50
+ # Accuracy subplot
51
+ plt.subplot(1, 3, 2)
52
+ train_acc_line = plt.plot(epochs, all_train_acc, '--', alpha=0.6, label="Train Acc (raw)")[0]
53
+ val_acc_line = plt.plot(epochs, all_val_acc, '--', alpha=0.6, label="Val Acc (raw)")[0]
54
+ if len(all_train_acc) >= window_size:
55
+ ma_epochs = np.arange(window_size, len(all_train_acc) + 1)
56
+ plt.plot(ma_epochs, moving_average(all_train_acc, window_size), '-',
57
+ color=train_acc_line.get_color(), label=f"Train Acc (MA-{window_size})")
58
+ plt.plot(ma_epochs, moving_average(all_val_acc, window_size), '-',
59
+ color=val_acc_line.get_color(), label=f"Val Acc (MA-{window_size})")
60
+ plt.legend()
61
+ plt.xlabel("Epoch")
62
+ plt.ylabel("Accuracy")
63
+
64
+ # AUC subplot
65
+ plt.subplot(1, 3, 3)
66
+ train_auc_line = plt.plot(epochs, all_train_auc, '--', alpha=0.6, label="Train AUC (raw)")[0]
67
+ val_auc_line = plt.plot(epochs, all_val_auc, '--', alpha=0.6, label="Val AUC (raw)")[0]
68
+ if len(all_train_auc) >= window_size:
69
+ ma_epochs = np.arange(window_size, len(all_train_auc) + 1)
70
+ plt.plot(ma_epochs, moving_average(all_train_auc, window_size), '-',
71
+ color=train_auc_line.get_color(), label=f"Train AUC (MA-{window_size})")
72
+ plt.plot(ma_epochs, moving_average(all_val_auc, window_size), '-',
73
+ color=val_auc_line.get_color(), label=f"Val AUC (MA-{window_size})")
74
+ plt.legend()
75
+ plt.xlabel("Epoch")
76
+ plt.ylabel("AUC")
77
+
78
+ plt.tight_layout()
79
+ plt.savefig(save_path)
80
+ plt.close()
81
+
82
+ return
83
+
84
+
85
+ def plot_pred_summary_bc(preds, probs, gts, save_path):
86
+ """
87
+ Plot predictions for binary classification with confusion matrix, performance metrics, and ROC curve.
88
+ """
89
+
90
+ probs = np.array(probs)
91
+ preds = np.array(preds)
92
+ gts = np.array(gts)
93
+
94
+ # Compute metrics
95
+ balanced_accuracy = balanced_accuracy_score(gts, preds)
96
+ auc = roc_auc_score(gts, probs)
97
+
98
+ # Confusion matrix
99
+ cm = confusion_matrix(gts, preds)
100
+ tn, fp, fn, tp = cm.ravel()
101
+
102
+ # Additional metrics
103
+ sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0
104
+ specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
105
+ ppv = tp / (tp + fp) if (tp + fp) > 0 else 0
106
+ npv = tn / (tn + fn) if (tn + fn) > 0 else 0
107
+
108
+ # ROC curve
109
+ fpr, tpr, _ = roc_curve(gts, probs[:, 1] if probs.ndim == 2 else probs)
110
+
111
+ # Create figure with 3 subplots
112
+ fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 5))
113
+
114
+ # Confusion matrix
115
+ im = ax1.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
116
+ ax1.set_title('Confusion Matrix')
117
+ ax1.set_xlabel('Predicted')
118
+ ax1.set_ylabel('Actual')
119
+ ax1.set_xticks([0, 1])
120
+ ax1.set_yticks([0, 1])
121
+ ax1.set_xticklabels(['Negative', 'Positive'])
122
+ ax1.set_yticklabels(['Negative', 'Positive'])
123
+
124
+ # Add text annotations
125
+ thresh = cm.max() / 2
126
+ for i in range(2):
127
+ for j in range(2):
128
+ ax1.text(j, i, format(cm[i, j], 'd'),
129
+ ha="center", va="center",
130
+ color="white" if cm[i, j] > thresh else "black")
131
+
132
+ # Performance metrics bar chart
133
+ metrics = ['Sensitivity', 'Specificity', 'PPV', 'NPV', 'Balanced Accuracy']
134
+ values = [sensitivity, specificity, ppv, npv, balanced_accuracy]
135
+ bars = ax2.bar(metrics, values, color=['skyblue', 'lightcoral', 'lightgreen', 'gold', 'lightpink'])
136
+ ax2.set_title('Performance Metrics')
137
+ ax2.set_ylabel('Value')
138
+ ax2.set_ylim(0, 1)
139
+
140
+ # Add value labels on bars
141
+ for bar, value in zip(bars, values):
142
+ height = bar.get_height()
143
+ ax2.text(bar.get_x() + bar.get_width()/2., height + 0.01,
144
+ f'{value:.3f}', ha='center', va='bottom')
145
+
146
+ # ROC curve
147
+ ax3.plot(fpr, tpr, color='darkorange', lw=2, label=f'ROC curve (AUC = {auc:.3f})')
148
+ ax3.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
149
+ ax3.set_xlim([0.0, 1.0])
150
+ ax3.set_ylim([0.0, 1.05])
151
+ ax3.set_xlabel('False Positive Rate')
152
+ ax3.set_ylabel('True Positive Rate')
153
+ ax3.set_title('ROC Curve')
154
+ ax3.legend(loc="lower right")
155
+ ax3.grid(True)
156
+
157
+ plt.tight_layout()
158
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
159
+ plt.close()
160
+
161
+ return
162
+
163
+
164
+ def plot_pred_summary_mc(preds, probs, gts, n_classes, save_path):
165
+ """
166
+ Plot predictions for multiclass classification with confusion matrix and performance metrics.
167
+ """
168
+
169
+ # Normalize probabilities to handle floating point precision errors from mixed precision training
170
+ probs = np.array(probs)
171
+ probs = probs / probs.sum(axis=1, keepdims=True)
172
+
173
+ preds = np.array(preds)
174
+ gts = np.array(gts)
175
+
176
+ # Compute metrics
177
+ balanced_accuracy = balanced_accuracy_score(gts, preds)
178
+ auc = roc_auc_score(gts, probs, multi_class='ovo', average='macro', labels=list(range(n_classes)))
179
+
180
+ # Confusion matrix
181
+ cm = confusion_matrix(gts, preds, labels=list(range(n_classes)))
182
+
183
+ # Create figure with 2 subplots for multiclass
184
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
185
+
186
+ # Confusion matrix
187
+ im = ax1.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
188
+ ax1.set_title('Confusion Matrix')
189
+ ax1.set_xlabel('Predicted Class')
190
+ ax1.set_ylabel('True Class')
191
+ ax1.set_xticks(range(n_classes))
192
+ ax1.set_yticks(range(n_classes))
193
+ ax1.set_xticklabels([f'Class {i}' for i in range(n_classes)])
194
+ ax1.set_yticklabels([f'Class {i}' for i in range(n_classes)])
195
+
196
+ # Add text annotations
197
+ thresh = cm.max() / 2
198
+ for i in range(n_classes):
199
+ for j in range(n_classes):
200
+ ax1.text(j, i, format(cm[i, j], 'd'),
201
+ ha="center", va="center",
202
+ color="white" if cm[i, j] > thresh else "black")
203
+
204
+ # Performance metrics bar chart
205
+ metrics = ['Balanced Accuracy', 'Macro AUC']
206
+ values = [balanced_accuracy, auc]
207
+ bars = ax2.bar(metrics, values, color=['lightpink', 'lightblue'])
208
+ ax2.set_title('Performance Metrics')
209
+ ax2.set_ylabel('Value')
210
+ ax2.set_ylim(0, 1)
211
+
212
+ # Add value labels on bars
213
+ for bar, value in zip(bars, values):
214
+ height = bar.get_height()
215
+ ax2.text(bar.get_x() + bar.get_width()/2., height + 0.01,
216
+ f'{value:.3f}', ha='center', va='bottom')
217
+
218
+ plt.tight_layout()
219
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
220
+ plt.close()
221
+
222
+ return
223
+
224
+
225
+ def plot_multiclass_roc_curve(roc_curve_data, save_path=None):
226
+ """
227
+ Plot the ROC curve for multiclass classification.
228
+ """
229
+
230
+ n_classes = len(roc_curve_data)
231
+ plt.figure(figsize=(n_classes * 5, 5))
232
+
233
+ for c, (fpr, tpr, _) in enumerate(roc_curve_data):
234
+ plt.subplot(1, n_classes, c + 1)
235
+ plt.plot(fpr, tpr, label=f'ROC curve of class {c}')
236
+ plt.plot([0, 1], [0, 1], color='navy', lw=2, linestyle='--')
237
+ plt.xlim([0.0, 1.0])
238
+ plt.ylim([0.0, 1.0])
239
+ auc_val = auc(fpr, tpr)
240
+ plt.legend([f"AUC = {auc_val:.2f}"])
241
+ plt.xlabel("False Positive Rate")
242
+ plt.ylabel("True Positive Rate")
243
+ plt.title(f"ROC Curve of Class {c}")
244
+ plt.grid(True)
245
+
246
+ plt.savefig(save_path, dpi=150, bbox_inches="tight")
247
+ plt.close()
248
+
249
+ return
250
+
251
+ def plot_multiclass_confusion_matrix(cm, save_path=None):
252
+ """
253
+ Plot the confusion matrix.
254
+ """
255
+
256
+ n_classes = cm.shape[0]
257
+ fig, ax1 = plt.subplots(1, 1, figsize=(n_classes * 3, n_classes * 3))
258
+
259
+ # Confusion matrix
260
+ im = ax1.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
261
+ ax1.set_title('Confusion Matrix')
262
+ ax1.set_xlabel('Predicted Class')
263
+ ax1.set_ylabel('True Class')
264
+ ax1.set_xticks(range(n_classes))
265
+ ax1.set_yticks(range(n_classes))
266
+ ax1.set_xticklabels([f'Class {i}' for i in range(n_classes)])
267
+ ax1.set_yticklabels([f'Class {i}' for i in range(n_classes)])
268
+
269
+ # Add text annotations
270
+ thresh = cm.max() / 2
271
+ for i in range(n_classes):
272
+ for j in range(n_classes):
273
+ ax1.text(j, i, format(cm[i, j], 'd'),
274
+ ha="center", va="center",
275
+ color="white" if cm[i, j] > thresh else "black")
276
+
277
+ plt.tight_layout()
278
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
279
+ plt.close()
280
+
281
+ return
282
+
training_code/utils/transforms.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torchio as tio
2
+ import numpy as np
3
+ import torch
4
+
5
+
6
+ # Helper function for multiprocessing-safe casting
7
+ def _to_float32(tensor: torch.Tensor) -> torch.Tensor:
8
+ """Cast TorchIO tensor to float32 (multiprocessing-safe)."""
9
+ return tensor.to(torch.float32)
10
+
11
+
12
+ class PadToMultiple(tio.Transform):
13
+ """
14
+ Pads a 3D image so that each spatial dimension is a multiple of a given number.
15
+ """
16
+ def __init__(self, multiple: int = 16, **kwargs):
17
+ super().__init__(**kwargs)
18
+ self.multiple = multiple
19
+
20
+ def apply_transform(self, subject: tio.Subject) -> tio.Subject:
21
+ image_keys = sorted([i for i in subject.keys() if i.startswith("image_")])
22
+ for image_key in image_keys:
23
+ image = subject[image_key]
24
+ shape = image.spatial_shape
25
+
26
+ target_shape = [int(np.ceil(dim / self.multiple)) * self.multiple for dim in shape]
27
+
28
+ pad_transform = tio.CropOrPad(
29
+ target_shape=tuple(target_shape), padding_mode=0
30
+ )
31
+ subject = pad_transform(subject)
32
+
33
+ return subject
34
+
35
+
36
+ def pad_fixed_size_collate_fn(batch):
37
+ """
38
+ Pads each image in the batch to the same size and returns a tensor of images and a tensor of labels.
39
+ Assumes that the subject only contains one image and one label.
40
+ """
41
+
42
+ # Find the maximum spatial dimensions in the batch
43
+ max_shape = np.max([item['image_1'][tio.DATA].shape[1:] for item in batch], axis=0)
44
+
45
+ # Make it a multiple of 16
46
+ target_shape = tuple([int(np.ceil(dim / 16)) * 16 for dim in max_shape])
47
+ pad_transform = tio.CropOrPad(
48
+ target_shape=target_shape, padding_mode=0
49
+ )
50
+
51
+ # Pad each image to the max shape
52
+ for c, item in enumerate(batch):
53
+ image_keys = sorted([i for i in item.keys() if i.startswith("image_")])
54
+ for image_key in image_keys:
55
+ image = item[image_key]
56
+ padded_image = pad_transform(image)
57
+ batch[c][image_key] = padded_image
58
+
59
+ return batch
60
+
61
+
62
+
63
+ def get_image_transforms(do_augmentation: bool = True):
64
+ """
65
+ Get the image transforms for the training and validation sets.
66
+ """
67
+
68
+ if do_augmentation:
69
+ train_transforms = tio.Compose([
70
+ tio.RandomFlip(axes=('LR', 'AP', 'IS'), flip_probability=0.5),
71
+ tio.RandomAffine(scales=(0.8, 1.2), degrees=45, translation=(15, 15, 15), p=0.3),
72
+ tio.RandomNoise(std=(0.01, 0.10), p=0.2),
73
+ # tio.RandomBiasField(coefficients=0.5, p=0.2),
74
+ # tio.RandomBlur(std=(0, 2), p=0.2),
75
+ tio.RandomGamma(log_gamma=(-0.3, 0.3), p=0.2),
76
+ tio.RandomSwap(patch_size=10, num_iterations=30, p=0.2)
77
+ ])
78
+ else:
79
+ train_transforms = tio.Compose([
80
+ tio.RandomFlip(axes=('LR', 'AP', 'IS'), flip_probability=0.5),
81
+ tio.RandomAffine(scales=(0.8, 1.2), degrees=45, translation=(15, 15, 15), p=0.3),
82
+ ])
83
+
84
+ # Add type transform to ensure image data loading for validation
85
+ val_transforms = tio.Compose([tio.Lambda(_to_float32)])
86
+
87
+ return train_transforms, val_transforms
training_code/utils/utils.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import pandas as pd
4
+
5
+
6
+ def get_optimizer(model, cnn_wd, transformer_wd, cnn_lr, transformer_lr, general_lr):
7
+ """
8
+ Helper function to set different weight decay for CNN and transformers.
9
+ """
10
+
11
+ # Figure out which parameters need decay
12
+ decay, no_decay = set(), set()
13
+ for mn, m in model.named_modules():
14
+ for pn, p in m.named_parameters(recurse=False):
15
+ fqn = f"{mn}.{pn}" if mn else pn
16
+ if isinstance(m, (nn.LayerNorm, nn.BatchNorm1d, nn.BatchNorm2d)) or pn.endswith("bias"):
17
+ no_decay.add(fqn)
18
+ else:
19
+ decay.add(fqn)
20
+
21
+ # Set different weight decay for CNN and transformers
22
+ param_dict = {pn: p for pn, p in model.named_parameters()}
23
+ optimizer = torch.optim.AdamW(
24
+ [
25
+ {"params": [param_dict[n] for n in sorted(decay) if "transformer" not in n], "weight_decay": cnn_wd, "lr": cnn_lr},
26
+ {"params": [param_dict[n] for n in sorted(decay) if "transformer" in n], "weight_decay": transformer_wd, "lr": transformer_lr},
27
+ {"params": [param_dict[n] for n in sorted(no_decay)], "weight_decay": 0.0, "lr": general_lr},
28
+ ]
29
+ )
30
+
31
+ return optimizer
32
+
33
+
34
+ def compute_class_weights(df: pd.DataFrame, label: str, dataset: str, all_scans: bool = False):
35
+ """
36
+ Compute the class weights for the given label.
37
+ """
38
+
39
+ # Make a copy to avoid SettingWithCopyWarning
40
+ df = df.copy()
41
+
42
+ # Map values to range [0, 2]
43
+ if dataset == "odelia":
44
+ odelia_mapper = {"normal": 0, "benign": 1, "malignant": 2}
45
+ df["breast_label"] = df["breast_label"].map(odelia_mapper)
46
+
47
+ # Only select a single scan per patient
48
+ if all_scans:
49
+ df = df[df["scan_number"] == 1]
50
+
51
+ # Get counts for how often each class occurs
52
+ class_counts = df[label].value_counts().sort_index()
53
+
54
+ # Account for multiclass and binary classification
55
+ if dataset == "odelia":
56
+ class_weights = class_counts.max() / class_counts
57
+ return torch.tensor(class_weights.values, dtype=torch.float32, device="cuda")
58
+ else:
59
+ class_weights = class_counts[0] / class_counts[1]
60
+ return torch.tensor(class_weights, dtype=torch.float32, device="cuda")