upload training_code
Browse files- .gitignore +216 -0
- training_code/model/resnet.py +54 -0
- training_code/train.py +261 -0
- training_code/utils/dataloader.py +165 -0
- training_code/utils/metrics.py +104 -0
- training_code/utils/plots.py +282 -0
- training_code/utils/transforms.py +87 -0
- training_code/utils/utils.py +60 -0
.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")
|