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.
Files changed (89) hide show
  1. cnn_classifier/__init__.py +5 -0
  2. cnn_classifier/_version.py +1 -0
  3. cnn_classifier/cli.py +1085 -0
  4. cnn_classifier/config/__init__.py +17 -0
  5. cnn_classifier/config/loader.py +102 -0
  6. cnn_classifier/config/presets/default.yaml +47 -0
  7. cnn_classifier/config/resolve.py +108 -0
  8. cnn_classifier/config/schema.py +251 -0
  9. cnn_classifier/core/__init__.py +19 -0
  10. cnn_classifier/core/device.py +50 -0
  11. cnn_classifier/core/errors.py +21 -0
  12. cnn_classifier/core/hashing.py +18 -0
  13. cnn_classifier/core/registry.py +46 -0
  14. cnn_classifier/core/seeding.py +37 -0
  15. cnn_classifier/core/types.py +97 -0
  16. cnn_classifier/data/__init__.py +22 -0
  17. cnn_classifier/data/adapters/__init__.py +19 -0
  18. cnn_classifier/data/adapters/_common.py +241 -0
  19. cnn_classifier/data/adapters/image_folder.py +65 -0
  20. cnn_classifier/data/adapters/manifest.py +62 -0
  21. cnn_classifier/data/adapters/numpy.py +96 -0
  22. cnn_classifier/data/adapters/registry.py +29 -0
  23. cnn_classifier/data/adapters/synthetic.py +114 -0
  24. cnn_classifier/data/adapters/torchvision.py +192 -0
  25. cnn_classifier/data/base.py +17 -0
  26. cnn_classifier/data/contract.py +28 -0
  27. cnn_classifier/data/image_io.py +116 -0
  28. cnn_classifier/data/inspection.py +111 -0
  29. cnn_classifier/data/loaders.py +79 -0
  30. cnn_classifier/data/manifest.py +98 -0
  31. cnn_classifier/data/splits.py +136 -0
  32. cnn_classifier/data/stats.py +106 -0
  33. cnn_classifier/data/transforms.py +192 -0
  34. cnn_classifier/diagnostics/__init__.py +22 -0
  35. cnn_classifier/diagnostics/activations.py +56 -0
  36. cnn_classifier/diagnostics/architecture.py +172 -0
  37. cnn_classifier/diagnostics/determinism.py +25 -0
  38. cnn_classifier/diagnostics/gradients.py +32 -0
  39. cnn_classifier/diagnostics/sanity.py +49 -0
  40. cnn_classifier/diagnostics/tracing.py +56 -0
  41. cnn_classifier/evaluation/__init__.py +53 -0
  42. cnn_classifier/evaluation/bootstrap.py +96 -0
  43. cnn_classifier/evaluation/calibration.py +74 -0
  44. cnn_classifier/evaluation/compare.py +111 -0
  45. cnn_classifier/evaluation/error_analysis.py +61 -0
  46. cnn_classifier/evaluation/evaluator.py +143 -0
  47. cnn_classifier/evaluation/latency.py +58 -0
  48. cnn_classifier/evaluation/metrics.py +94 -0
  49. cnn_classifier/evaluation/predict.py +64 -0
  50. cnn_classifier/models/__init__.py +50 -0
  51. cnn_classifier/models/base.py +62 -0
  52. cnn_classifier/models/residual_cnn.py +138 -0
  53. cnn_classifier/models/simple_cnn.py +55 -0
  54. cnn_classifier/models/summary.py +39 -0
  55. cnn_classifier/models/torchvision.py +46 -0
  56. cnn_classifier/models/transfer.py +193 -0
  57. cnn_classifier/runs/__init__.py +6 -0
  58. cnn_classifier/runs/artifacts.py +50 -0
  59. cnn_classifier/runs/context.py +130 -0
  60. cnn_classifier/tracking/__init__.py +34 -0
  61. cnn_classifier/tracking/base.py +16 -0
  62. cnn_classifier/tracking/composite.py +20 -0
  63. cnn_classifier/tracking/console.py +14 -0
  64. cnn_classifier/tracking/csv.py +19 -0
  65. cnn_classifier/tracking/tensorboard.py +22 -0
  66. cnn_classifier/training/__init__.py +37 -0
  67. cnn_classifier/training/amp.py +51 -0
  68. cnn_classifier/training/callbacks.py +229 -0
  69. cnn_classifier/training/checkpoint.py +151 -0
  70. cnn_classifier/training/losses.py +67 -0
  71. cnn_classifier/training/optim.py +40 -0
  72. cnn_classifier/training/schedulers.py +53 -0
  73. cnn_classifier/training/state.py +35 -0
  74. cnn_classifier/training/trainer.py +357 -0
  75. cnn_classifier/viz/__init__.py +42 -0
  76. cnn_classifier/viz/architecture.py +276 -0
  77. cnn_classifier/viz/compare.py +36 -0
  78. cnn_classifier/viz/data.py +56 -0
  79. cnn_classifier/viz/evaluation.py +134 -0
  80. cnn_classifier/viz/features.py +122 -0
  81. cnn_classifier/viz/report.py +821 -0
  82. cnn_classifier/viz/style.py +69 -0
  83. cnn_classifier/viz/templates/report.html.j2 +355 -0
  84. cnn_classifier/viz/training.py +83 -0
  85. cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
  86. cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
  87. cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
  88. cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  89. 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)