cnn-classifier-toolkit 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.
- cnn_classifier/__init__.py +5 -0
- cnn_classifier/_version.py +1 -0
- cnn_classifier/cli.py +1085 -0
- cnn_classifier/config/__init__.py +17 -0
- cnn_classifier/config/loader.py +102 -0
- cnn_classifier/config/presets/default.yaml +47 -0
- cnn_classifier/config/resolve.py +108 -0
- cnn_classifier/config/schema.py +251 -0
- cnn_classifier/core/__init__.py +19 -0
- cnn_classifier/core/device.py +50 -0
- cnn_classifier/core/errors.py +21 -0
- cnn_classifier/core/hashing.py +18 -0
- cnn_classifier/core/registry.py +46 -0
- cnn_classifier/core/seeding.py +37 -0
- cnn_classifier/core/types.py +97 -0
- cnn_classifier/data/__init__.py +22 -0
- cnn_classifier/data/adapters/__init__.py +19 -0
- cnn_classifier/data/adapters/_common.py +241 -0
- cnn_classifier/data/adapters/image_folder.py +65 -0
- cnn_classifier/data/adapters/manifest.py +62 -0
- cnn_classifier/data/adapters/numpy.py +96 -0
- cnn_classifier/data/adapters/registry.py +29 -0
- cnn_classifier/data/adapters/synthetic.py +114 -0
- cnn_classifier/data/adapters/torchvision.py +192 -0
- cnn_classifier/data/base.py +17 -0
- cnn_classifier/data/contract.py +28 -0
- cnn_classifier/data/image_io.py +116 -0
- cnn_classifier/data/inspection.py +111 -0
- cnn_classifier/data/loaders.py +79 -0
- cnn_classifier/data/manifest.py +98 -0
- cnn_classifier/data/splits.py +136 -0
- cnn_classifier/data/stats.py +106 -0
- cnn_classifier/data/transforms.py +192 -0
- cnn_classifier/diagnostics/__init__.py +22 -0
- cnn_classifier/diagnostics/activations.py +56 -0
- cnn_classifier/diagnostics/architecture.py +172 -0
- cnn_classifier/diagnostics/determinism.py +25 -0
- cnn_classifier/diagnostics/gradients.py +32 -0
- cnn_classifier/diagnostics/sanity.py +49 -0
- cnn_classifier/diagnostics/tracing.py +56 -0
- cnn_classifier/evaluation/__init__.py +53 -0
- cnn_classifier/evaluation/bootstrap.py +96 -0
- cnn_classifier/evaluation/calibration.py +74 -0
- cnn_classifier/evaluation/compare.py +111 -0
- cnn_classifier/evaluation/error_analysis.py +61 -0
- cnn_classifier/evaluation/evaluator.py +143 -0
- cnn_classifier/evaluation/latency.py +58 -0
- cnn_classifier/evaluation/metrics.py +94 -0
- cnn_classifier/evaluation/predict.py +64 -0
- cnn_classifier/models/__init__.py +50 -0
- cnn_classifier/models/base.py +62 -0
- cnn_classifier/models/residual_cnn.py +138 -0
- cnn_classifier/models/simple_cnn.py +55 -0
- cnn_classifier/models/summary.py +39 -0
- cnn_classifier/models/torchvision.py +46 -0
- cnn_classifier/models/transfer.py +193 -0
- cnn_classifier/runs/__init__.py +6 -0
- cnn_classifier/runs/artifacts.py +50 -0
- cnn_classifier/runs/context.py +130 -0
- cnn_classifier/tracking/__init__.py +34 -0
- cnn_classifier/tracking/base.py +16 -0
- cnn_classifier/tracking/composite.py +20 -0
- cnn_classifier/tracking/console.py +14 -0
- cnn_classifier/tracking/csv.py +19 -0
- cnn_classifier/tracking/tensorboard.py +22 -0
- cnn_classifier/training/__init__.py +37 -0
- cnn_classifier/training/amp.py +51 -0
- cnn_classifier/training/callbacks.py +229 -0
- cnn_classifier/training/checkpoint.py +151 -0
- cnn_classifier/training/losses.py +67 -0
- cnn_classifier/training/optim.py +40 -0
- cnn_classifier/training/schedulers.py +53 -0
- cnn_classifier/training/state.py +35 -0
- cnn_classifier/training/trainer.py +357 -0
- cnn_classifier/viz/__init__.py +42 -0
- cnn_classifier/viz/architecture.py +276 -0
- cnn_classifier/viz/compare.py +36 -0
- cnn_classifier/viz/data.py +56 -0
- cnn_classifier/viz/evaluation.py +134 -0
- cnn_classifier/viz/features.py +122 -0
- cnn_classifier/viz/report.py +821 -0
- cnn_classifier/viz/style.py +69 -0
- cnn_classifier/viz/templates/report.html.j2 +355 -0
- cnn_classifier/viz/training.py +83 -0
- cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
- cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
- cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
- cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- cnn_classifier_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Stage-level architecture descriptors for visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from torch import nn
|
|
9
|
+
|
|
10
|
+
from cnn_classifier.diagnostics.tracing import LayerShape, trace_shapes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ArchitectureStage:
|
|
15
|
+
name: str
|
|
16
|
+
kind: str
|
|
17
|
+
output_shape: tuple[int, ...]
|
|
18
|
+
params: int
|
|
19
|
+
trainable_params: int
|
|
20
|
+
residual: bool = False
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def frozen(self) -> bool:
|
|
24
|
+
return self.trainable_params == 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _output_shape(record: LayerShape | None) -> tuple[int, ...]:
|
|
28
|
+
if record is None:
|
|
29
|
+
return ()
|
|
30
|
+
shape = tuple(int(value) for value in record.output_shape)
|
|
31
|
+
if len(shape) > 1 and shape[0] == 1:
|
|
32
|
+
return shape[1:]
|
|
33
|
+
return shape
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _stage(
|
|
37
|
+
name: str,
|
|
38
|
+
kind: str,
|
|
39
|
+
modules: list[nn.Module],
|
|
40
|
+
records: dict[str, LayerShape],
|
|
41
|
+
record_name: str,
|
|
42
|
+
*,
|
|
43
|
+
residual: bool = False,
|
|
44
|
+
) -> ArchitectureStage:
|
|
45
|
+
params = sum(parameter.numel() for module in modules for parameter in module.parameters())
|
|
46
|
+
trainable = sum(
|
|
47
|
+
parameter.numel()
|
|
48
|
+
for module in modules
|
|
49
|
+
for parameter in module.parameters()
|
|
50
|
+
if parameter.requires_grad
|
|
51
|
+
)
|
|
52
|
+
return ArchitectureStage(
|
|
53
|
+
name=name,
|
|
54
|
+
kind=kind,
|
|
55
|
+
output_shape=_output_shape(records.get(record_name)),
|
|
56
|
+
params=params,
|
|
57
|
+
trainable_params=trainable,
|
|
58
|
+
residual=residual,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sequential_stages(model: nn.Module, records: dict[str, LayerShape]) -> list[ArchitectureStage]:
|
|
63
|
+
features = cast(nn.Sequential, model.features)
|
|
64
|
+
groups: list[list[tuple[int, nn.Module]]] = []
|
|
65
|
+
current: list[tuple[int, nn.Module]] = []
|
|
66
|
+
for index, module in enumerate(features.children()):
|
|
67
|
+
current.append((index, module))
|
|
68
|
+
if isinstance(module, nn.MaxPool2d):
|
|
69
|
+
groups.append(current)
|
|
70
|
+
current = []
|
|
71
|
+
if current:
|
|
72
|
+
groups.append(current)
|
|
73
|
+
stages = [
|
|
74
|
+
_stage(
|
|
75
|
+
f"stage {index}",
|
|
76
|
+
"stage",
|
|
77
|
+
[module for _, module in group],
|
|
78
|
+
records,
|
|
79
|
+
f"features.{group[-1][0]}",
|
|
80
|
+
)
|
|
81
|
+
for index, group in enumerate(groups, start=1)
|
|
82
|
+
]
|
|
83
|
+
stages.append(_stage("head", "head", _children(cast(nn.Module, model.head)), records, "head"))
|
|
84
|
+
return stages
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _children(module: nn.Module) -> list[nn.Module]:
|
|
88
|
+
if isinstance(module, nn.Sequential):
|
|
89
|
+
return list(module.children())
|
|
90
|
+
return [module]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _residual_stages(model: nn.Module, records: dict[str, LayerShape]) -> list[ArchitectureStage]:
|
|
94
|
+
features = cast(nn.Sequential, model.features)
|
|
95
|
+
spans = cast(tuple[tuple[str, int, int, bool], ...], model.stage_spans)
|
|
96
|
+
stages = [
|
|
97
|
+
_stage(
|
|
98
|
+
name,
|
|
99
|
+
"stem" if name == "stem" else "stage",
|
|
100
|
+
[features[index] for index in range(start, end)],
|
|
101
|
+
records,
|
|
102
|
+
f"features.{end - 1}",
|
|
103
|
+
residual=residual,
|
|
104
|
+
)
|
|
105
|
+
for name, start, end, residual in spans
|
|
106
|
+
]
|
|
107
|
+
stages.append(_stage("head", "head", _children(cast(nn.Module, model.head)), records, "head"))
|
|
108
|
+
return stages
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _torchvision_stages(
|
|
112
|
+
model: nn.Module, records: dict[str, LayerShape]
|
|
113
|
+
) -> list[ArchitectureStage]:
|
|
114
|
+
children = dict(model.named_children())
|
|
115
|
+
stem_names = [name for name in ("conv1", "bn1", "relu", "maxpool") if name in children]
|
|
116
|
+
stages = [
|
|
117
|
+
_stage(
|
|
118
|
+
"stem",
|
|
119
|
+
"stem",
|
|
120
|
+
[children[name] for name in stem_names],
|
|
121
|
+
records,
|
|
122
|
+
stem_names[-1],
|
|
123
|
+
)
|
|
124
|
+
]
|
|
125
|
+
for name in ("layer1", "layer2", "layer3", "layer4"):
|
|
126
|
+
if name in children:
|
|
127
|
+
stages.append(
|
|
128
|
+
_stage(
|
|
129
|
+
name.replace("layer", "layer "),
|
|
130
|
+
"stage",
|
|
131
|
+
[children[name]],
|
|
132
|
+
records,
|
|
133
|
+
name,
|
|
134
|
+
residual=True,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
head_names: list[str] = [name for name in ("avgpool", "fc") if name in children]
|
|
138
|
+
if not head_names:
|
|
139
|
+
head_names = list(children)
|
|
140
|
+
stages.append(
|
|
141
|
+
_stage(
|
|
142
|
+
"head",
|
|
143
|
+
"head",
|
|
144
|
+
[children[name] for name in head_names],
|
|
145
|
+
records,
|
|
146
|
+
head_names[-1],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return stages
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def architecture_stages(
|
|
153
|
+
model: nn.Module, input_shape: tuple[int, int, int]
|
|
154
|
+
) -> list[ArchitectureStage]:
|
|
155
|
+
"""Return concise stage descriptors for a built-in CNN.
|
|
156
|
+
|
|
157
|
+
The built-in ``simple_cnn`` and ``residual_cnn`` families and torchvision
|
|
158
|
+
ResNet models get precise grouping. Other models fall back to their
|
|
159
|
+
top-level children.
|
|
160
|
+
"""
|
|
161
|
+
records = {record.name: record for record in trace_shapes(model, input_shape)}
|
|
162
|
+
if hasattr(model, "stage_spans") and hasattr(model, "features"):
|
|
163
|
+
return _residual_stages(model, records)
|
|
164
|
+
if hasattr(model, "features") and isinstance(model.features, nn.Sequential):
|
|
165
|
+
return _sequential_stages(model, records)
|
|
166
|
+
children = dict(model.named_children())
|
|
167
|
+
if "conv1" in children and "layer1" in children:
|
|
168
|
+
return _torchvision_stages(model, records)
|
|
169
|
+
stages = [_stage(name, "stage", [module], records, name) for name, module in children.items()]
|
|
170
|
+
if not stages:
|
|
171
|
+
raise ValueError("model has no submodules to visualize")
|
|
172
|
+
return stages
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Determinism check for a seeded callable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
|
|
10
|
+
from cnn_classifier.core.seeding import seed_everything
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _equal(left: Any, right: Any) -> bool:
|
|
14
|
+
if torch.is_tensor(left) or torch.is_tensor(right):
|
|
15
|
+
return bool(torch.equal(torch.as_tensor(left), torch.as_tensor(right)))
|
|
16
|
+
return bool(left == right)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def determinism_check(train_fn: Callable[[], Any], seed: int) -> bool:
|
|
20
|
+
"""Run a callable twice with the same seed and compare its result."""
|
|
21
|
+
seed_everything(seed)
|
|
22
|
+
first = train_fn()
|
|
23
|
+
seed_everything(seed)
|
|
24
|
+
second = train_fn()
|
|
25
|
+
return _equal(first, second)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Gradient statistics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import nn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class GradientStat:
|
|
13
|
+
name: str
|
|
14
|
+
norm: float
|
|
15
|
+
zero_fraction: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def gradient_report(model: nn.Module) -> list[GradientStat]:
|
|
19
|
+
"""Return norm and zero fraction for every parameter with a gradient."""
|
|
20
|
+
report: list[GradientStat] = []
|
|
21
|
+
for name, parameter in model.named_parameters():
|
|
22
|
+
if parameter.grad is None:
|
|
23
|
+
continue
|
|
24
|
+
gradient = parameter.grad.detach()
|
|
25
|
+
report.append(
|
|
26
|
+
GradientStat(
|
|
27
|
+
name=name,
|
|
28
|
+
norm=float(torch.linalg.vector_norm(gradient).item()),
|
|
29
|
+
zero_fraction=float((gradient == 0).float().mean().item()),
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return report
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""One-batch overfit sanity check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import nn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class OverfitResult:
|
|
13
|
+
initial_loss: float
|
|
14
|
+
final_loss: float
|
|
15
|
+
steps: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def overfit_batch(
|
|
19
|
+
model: nn.Module,
|
|
20
|
+
batch: tuple[torch.Tensor, ...],
|
|
21
|
+
loss_fn: nn.Module,
|
|
22
|
+
optimizer: torch.optim.Optimizer,
|
|
23
|
+
device: torch.device,
|
|
24
|
+
max_steps: int = 100,
|
|
25
|
+
target_loss: float = 0.01,
|
|
26
|
+
) -> OverfitResult:
|
|
27
|
+
"""Train one batch to a small loss, restoring the model mode."""
|
|
28
|
+
images = batch[0].to(device)
|
|
29
|
+
labels = batch[1].to(device)
|
|
30
|
+
was_training = model.training
|
|
31
|
+
model.train()
|
|
32
|
+
initial_loss = 0.0
|
|
33
|
+
final_loss = 0.0
|
|
34
|
+
steps = 0
|
|
35
|
+
try:
|
|
36
|
+
for step in range(1, max_steps + 1):
|
|
37
|
+
optimizer.zero_grad(set_to_none=True)
|
|
38
|
+
loss = loss_fn(model(images), labels)
|
|
39
|
+
if step == 1:
|
|
40
|
+
initial_loss = float(loss.item())
|
|
41
|
+
loss.backward()
|
|
42
|
+
optimizer.step()
|
|
43
|
+
final_loss = float(loss.item())
|
|
44
|
+
steps = step
|
|
45
|
+
if final_loss <= target_loss:
|
|
46
|
+
break
|
|
47
|
+
finally:
|
|
48
|
+
model.train(was_training)
|
|
49
|
+
return OverfitResult(initial_loss=initial_loss, final_loss=final_loss, steps=steps)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shape tracing with forward hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
from torch import nn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LayerShape:
|
|
15
|
+
name: str
|
|
16
|
+
type: str
|
|
17
|
+
input_shape: tuple[int, ...]
|
|
18
|
+
output_shape: tuple[int, ...]
|
|
19
|
+
params: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def trace_shapes(model: nn.Module, input_shape: tuple[int, int, int]) -> list[LayerShape]:
|
|
23
|
+
"""Run one dummy input and report each module's shapes."""
|
|
24
|
+
parameter = next(model.parameters(), None)
|
|
25
|
+
device = parameter.device if parameter is not None else torch.device("cpu")
|
|
26
|
+
was_training = model.training
|
|
27
|
+
records: list[LayerShape] = []
|
|
28
|
+
handles: list[Any] = []
|
|
29
|
+
|
|
30
|
+
def hook(name: str, module: nn.Module) -> Callable[[nn.Module, Any, Any], None]:
|
|
31
|
+
def _record(_module: nn.Module, inputs: Any, output: Any) -> None:
|
|
32
|
+
input_tensor = inputs[0] if isinstance(inputs, tuple) else inputs
|
|
33
|
+
output_tensor = output[0] if isinstance(output, tuple) else output
|
|
34
|
+
records.append(
|
|
35
|
+
LayerShape(
|
|
36
|
+
name=name or "model",
|
|
37
|
+
type=type(module).__name__,
|
|
38
|
+
input_shape=tuple(int(value) for value in input_tensor.shape),
|
|
39
|
+
output_shape=tuple(int(value) for value in output_tensor.shape),
|
|
40
|
+
params=sum(p.numel() for p in module.parameters()),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return _record
|
|
45
|
+
|
|
46
|
+
for name, module in model.named_modules():
|
|
47
|
+
handles.append(module.register_forward_hook(hook(name, module)))
|
|
48
|
+
try:
|
|
49
|
+
model.eval()
|
|
50
|
+
with torch.no_grad():
|
|
51
|
+
model(torch.zeros(1, *input_shape, device=device))
|
|
52
|
+
finally:
|
|
53
|
+
for handle in handles:
|
|
54
|
+
handle.remove()
|
|
55
|
+
model.train(was_training)
|
|
56
|
+
return records
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Evaluation metrics, calibration, bootstrap, comparisons, and inference."""
|
|
2
|
+
|
|
3
|
+
from cnn_classifier.evaluation.bootstrap import (
|
|
4
|
+
ConfidenceInterval,
|
|
5
|
+
bootstrap_ci,
|
|
6
|
+
paired_bootstrap_difference,
|
|
7
|
+
)
|
|
8
|
+
from cnn_classifier.evaluation.calibration import (
|
|
9
|
+
CalibrationBin,
|
|
10
|
+
CalibrationReport,
|
|
11
|
+
expected_calibration_error,
|
|
12
|
+
)
|
|
13
|
+
from cnn_classifier.evaluation.error_analysis import (
|
|
14
|
+
ErrorExample,
|
|
15
|
+
confusion_pairs,
|
|
16
|
+
most_confident_errors,
|
|
17
|
+
)
|
|
18
|
+
from cnn_classifier.evaluation.evaluator import (
|
|
19
|
+
EvaluationResult,
|
|
20
|
+
evaluate_model,
|
|
21
|
+
save_predictions,
|
|
22
|
+
)
|
|
23
|
+
from cnn_classifier.evaluation.latency import LatencyReport, measure_latency
|
|
24
|
+
from cnn_classifier.evaluation.metrics import (
|
|
25
|
+
ClassMetrics,
|
|
26
|
+
MetricsReport,
|
|
27
|
+
accuracy,
|
|
28
|
+
classification_metrics,
|
|
29
|
+
)
|
|
30
|
+
from cnn_classifier.evaluation.predict import Predictions, predict_paths
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CalibrationBin",
|
|
34
|
+
"CalibrationReport",
|
|
35
|
+
"ClassMetrics",
|
|
36
|
+
"ConfidenceInterval",
|
|
37
|
+
"ErrorExample",
|
|
38
|
+
"EvaluationResult",
|
|
39
|
+
"LatencyReport",
|
|
40
|
+
"MetricsReport",
|
|
41
|
+
"Predictions",
|
|
42
|
+
"accuracy",
|
|
43
|
+
"bootstrap_ci",
|
|
44
|
+
"classification_metrics",
|
|
45
|
+
"confusion_pairs",
|
|
46
|
+
"evaluate_model",
|
|
47
|
+
"expected_calibration_error",
|
|
48
|
+
"measure_latency",
|
|
49
|
+
"most_confident_errors",
|
|
50
|
+
"paired_bootstrap_difference",
|
|
51
|
+
"predict_paths",
|
|
52
|
+
"save_predictions",
|
|
53
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Bootstrap confidence intervals and paired comparisons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
MetricFunction = Callable[[np.ndarray, np.ndarray], float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ConfidenceInterval:
|
|
15
|
+
low: float
|
|
16
|
+
high: float
|
|
17
|
+
estimate: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate(y_true: np.ndarray, y_pred: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
21
|
+
truth = np.asarray(y_true)
|
|
22
|
+
predictions = np.asarray(y_pred)
|
|
23
|
+
if len(truth) != len(predictions):
|
|
24
|
+
raise ValueError("y_true and y_pred must have the same length")
|
|
25
|
+
return truth, predictions
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resample_indices(
|
|
29
|
+
truth: np.ndarray,
|
|
30
|
+
rng: np.random.Generator,
|
|
31
|
+
stratify: bool,
|
|
32
|
+
) -> np.ndarray:
|
|
33
|
+
if not stratify:
|
|
34
|
+
return rng.integers(0, len(truth), size=len(truth))
|
|
35
|
+
pieces = []
|
|
36
|
+
for label in np.unique(truth):
|
|
37
|
+
class_indices = np.flatnonzero(truth == label)
|
|
38
|
+
pieces.append(rng.choice(class_indices, size=len(class_indices), replace=True))
|
|
39
|
+
return np.concatenate(pieces)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bootstrap_ci(
|
|
43
|
+
y_true: np.ndarray,
|
|
44
|
+
y_pred: np.ndarray,
|
|
45
|
+
metric_fn: MetricFunction,
|
|
46
|
+
n_resamples: int = 1000,
|
|
47
|
+
confidence: float = 0.95,
|
|
48
|
+
seed: int = 0,
|
|
49
|
+
stratify: bool = False,
|
|
50
|
+
) -> ConfidenceInterval:
|
|
51
|
+
"""Return a percentile bootstrap interval for one prediction set."""
|
|
52
|
+
truth, predictions = _validate(y_true, y_pred)
|
|
53
|
+
if n_resamples <= 0:
|
|
54
|
+
raise ValueError("n_resamples must be positive")
|
|
55
|
+
if not 0.0 < confidence < 1.0:
|
|
56
|
+
raise ValueError("confidence must be in (0, 1)")
|
|
57
|
+
rng = np.random.default_rng(seed)
|
|
58
|
+
values = []
|
|
59
|
+
for _ in range(n_resamples):
|
|
60
|
+
indices = _resample_indices(truth, rng, stratify)
|
|
61
|
+
values.append(metric_fn(truth[indices], predictions[indices]))
|
|
62
|
+
alpha = (1.0 - confidence) / 2.0
|
|
63
|
+
low, high = np.percentile(values, [100 * alpha, 100 * (1 - alpha)])
|
|
64
|
+
return ConfidenceInterval(
|
|
65
|
+
low=float(low), high=float(high), estimate=float(metric_fn(truth, predictions))
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def paired_bootstrap_difference(
|
|
70
|
+
y_true: np.ndarray,
|
|
71
|
+
pred_a: np.ndarray,
|
|
72
|
+
pred_b: np.ndarray,
|
|
73
|
+
metric_fn: MetricFunction,
|
|
74
|
+
n_resamples: int = 1000,
|
|
75
|
+
confidence: float = 0.95,
|
|
76
|
+
seed: int = 0,
|
|
77
|
+
stratify: bool = False,
|
|
78
|
+
) -> ConfidenceInterval:
|
|
79
|
+
"""Return a bootstrap interval for ``metric(a) - metric(b)``."""
|
|
80
|
+
truth, predictions_a = _validate(y_true, pred_a)
|
|
81
|
+
_, predictions_b = _validate(y_true, pred_b)
|
|
82
|
+
rng = np.random.default_rng(seed)
|
|
83
|
+
values = []
|
|
84
|
+
for _ in range(n_resamples):
|
|
85
|
+
indices = _resample_indices(truth, rng, stratify)
|
|
86
|
+
values.append(
|
|
87
|
+
metric_fn(truth[indices], predictions_a[indices])
|
|
88
|
+
- metric_fn(truth[indices], predictions_b[indices])
|
|
89
|
+
)
|
|
90
|
+
alpha = (1.0 - confidence) / 2.0
|
|
91
|
+
low, high = np.percentile(values, [100 * alpha, 100 * (1 - alpha)])
|
|
92
|
+
return ConfidenceInterval(
|
|
93
|
+
low=float(low),
|
|
94
|
+
high=float(high),
|
|
95
|
+
estimate=float(metric_fn(truth, predictions_a) - metric_fn(truth, predictions_b)),
|
|
96
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Calibration metrics: ECE, MCE, Brier score, and reliability bins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class CalibrationBin:
|
|
12
|
+
lower: float
|
|
13
|
+
upper: float
|
|
14
|
+
count: int
|
|
15
|
+
confidence: float
|
|
16
|
+
accuracy: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class CalibrationReport:
|
|
21
|
+
ece: float
|
|
22
|
+
mce: float
|
|
23
|
+
bins: list[CalibrationBin]
|
|
24
|
+
brier: float
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def expected_calibration_error(
|
|
28
|
+
probabilities: np.ndarray,
|
|
29
|
+
y_true: np.ndarray,
|
|
30
|
+
n_bins: int = 15,
|
|
31
|
+
) -> CalibrationReport:
|
|
32
|
+
"""Compute ECE, MCE, Brier score, and reliability bins."""
|
|
33
|
+
probs = np.asarray(probabilities, dtype=np.float64)
|
|
34
|
+
truth = np.asarray(y_true, dtype=np.int64)
|
|
35
|
+
if probs.ndim != 2 or len(probs) != len(truth):
|
|
36
|
+
raise ValueError("probabilities must be [N,C] and match y_true")
|
|
37
|
+
if n_bins <= 0:
|
|
38
|
+
raise ValueError("n_bins must be positive")
|
|
39
|
+
confidence = probs.max(axis=1)
|
|
40
|
+
predictions = probs.argmax(axis=1)
|
|
41
|
+
correct = (predictions == truth).astype(np.float64)
|
|
42
|
+
edges = np.linspace(0.0, 1.0, n_bins + 1)
|
|
43
|
+
bin_indices = np.clip(np.digitize(confidence, edges, right=False) - 1, 0, n_bins - 1)
|
|
44
|
+
bins: list[CalibrationBin] = []
|
|
45
|
+
ece = 0.0
|
|
46
|
+
mce = 0.0
|
|
47
|
+
total = len(truth)
|
|
48
|
+
for index in range(n_bins):
|
|
49
|
+
mask = bin_indices == index
|
|
50
|
+
count = int(mask.sum())
|
|
51
|
+
if count:
|
|
52
|
+
mean_conf = float(confidence[mask].mean())
|
|
53
|
+
mean_acc = float(correct[mask].mean())
|
|
54
|
+
gap = abs(mean_acc - mean_conf)
|
|
55
|
+
else:
|
|
56
|
+
mean_conf = 0.0
|
|
57
|
+
mean_acc = 0.0
|
|
58
|
+
gap = 0.0
|
|
59
|
+
bins.append(
|
|
60
|
+
CalibrationBin(
|
|
61
|
+
lower=float(edges[index]),
|
|
62
|
+
upper=float(edges[index + 1]),
|
|
63
|
+
count=count,
|
|
64
|
+
confidence=mean_conf,
|
|
65
|
+
accuracy=mean_acc,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
if total:
|
|
69
|
+
ece += (count / total) * gap
|
|
70
|
+
mce = max(mce, gap)
|
|
71
|
+
one_hot = np.zeros_like(probs)
|
|
72
|
+
one_hot[np.arange(len(truth)), truth] = 1.0
|
|
73
|
+
brier = float(np.mean(np.sum((probs - one_hot) ** 2, axis=1)))
|
|
74
|
+
return CalibrationReport(ece=float(ece), mce=float(mce), bins=bins, brier=brier)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Fair multi-run comparison with test-fingerprint enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cnn_classifier.core.errors import RunError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ComparisonReport:
|
|
15
|
+
rows: list[dict[str, Any]]
|
|
16
|
+
warnings: list[str]
|
|
17
|
+
markdown: str
|
|
18
|
+
pareto_data: list[dict[str, Any]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_run(run_dir: Path) -> tuple[str, dict[str, Any]]:
|
|
22
|
+
summary_path = run_dir / "summary.json"
|
|
23
|
+
fingerprint_path = run_dir / "data" / "fingerprint.json"
|
|
24
|
+
try:
|
|
25
|
+
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
|
26
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
27
|
+
raise RunError(f"cannot read summary for {run_dir}: {exc}") from exc
|
|
28
|
+
fingerprint = summary.get("test_fingerprint") or summary.get("dataset_fingerprint")
|
|
29
|
+
if fingerprint_path.is_file():
|
|
30
|
+
try:
|
|
31
|
+
fingerprint = json.loads(fingerprint_path.read_text(encoding="utf-8")).get(
|
|
32
|
+
"fingerprint", fingerprint
|
|
33
|
+
)
|
|
34
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
35
|
+
raise RunError(f"cannot read fingerprint for {run_dir}: {exc}") from exc
|
|
36
|
+
if not fingerprint:
|
|
37
|
+
raise RunError(f"run {run_dir} has no test fingerprint")
|
|
38
|
+
return str(fingerprint), summary
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _row(run_dir: Path, summary: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
interval = summary.get("accuracy_ci") or [None, None]
|
|
43
|
+
return {
|
|
44
|
+
"run": run_dir.name,
|
|
45
|
+
"accuracy": summary.get("test_accuracy", summary.get("best_metric")),
|
|
46
|
+
"macro_f1": summary.get("macro_f1"),
|
|
47
|
+
"ci_low": interval[0],
|
|
48
|
+
"ci_high": interval[1],
|
|
49
|
+
"params": summary.get("params"),
|
|
50
|
+
"trainable_params": summary.get("trainable_params"),
|
|
51
|
+
"train_seconds": summary.get("train_seconds"),
|
|
52
|
+
"latency_ms": summary.get("latency_ms"),
|
|
53
|
+
"peak_memory_mb": summary.get("peak_memory_mb"),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def compare_runs(
|
|
58
|
+
run_dirs: list[Path] | tuple[Path, ...],
|
|
59
|
+
output_dir: Path,
|
|
60
|
+
) -> ComparisonReport:
|
|
61
|
+
"""Compare runs only when they share the same test fingerprint."""
|
|
62
|
+
if not run_dirs:
|
|
63
|
+
raise RunError("compare_runs requires at least one run")
|
|
64
|
+
fingerprints: dict[str, str] = {}
|
|
65
|
+
summaries: list[tuple[Path, dict[str, Any]]] = []
|
|
66
|
+
for run_dir in run_dirs:
|
|
67
|
+
fingerprint, summary = _load_run(Path(run_dir))
|
|
68
|
+
fingerprints[run_dir.name] = fingerprint
|
|
69
|
+
summaries.append((Path(run_dir), summary))
|
|
70
|
+
if len(set(fingerprints.values())) != 1:
|
|
71
|
+
details = ", ".join(f"{name}={fingerprint}" for name, fingerprint in fingerprints.items())
|
|
72
|
+
raise RunError(f"test fingerprint mismatch across runs: {details}")
|
|
73
|
+
rows = sorted(
|
|
74
|
+
(_row(run_dir, summary) for run_dir, summary in summaries),
|
|
75
|
+
key=lambda row: float(row.get("accuracy") or 0.0),
|
|
76
|
+
reverse=True,
|
|
77
|
+
)
|
|
78
|
+
warnings: list[str] = []
|
|
79
|
+
for row in rows:
|
|
80
|
+
if row["accuracy"] is None:
|
|
81
|
+
warnings.append(f"{row['run']} has no accuracy")
|
|
82
|
+
header = "| Run | Accuracy | Macro F1 | Params | Latency (ms) |"
|
|
83
|
+
separator = "|---|---:|---:|---:|---:|"
|
|
84
|
+
lines = [header, separator]
|
|
85
|
+
for row in rows:
|
|
86
|
+
accuracy = row["accuracy"]
|
|
87
|
+
macro_f1 = row["macro_f1"]
|
|
88
|
+
lines.append(
|
|
89
|
+
f"| {row['run']} | "
|
|
90
|
+
f"{accuracy if accuracy is not None else 'n/a'} | "
|
|
91
|
+
f"{macro_f1 if macro_f1 is not None else 'n/a'} | "
|
|
92
|
+
f"{row['params'] if row['params'] is not None else 'n/a'} | "
|
|
93
|
+
f"{row['latency_ms'] if row['latency_ms'] is not None else 'n/a'} |"
|
|
94
|
+
)
|
|
95
|
+
markdown = "\n".join(lines) + "\n"
|
|
96
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
(output_dir / "comparison.md").write_text(markdown, encoding="utf-8")
|
|
98
|
+
(output_dir / "comparison.json").write_text(
|
|
99
|
+
json.dumps({"rows": rows, "warnings": warnings}, indent=2, sort_keys=True) + "\n",
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
pareto = [
|
|
103
|
+
{
|
|
104
|
+
"run": row["run"],
|
|
105
|
+
"accuracy": row["accuracy"],
|
|
106
|
+
"params": row["params"],
|
|
107
|
+
"latency_ms": row["latency_ms"],
|
|
108
|
+
}
|
|
109
|
+
for row in rows
|
|
110
|
+
]
|
|
111
|
+
return ComparisonReport(rows=rows, warnings=warnings, markdown=markdown, pareto_data=pareto)
|