radiologist-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- radiologist/core/__init__.py +43 -0
- radiologist/core/callbacks/__init__.py +35 -0
- radiologist/core/callbacks/attribution.py +577 -0
- radiologist/core/callbacks/best_metric.py +90 -0
- radiologist/core/callbacks/onnx_export.py +98 -0
- radiologist/core/callbacks/wandb_summary.py +69 -0
- radiologist/core/configs/__init__.py +23 -0
- radiologist/core/configs/callbacks/attribution.yaml +8 -0
- radiologist/core/configs/callbacks/best_metric.yaml +5 -0
- radiologist/core/configs/callbacks/default.yaml +8 -0
- radiologist/core/configs/callbacks/lr_monitor.yaml +4 -0
- radiologist/core/configs/callbacks/model_checkpoint.yaml +16 -0
- radiologist/core/configs/callbacks/onnx_export.yaml +7 -0
- radiologist/core/configs/callbacks/wandb_summary.yaml +5 -0
- radiologist/core/configs/datamodule/default.yaml +56 -0
- radiologist/core/configs/debug/barebones.yaml +18 -0
- radiologist/core/configs/debug/base.yaml +31 -0
- radiologist/core/configs/debug/fast_dev_run.yaml +13 -0
- radiologist/core/configs/debug/limit.yaml +23 -0
- radiologist/core/configs/debug/overfit.yaml +20 -0
- radiologist/core/configs/eval.yaml +24 -0
- radiologist/core/configs/extras.yaml +5 -0
- radiologist/core/configs/hydra/default.yaml +9 -0
- radiologist/core/configs/loggers/wandb.yaml +17 -0
- radiologist/core/configs/module/loss/focal_loss.yaml +8 -0
- radiologist/core/configs/module/metric/fbeta_score.yaml +16 -0
- radiologist/core/configs/module/optimizer/adamw.yaml +8 -0
- radiologist/core/configs/module/resnet50.yaml +7 -0
- radiologist/core/configs/module/scheduler/sequential.yaml +8 -0
- radiologist/core/configs/paths.yaml +7 -0
- radiologist/core/configs/strategy/auto.yaml +2 -0
- radiologist/core/configs/train.yaml +28 -0
- radiologist/core/configs/trainer.yaml +14 -0
- radiologist/core/data/__init__.py +27 -0
- radiologist/core/data/datamodule.py +342 -0
- radiologist/core/data/dtypes.py +34 -0
- radiologist/core/data/shards.py +125 -0
- radiologist/core/losses.py +100 -0
- radiologist/core/module.py +333 -0
- radiologist/core/registry/__init__.py +27 -0
- radiologist/core/registry/export.py +190 -0
- radiologist/core/resume.py +91 -0
- radiologist/core/train.py +180 -0
- radiologist_core-0.1.0.dist-info/METADATA +268 -0
- radiologist_core-0.1.0.dist-info/RECORD +48 -0
- radiologist_core-0.1.0.dist-info/WHEEL +4 -0
- radiologist_core-0.1.0.dist-info/entry_points.txt +3 -0
- radiologist_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 @CedrickArmel
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
"""Training loop, datamodule, focal loss, and callbacks for the classifier."""
|
|
24
|
+
|
|
25
|
+
from radiologist.core.callbacks import (
|
|
26
|
+
AttributionCallback,
|
|
27
|
+
BestMetricCallback,
|
|
28
|
+
OnnxExportCallback,
|
|
29
|
+
WandbDefineSummaryCallback,
|
|
30
|
+
)
|
|
31
|
+
from radiologist.core.data import WebDatasetDataModule
|
|
32
|
+
from radiologist.core.losses import FocalLoss
|
|
33
|
+
from radiologist.core.module import LModule
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AttributionCallback",
|
|
37
|
+
"BestMetricCallback",
|
|
38
|
+
"FocalLoss",
|
|
39
|
+
"LModule",
|
|
40
|
+
"OnnxExportCallback",
|
|
41
|
+
"WandbDefineSummaryCallback",
|
|
42
|
+
"WebDatasetDataModule",
|
|
43
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 @CedrickArmel
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
"""Lightning callbacks: attribution, best-metric tracking, ONNX export, W&B."""
|
|
24
|
+
|
|
25
|
+
from radiologist.core.callbacks.attribution import AttributionCallback
|
|
26
|
+
from radiologist.core.callbacks.best_metric import BestMetricCallback
|
|
27
|
+
from radiologist.core.callbacks.onnx_export import OnnxExportCallback
|
|
28
|
+
from radiologist.core.callbacks.wandb_summary import WandbDefineSummaryCallback
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AttributionCallback",
|
|
32
|
+
"BestMetricCallback",
|
|
33
|
+
"OnnxExportCallback",
|
|
34
|
+
"WandbDefineSummaryCallback",
|
|
35
|
+
]
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) 2026 @CedrickArmel
|
|
4
|
+
#
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
#
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
# copies or substantial portions of the Software.
|
|
14
|
+
#
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
# SOFTWARE.
|
|
22
|
+
|
|
23
|
+
"""Score-CAM / Integrated Gradients attribution callback for Lightning."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
|
31
|
+
|
|
32
|
+
import lightning as L
|
|
33
|
+
import torch
|
|
34
|
+
import torchvision.utils as vutils # type: ignore[import-untyped]
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from captum.attr import ( # type: ignore[import-untyped]
|
|
38
|
+
IntegratedGradients,
|
|
39
|
+
LayerGradCam,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_CAPTUM_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
IntegratedGradients = None # type: ignore[assignment,misc]
|
|
45
|
+
LayerGradCam = None # type: ignore[assignment,misc]
|
|
46
|
+
_CAPTUM_AVAILABLE = False
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import wandb
|
|
50
|
+
except ImportError:
|
|
51
|
+
wandb = None # type: ignore[assignment]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _Panel:
|
|
56
|
+
"""One rendered sample panel and the metadata needed to save and log it.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
key: dataset key identifying the source sample.
|
|
60
|
+
image: rendered grid ``[3, H', W']`` from ``_build_sample_image``.
|
|
61
|
+
caption: human-readable W&B caption (epoch / key / true / pred).
|
|
62
|
+
filename: deterministic PNG filename (stage, epoch, key, true, pred).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
key: str
|
|
66
|
+
image: torch.Tensor
|
|
67
|
+
caption: str
|
|
68
|
+
filename: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AttributionCallback(L.Callback):
|
|
72
|
+
"""Writes GradCAM and Integrated-Gradients overlays for val/test batches.
|
|
73
|
+
|
|
74
|
+
Selects one sample per true class (preferring misclassified ones), computes
|
|
75
|
+
attributions toward every target class in a single batched call, and logs a
|
|
76
|
+
stacked panel image (one row per class).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
target_layer: dot-path resolved lazily against ``pl_module.net``
|
|
80
|
+
(e.g. ``"layer4.1.conv2"``).
|
|
81
|
+
every_n_val_epochs: run on validation epochs where
|
|
82
|
+
``epoch % every_n_val_epochs == 0``.
|
|
83
|
+
every_n_batches: run on batches where ``batch_idx % every_n_batches == 0``.
|
|
84
|
+
every_n_test_batches: override of ``every_n_batches`` for test stage.
|
|
85
|
+
output_subdir: sub-directory under ``trainer.log_dir`` for PNGs.
|
|
86
|
+
save_to_file: whether to save panel PNGs to disk.
|
|
87
|
+
ig_n_steps: number of IG quadrature steps. ``None`` (default) disables IG
|
|
88
|
+
entirely — IG requires this many forward+backward passes per
|
|
89
|
+
sample×class and dominates wall time.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
target_layer: str,
|
|
95
|
+
every_n_val_epochs: int = 1,
|
|
96
|
+
every_n_batches: int = 1,
|
|
97
|
+
every_n_test_batches: Optional[int] = None,
|
|
98
|
+
output_subdir: str = "attributions",
|
|
99
|
+
save_to_file: bool = True,
|
|
100
|
+
ig_n_steps: Optional[int] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Initialize the callback with attribution scheduling and output options."""
|
|
103
|
+
super().__init__()
|
|
104
|
+
self.target_layer = target_layer
|
|
105
|
+
self.every_n_val_epochs = every_n_val_epochs
|
|
106
|
+
self._every_n_batches = every_n_batches
|
|
107
|
+
self._every_n_test_batches = every_n_test_batches
|
|
108
|
+
self.output_subdir = output_subdir
|
|
109
|
+
self._save_to_file = save_to_file
|
|
110
|
+
self.ig_n_steps = ig_n_steps
|
|
111
|
+
|
|
112
|
+
def on_validation_batch_end(
|
|
113
|
+
self,
|
|
114
|
+
trainer: Any,
|
|
115
|
+
pl_module: Any,
|
|
116
|
+
outputs: Union[torch.Tensor, Mapping[str, Any], None],
|
|
117
|
+
batch: Dict[str, Any],
|
|
118
|
+
batch_idx: int,
|
|
119
|
+
dataloader_idx: int = 0,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Run attribution on eligible validation batches on the global-zero rank.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
trainer: the active ``lightning.Trainer``.
|
|
125
|
+
pl_module: the ``LightningModule`` being validated.
|
|
126
|
+
outputs: the module's output for this batch, expected to be the
|
|
127
|
+
raw logits tensor; skipped when ``None`` or not a tensor.
|
|
128
|
+
batch: the validation batch dict, must contain ``"input"``,
|
|
129
|
+
``"target"``, and ``"key"``.
|
|
130
|
+
batch_idx: index of the current batch within the epoch.
|
|
131
|
+
dataloader_idx: index of the dataloader, for multi-loader setups.
|
|
132
|
+
"""
|
|
133
|
+
if (
|
|
134
|
+
not trainer.is_global_zero
|
|
135
|
+
or batch_idx % self._every_n_batches != 0
|
|
136
|
+
or trainer.current_epoch % self.every_n_val_epochs != 0
|
|
137
|
+
):
|
|
138
|
+
return
|
|
139
|
+
self._run_attribution(trainer, pl_module, outputs, batch, stage="val")
|
|
140
|
+
|
|
141
|
+
def on_test_batch_end(
|
|
142
|
+
self,
|
|
143
|
+
trainer: Any,
|
|
144
|
+
pl_module: Any,
|
|
145
|
+
outputs: Union[torch.Tensor, Mapping[str, Any], None],
|
|
146
|
+
batch: Dict[str, Any],
|
|
147
|
+
batch_idx: int,
|
|
148
|
+
dataloader_idx: int = 0,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Run attribution on eligible test batches on the global-zero rank.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
trainer: the active ``lightning.Trainer``.
|
|
154
|
+
pl_module: the ``LightningModule`` being tested.
|
|
155
|
+
outputs: the module's output for this batch, expected to be the
|
|
156
|
+
raw logits tensor; skipped when ``None`` or not a tensor.
|
|
157
|
+
batch: the test batch dict, must contain ``"input"``, ``"target"``,
|
|
158
|
+
and ``"key"``.
|
|
159
|
+
batch_idx: index of the current batch within the epoch.
|
|
160
|
+
dataloader_idx: index of the dataloader, for multi-loader setups.
|
|
161
|
+
"""
|
|
162
|
+
if (
|
|
163
|
+
not trainer.is_global_zero
|
|
164
|
+
or batch_idx % (self._every_n_test_batches or self._every_n_batches) != 0
|
|
165
|
+
):
|
|
166
|
+
return
|
|
167
|
+
self._run_attribution(trainer, pl_module, outputs, batch, stage="test")
|
|
168
|
+
|
|
169
|
+
def _run_attribution(
|
|
170
|
+
self,
|
|
171
|
+
trainer: Any,
|
|
172
|
+
pl_module: Any,
|
|
173
|
+
outputs: Union[torch.Tensor, Mapping[str, Any], None],
|
|
174
|
+
batch: Dict[str, Any],
|
|
175
|
+
stage: str,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Orchestrate: guard → select → batch → attribute → build → save → log."""
|
|
178
|
+
if not _CAPTUM_AVAILABLE:
|
|
179
|
+
return
|
|
180
|
+
log_dir = trainer.log_dir or trainer.default_root_dir
|
|
181
|
+
if log_dir is None:
|
|
182
|
+
return
|
|
183
|
+
if outputs is None or not isinstance(outputs, torch.Tensor):
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
inputs = batch["input"]
|
|
187
|
+
targets_true = batch["target"]
|
|
188
|
+
K = outputs.shape[1]
|
|
189
|
+
preds = outputs.argmax(dim=1)
|
|
190
|
+
|
|
191
|
+
selected = self._select_indices(targets_true, preds, K)
|
|
192
|
+
if not selected:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
layer = self._resolve_layer(pl_module.net)
|
|
196
|
+
out_dir = self._resolve_out_dir(trainer.log_dir or trainer.default_root_dir)
|
|
197
|
+
inputs_rep, targets_rep = self._prepare_batched_inputs(inputs, selected, K)
|
|
198
|
+
|
|
199
|
+
was_training = pl_module.net.training
|
|
200
|
+
pl_module.net.train(mode=False)
|
|
201
|
+
try:
|
|
202
|
+
gc_all, ig_all = self._compute_attributions(
|
|
203
|
+
pl_module.net, layer, inputs_rep, targets_rep
|
|
204
|
+
)
|
|
205
|
+
finally:
|
|
206
|
+
pl_module.net.train(mode=was_training)
|
|
207
|
+
|
|
208
|
+
panels = self._build_panels(
|
|
209
|
+
inputs,
|
|
210
|
+
selected,
|
|
211
|
+
batch["key"],
|
|
212
|
+
targets_true,
|
|
213
|
+
preds,
|
|
214
|
+
gc_all,
|
|
215
|
+
ig_all,
|
|
216
|
+
K,
|
|
217
|
+
trainer.current_epoch,
|
|
218
|
+
stage,
|
|
219
|
+
)
|
|
220
|
+
for panel in panels:
|
|
221
|
+
self._save_panel(panel, out_dir)
|
|
222
|
+
self._log_gallery(
|
|
223
|
+
panels, stage, step=trainer.global_step if stage == "val" else None
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _compute_attributions(
|
|
227
|
+
self,
|
|
228
|
+
net: torch.nn.Module,
|
|
229
|
+
layer: torch.nn.Module,
|
|
230
|
+
inputs_rep: torch.Tensor,
|
|
231
|
+
targets_rep: torch.Tensor,
|
|
232
|
+
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
|
|
233
|
+
"""Run GradCAM (and IG when enabled) over the batched inputs.
|
|
234
|
+
|
|
235
|
+
Exits ``inference_mode`` and re-enables grad so captum can backprop.
|
|
236
|
+
Clones both tensors before attribution — they may be inference tensors
|
|
237
|
+
when called from the test loop, and captum uses targets inside
|
|
238
|
+
``torch.gather`` on an autograd-tracked output.
|
|
239
|
+
|
|
240
|
+
Does not toggle ``net.train`` — the caller owns that lifecycle.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
net: the module under attribution.
|
|
244
|
+
layer: target conv layer resolved via ``_resolve_layer``.
|
|
245
|
+
inputs_rep: batched inputs ``[S*K, C, H, W]``.
|
|
246
|
+
targets_rep: per-row target classes ``[S*K]``.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
``(gc_all, ig_all)``. ``gc_all`` is ``[S*K, 1, h, w]``;
|
|
250
|
+
``ig_all`` is ``[S*K, C, H, W]`` or ``None`` when IG is disabled.
|
|
251
|
+
"""
|
|
252
|
+
gc = LayerGradCam(net, layer)
|
|
253
|
+
ig = IntegratedGradients(net) if self.ig_n_steps is not None else None
|
|
254
|
+
|
|
255
|
+
with torch.inference_mode(False), torch.enable_grad():
|
|
256
|
+
inputs_attr = inputs_rep.clone()
|
|
257
|
+
targets_attr = targets_rep.clone()
|
|
258
|
+
gc_all = gc.attribute(inputs_attr, target=targets_attr) # [S*K, 1, h, w]
|
|
259
|
+
ig_all = (
|
|
260
|
+
ig.attribute(
|
|
261
|
+
inputs_attr, target=targets_attr, n_steps=self.ig_n_steps
|
|
262
|
+
) # [S*K, C, H, W]
|
|
263
|
+
if ig is not None
|
|
264
|
+
else None
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return gc_all, ig_all
|
|
268
|
+
|
|
269
|
+
def _resolve_layer(self, net: torch.nn.Module) -> torch.nn.Module:
|
|
270
|
+
"""Resolve ``self.target_layer`` dot-path against ``net``."""
|
|
271
|
+
return functools.reduce(getattr, self.target_layer.split("."), net)
|
|
272
|
+
|
|
273
|
+
def _resolve_out_dir(self, log_dir: str) -> Path:
|
|
274
|
+
p = Path(log_dir) / self.output_subdir
|
|
275
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
return p
|
|
277
|
+
|
|
278
|
+
def _select_indices(
|
|
279
|
+
self,
|
|
280
|
+
targets_true: torch.Tensor,
|
|
281
|
+
preds: torch.Tensor,
|
|
282
|
+
K: int,
|
|
283
|
+
) -> List[int]:
|
|
284
|
+
"""Return one batch index per class, preferring misclassified samples.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
targets_true: ground-truth class indices, shape ``[N]``.
|
|
288
|
+
preds: predicted class indices (``outputs.argmax``), shape ``[N]``.
|
|
289
|
+
K: total number of classes (``outputs.shape[1]``).
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of at most ``K`` indices into the batch (one per present class).
|
|
293
|
+
Classes absent from this batch are skipped.
|
|
294
|
+
"""
|
|
295
|
+
selected: List[int] = []
|
|
296
|
+
for c in range(K):
|
|
297
|
+
in_class = (targets_true == c).nonzero(as_tuple=True)[0]
|
|
298
|
+
if in_class.numel() == 0:
|
|
299
|
+
continue
|
|
300
|
+
# prefer first misclassified; fall back to first correct
|
|
301
|
+
wrong = [idx.item() for idx in in_class if preds[idx] != c]
|
|
302
|
+
selected.append(int(wrong[0] if wrong else in_class[0].item()))
|
|
303
|
+
return selected
|
|
304
|
+
|
|
305
|
+
def _prepare_batched_inputs(
|
|
306
|
+
self,
|
|
307
|
+
inputs: torch.Tensor,
|
|
308
|
+
selected: List[int],
|
|
309
|
+
K: int,
|
|
310
|
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
311
|
+
"""Build the captum batch: each selected sample repeated once per class.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
inputs: full batch inputs ``[N, C, H, W]``.
|
|
315
|
+
selected: batch indices chosen by ``_select_indices``.
|
|
316
|
+
K: number of target classes.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
``(inputs_rep, targets_rep)`` where ``inputs_rep`` is
|
|
320
|
+
``[S*K, C, H, W]`` (each sample repeat-interleaved K times) and
|
|
321
|
+
``targets_rep`` is ``[S*K]`` cycling ``range(K)`` per sample.
|
|
322
|
+
"""
|
|
323
|
+
inputs_rep = inputs[selected].repeat_interleave(K, dim=0)
|
|
324
|
+
targets_rep = torch.arange(K, device=inputs.device).repeat(len(selected))
|
|
325
|
+
return inputs_rep, targets_rep
|
|
326
|
+
|
|
327
|
+
def _denorm_input(self, x: torch.Tensor) -> torch.Tensor:
|
|
328
|
+
"""Min-max normalise [1,C,H,W] input to [3,H,W] float32 in [0,1]."""
|
|
329
|
+
t = x.detach().cpu().float()[0] # [C, H, W]
|
|
330
|
+
if t.shape[0] == 1:
|
|
331
|
+
t = t.repeat(3, 1, 1)
|
|
332
|
+
lo, hi = t.min(), t.max()
|
|
333
|
+
if (hi - lo).abs() > 1e-8:
|
|
334
|
+
t = (t - lo) / (hi - lo)
|
|
335
|
+
return t
|
|
336
|
+
|
|
337
|
+
@torch.no_grad()
|
|
338
|
+
def _attr_to_2d(self, attr: torch.Tensor, H: int, W: int) -> torch.Tensor:
|
|
339
|
+
"""Reduce [K,C,h,w] attribution to [K,1,H,W] normalised per-map in [0,1].
|
|
340
|
+
|
|
341
|
+
Handles both low-res GradCAM maps (upsampled) and full-res IG maps
|
|
342
|
+
(channel-reduced via absolute mean).
|
|
343
|
+
"""
|
|
344
|
+
import torch.nn.functional as F
|
|
345
|
+
|
|
346
|
+
a = attr.detach().cpu().float()
|
|
347
|
+
a = a.abs().mean(dim=1, keepdim=True) if a.shape[1] > 1 else a.abs()
|
|
348
|
+
if a.shape[2] != H or a.shape[3] != W:
|
|
349
|
+
a = F.interpolate(a, size=(H, W), mode="bilinear", align_corners=False)
|
|
350
|
+
flat = a.view(a.shape[0], -1)
|
|
351
|
+
lo = flat.min(dim=1).values.view(-1, 1, 1, 1)
|
|
352
|
+
hi = flat.max(dim=1).values.view(-1, 1, 1, 1)
|
|
353
|
+
return torch.where((hi - lo).abs() > 1e-8, (a - lo) / (hi - lo), a) # [K,1,H,W]
|
|
354
|
+
|
|
355
|
+
@torch.no_grad()
|
|
356
|
+
def _heatmap(self, attr_2d: torch.Tensor) -> torch.Tensor:
|
|
357
|
+
"""Apply jet colormap to [K,1,H,W] attribution, return [K,3,H,W] in [0,1]."""
|
|
358
|
+
import numpy as np
|
|
359
|
+
|
|
360
|
+
a = attr_2d[:, 0].numpy() # [K, H, W]
|
|
361
|
+
try:
|
|
362
|
+
from matplotlib import colormaps # type: ignore[import-untyped]
|
|
363
|
+
|
|
364
|
+
rgb = colormaps["jet"](a)[..., :3].astype("float32") # [K, H, W, 3]
|
|
365
|
+
except Exception:
|
|
366
|
+
rgb = np.stack([a, np.zeros_like(a), 1.0 - a], axis=-1).astype("float32")
|
|
367
|
+
return torch.from_numpy(rgb).permute(0, 3, 1, 2) # [K, 3, H, W]
|
|
368
|
+
|
|
369
|
+
def _overlay(
|
|
370
|
+
self, base: torch.Tensor, heat: torch.Tensor, alpha: float = 0.5
|
|
371
|
+
) -> torch.Tensor:
|
|
372
|
+
"""Alpha-blend heatmap over base image — both [K,3,H,W] or [3,H,W]."""
|
|
373
|
+
return (alpha * heat + (1.0 - alpha) * base).clamp(0.0, 1.0)
|
|
374
|
+
|
|
375
|
+
def _annotate(
|
|
376
|
+
self,
|
|
377
|
+
img: torch.Tensor,
|
|
378
|
+
text: str,
|
|
379
|
+
color: Tuple[int, int, int] = (255, 255, 255),
|
|
380
|
+
) -> torch.Tensor:
|
|
381
|
+
"""Burn ``text`` onto a ``[3, H, W]`` float32 tensor in [0,1].
|
|
382
|
+
|
|
383
|
+
Draws a 1-px black shadow then the text in ``color`` for legibility on
|
|
384
|
+
any background. Falls back to the unmodified image when PIL is absent.
|
|
385
|
+
"""
|
|
386
|
+
try:
|
|
387
|
+
import numpy as np
|
|
388
|
+
from PIL import Image, ImageDraw # type: ignore[import-untyped]
|
|
389
|
+
|
|
390
|
+
arr = (img.permute(1, 2, 0).numpy() * 255).astype("uint8")
|
|
391
|
+
pil = Image.fromarray(arr)
|
|
392
|
+
draw = ImageDraw.Draw(pil)
|
|
393
|
+
draw.text((2, 2), text, fill=(0, 0, 0))
|
|
394
|
+
draw.text((1, 1), text, fill=color)
|
|
395
|
+
return torch.from_numpy(np.array(pil)).permute(2, 0, 1).float() / 255.0
|
|
396
|
+
except Exception:
|
|
397
|
+
return img
|
|
398
|
+
|
|
399
|
+
def _colorbar(self, H: int, W: int = 32) -> torch.Tensor:
|
|
400
|
+
"""Vertical jet colorbar strip ``[3, H, W]`` mapping 1→0 (top→bottom).
|
|
401
|
+
|
|
402
|
+
Annotates "1.0" at the top and "0.0" at the bottom. Falls back to a
|
|
403
|
+
simple red→blue ramp when matplotlib is absent; skips text when PIL is absent.
|
|
404
|
+
"""
|
|
405
|
+
import numpy as np
|
|
406
|
+
|
|
407
|
+
vals = np.linspace(1.0, 0.0, H, dtype="float32")
|
|
408
|
+
try:
|
|
409
|
+
from matplotlib import colormaps # type: ignore[import-untyped]
|
|
410
|
+
|
|
411
|
+
rgb = colormaps["jet"](vals)[:, :3] # [H, 3]
|
|
412
|
+
except Exception:
|
|
413
|
+
rgb = np.stack([vals, np.zeros(H, "float32"), 1.0 - vals], axis=-1)
|
|
414
|
+
strip = np.broadcast_to(rgb[:, None, :], (H, W, 3)).copy()
|
|
415
|
+
tensor = torch.from_numpy(strip).permute(2, 0, 1) # [3, H, W]
|
|
416
|
+
try:
|
|
417
|
+
from PIL import Image, ImageDraw # type: ignore[import-untyped]
|
|
418
|
+
|
|
419
|
+
arr = (tensor.permute(1, 2, 0).numpy() * 255).astype("uint8")
|
|
420
|
+
pil = Image.fromarray(arr)
|
|
421
|
+
draw = ImageDraw.Draw(pil)
|
|
422
|
+
for y, label in [(1, "1.0"), (H - 10, "0.0")]:
|
|
423
|
+
draw.text((1, y), label, fill=(0, 0, 0))
|
|
424
|
+
draw.text((0, y - 1), label, fill=(255, 255, 255))
|
|
425
|
+
tensor = torch.from_numpy(np.array(pil)).permute(2, 0, 1).float() / 255.0
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
return tensor
|
|
429
|
+
|
|
430
|
+
@torch.no_grad()
|
|
431
|
+
def _build_sample_image(
|
|
432
|
+
self,
|
|
433
|
+
x: torch.Tensor,
|
|
434
|
+
gc_K: torch.Tensor,
|
|
435
|
+
tc: int,
|
|
436
|
+
pc: int,
|
|
437
|
+
ig_K: Optional[torch.Tensor] = None,
|
|
438
|
+
) -> torch.Tensor:
|
|
439
|
+
"""Build a single-row panel for one sample.
|
|
440
|
+
|
|
441
|
+
Layout: ``[orig | GC-ov×K | (IG-ov×K if enabled)]`` + colorbar strip.
|
|
442
|
+
The original is shown once (annotated with true/pred); each overlay is
|
|
443
|
+
annotated with its target class. Correct predictions use green text,
|
|
444
|
+
wrong predictions use red.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
x: original input ``[1, C, H, W]``.
|
|
448
|
+
gc_K: GradCAM attributions for all K target classes ``[K, 1, h, w]``.
|
|
449
|
+
tc: ground-truth class index for this sample.
|
|
450
|
+
pc: predicted class index for this sample.
|
|
451
|
+
ig_K: optional IG attributions ``[K, C, H, W]``; omitted when IG is disabled.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
Grid ``[3, H+pad, N*(W+pad)]`` — single row, N = 1 + K (+ K when IG enabled).
|
|
455
|
+
"""
|
|
456
|
+
H, W = x.shape[-2], x.shape[-1]
|
|
457
|
+
K = gc_K.shape[0]
|
|
458
|
+
orig = self._denorm_input(x) # [3, H, W]
|
|
459
|
+
|
|
460
|
+
pred_color = (100, 220, 100) if tc == pc else (220, 100, 100)
|
|
461
|
+
orig_ann = self._annotate(orig, f"T:{tc} P:{pc}", color=pred_color)
|
|
462
|
+
|
|
463
|
+
gc_2d = self._attr_to_2d(gc_K, H, W) # [K, 1, H, W]
|
|
464
|
+
gc_heat = self._heatmap(gc_2d) # [K, 3, H, W]
|
|
465
|
+
orig_rep = orig.unsqueeze(0).expand(K, -1, -1, -1) # [K, 3, H, W]
|
|
466
|
+
gc_ov = self._overlay(orig_rep, gc_heat) # [K, 3, H, W]
|
|
467
|
+
gc_tiles = [self._annotate(gc_ov[k], f"GC→c{k}") for k in range(K)]
|
|
468
|
+
|
|
469
|
+
tiles: List[torch.Tensor] = [orig_ann] + gc_tiles
|
|
470
|
+
|
|
471
|
+
if ig_K is not None:
|
|
472
|
+
ig_2d = self._attr_to_2d(ig_K, H, W)
|
|
473
|
+
ig_heat = self._heatmap(ig_2d)
|
|
474
|
+
ig_ov = self._overlay(orig_rep, ig_heat) # [K, 3, H, W]
|
|
475
|
+
tiles += [self._annotate(ig_ov[k], f"IG→c{k}") for k in range(K)]
|
|
476
|
+
|
|
477
|
+
grid = vutils.make_grid(torch.stack(tiles), nrow=len(tiles), padding=2)
|
|
478
|
+
legend = self._colorbar(grid.shape[1]) # [3, H_grid, 20]
|
|
479
|
+
return torch.cat([grid, legend], dim=2)
|
|
480
|
+
|
|
481
|
+
def _build_panels(
|
|
482
|
+
self,
|
|
483
|
+
inputs: torch.Tensor,
|
|
484
|
+
selected: List[int],
|
|
485
|
+
keys: List[str],
|
|
486
|
+
targets_true: torch.Tensor,
|
|
487
|
+
preds: torch.Tensor,
|
|
488
|
+
gc_all: torch.Tensor,
|
|
489
|
+
ig_all: Optional[torch.Tensor],
|
|
490
|
+
K: int,
|
|
491
|
+
epoch: int,
|
|
492
|
+
stage: str,
|
|
493
|
+
) -> List[_Panel]:
|
|
494
|
+
"""Assemble one ``_Panel`` per selected sample.
|
|
495
|
+
|
|
496
|
+
Slices the batched attributions back into per-sample K-blocks, renders
|
|
497
|
+
each panel via ``_build_sample_image``, and computes the deterministic
|
|
498
|
+
filename and caption from epoch / key / true / pred.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
inputs: full batch inputs ``[N, C, H, W]``.
|
|
502
|
+
selected: batch indices from ``_select_indices``.
|
|
503
|
+
keys: dataset keys for all batch samples.
|
|
504
|
+
targets_true: ground-truth class indices ``[N]``.
|
|
505
|
+
preds: predicted class indices ``[N]``.
|
|
506
|
+
gc_all: GradCAM attributions ``[S*K, 1, h, w]``.
|
|
507
|
+
ig_all: IG attributions ``[S*K, C, H, W]`` or ``None``.
|
|
508
|
+
K: number of target classes.
|
|
509
|
+
epoch: current training epoch (for filenames / captions).
|
|
510
|
+
stage: ``"val"`` or ``"test"``.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
Panels in ``selected`` order.
|
|
514
|
+
"""
|
|
515
|
+
panels: List[_Panel] = []
|
|
516
|
+
for s, idx in enumerate(selected):
|
|
517
|
+
gc_K = gc_all[s * K : (s + 1) * K]
|
|
518
|
+
ig_K = ig_all[s * K : (s + 1) * K] if ig_all is not None else None
|
|
519
|
+
key = keys[idx]
|
|
520
|
+
tc = int(targets_true[idx].item())
|
|
521
|
+
pc = int(preds[idx].item())
|
|
522
|
+
panels.append(
|
|
523
|
+
_Panel(
|
|
524
|
+
key=key,
|
|
525
|
+
image=self._build_sample_image(
|
|
526
|
+
inputs[idx : idx + 1], gc_K, tc, pc, ig_K
|
|
527
|
+
),
|
|
528
|
+
caption=f"ep{epoch:03d} key={key} true={tc} pred={pc}",
|
|
529
|
+
filename=f"{stage}-ep{epoch:03d}-key-{key}-true{tc}-pred{pc}.png",
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
return panels
|
|
533
|
+
|
|
534
|
+
def _save_panel(self, panel: _Panel, out_dir: Path) -> None:
|
|
535
|
+
"""Write one panel PNG to ``out_dir`` using its deterministic filename.
|
|
536
|
+
|
|
537
|
+
No-op when ``self._save_to_file`` is ``False``. Overwrites on rerun.
|
|
538
|
+
"""
|
|
539
|
+
if self._save_to_file:
|
|
540
|
+
vutils.save_image(panel.image, str(out_dir / panel.filename))
|
|
541
|
+
|
|
542
|
+
def _log_gallery(
|
|
543
|
+
self,
|
|
544
|
+
panels: List[_Panel],
|
|
545
|
+
stage: str,
|
|
546
|
+
step: Optional[int] = None,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Log panels as a gallery list AND as per-sample keys in one ``wandb.log`` call.
|
|
549
|
+
|
|
550
|
+
``attributions/{stage}`` holds a list of all images → cross-image comparison
|
|
551
|
+
at a given step. ``attributions/{stage}/{key}`` holds each image individually
|
|
552
|
+
→ cross-step timeline tracking per sample.
|
|
553
|
+
|
|
554
|
+
Validation passes an explicit ``step`` to tie images to the training
|
|
555
|
+
global step. Test omits it — ``global_step`` is frozen at the last
|
|
556
|
+
training value and would trigger W&B's monotonicity warning.
|
|
557
|
+
"""
|
|
558
|
+
try:
|
|
559
|
+
import numpy as np
|
|
560
|
+
|
|
561
|
+
images = [
|
|
562
|
+
wandb.Image(
|
|
563
|
+
(p.image.permute(1, 2, 0).numpy() * 255).astype(np.uint8),
|
|
564
|
+
caption=p.caption,
|
|
565
|
+
)
|
|
566
|
+
for p in panels
|
|
567
|
+
]
|
|
568
|
+
payload: Dict[str, Any] = {f"attributions/{stage}": images}
|
|
569
|
+
payload.update(
|
|
570
|
+
{f"attributions/{stage}/{p.key}": img for p, img in zip(panels, images)}
|
|
571
|
+
)
|
|
572
|
+
if step is not None:
|
|
573
|
+
wandb.log(payload, step=step)
|
|
574
|
+
else:
|
|
575
|
+
wandb.log(payload)
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|