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,61 @@
|
|
|
1
|
+
"""Error analysis helpers for evaluation reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ErrorExample:
|
|
13
|
+
index: int
|
|
14
|
+
true: str
|
|
15
|
+
predicted: str
|
|
16
|
+
confidence: float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def most_confident_errors(
|
|
20
|
+
probabilities: np.ndarray,
|
|
21
|
+
y_true: np.ndarray,
|
|
22
|
+
class_names: Sequence[str],
|
|
23
|
+
max_examples: int = 32,
|
|
24
|
+
) -> list[ErrorExample]:
|
|
25
|
+
"""Return wrong predictions ranked by model confidence."""
|
|
26
|
+
probs = np.asarray(probabilities, dtype=np.float64)
|
|
27
|
+
truth = np.asarray(y_true, dtype=np.int64)
|
|
28
|
+
predictions = probs.argmax(axis=1)
|
|
29
|
+
confidence = probs.max(axis=1)
|
|
30
|
+
wrong = np.flatnonzero(predictions != truth)
|
|
31
|
+
order = wrong[np.argsort(confidence[wrong])[::-1]][:max_examples]
|
|
32
|
+
return [
|
|
33
|
+
ErrorExample(
|
|
34
|
+
index=int(index),
|
|
35
|
+
true=class_names[int(truth[index])],
|
|
36
|
+
predicted=class_names[int(predictions[index])],
|
|
37
|
+
confidence=float(confidence[index]),
|
|
38
|
+
)
|
|
39
|
+
for index in order
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def confusion_pairs(
|
|
44
|
+
y_true: np.ndarray,
|
|
45
|
+
y_pred: np.ndarray,
|
|
46
|
+
class_names: Sequence[str],
|
|
47
|
+
) -> list[tuple[str, str, int]]:
|
|
48
|
+
"""Return off-diagonal confusion pairs sorted by count."""
|
|
49
|
+
truth = np.asarray(y_true, dtype=np.int64)
|
|
50
|
+
predictions = np.asarray(y_pred, dtype=np.int64)
|
|
51
|
+
counts: dict[tuple[int, int], int] = {}
|
|
52
|
+
for actual, predicted in zip(truth, predictions, strict=True):
|
|
53
|
+
if actual == predicted:
|
|
54
|
+
continue
|
|
55
|
+
key = (int(actual), int(predicted))
|
|
56
|
+
counts[key] = counts.get(key, 0) + 1
|
|
57
|
+
ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
|
58
|
+
return [
|
|
59
|
+
(class_names[actual], class_names[predicted], count)
|
|
60
|
+
for (actual, predicted), count in ordered
|
|
61
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Inference, TTA, metrics, and prediction artifact helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import torch
|
|
11
|
+
from torch import nn
|
|
12
|
+
from torch.utils.data import DataLoader
|
|
13
|
+
|
|
14
|
+
from cnn_classifier.config.schema import EvaluationConfig
|
|
15
|
+
from cnn_classifier.core.errors import DataError
|
|
16
|
+
from cnn_classifier.evaluation.bootstrap import ConfidenceInterval, bootstrap_ci
|
|
17
|
+
from cnn_classifier.evaluation.latency import LatencyReport, measure_latency
|
|
18
|
+
from cnn_classifier.evaluation.metrics import MetricsReport, accuracy, classification_metrics
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class EvaluationResult:
|
|
23
|
+
metrics: MetricsReport
|
|
24
|
+
probabilities: np.ndarray
|
|
25
|
+
y_true: np.ndarray
|
|
26
|
+
y_pred: np.ndarray
|
|
27
|
+
indices: np.ndarray
|
|
28
|
+
paths: tuple[str, ...]
|
|
29
|
+
latency: LatencyReport | None
|
|
30
|
+
base_metrics: MetricsReport | None = None
|
|
31
|
+
tta_metrics: MetricsReport | None = None
|
|
32
|
+
confidence_intervals: dict[str, ConfidenceInterval] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@torch.no_grad()
|
|
36
|
+
def _predict(
|
|
37
|
+
model: nn.Module,
|
|
38
|
+
loader: DataLoader[Any],
|
|
39
|
+
device: torch.device,
|
|
40
|
+
config: EvaluationConfig,
|
|
41
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
42
|
+
model.eval()
|
|
43
|
+
base_parts: list[np.ndarray] = []
|
|
44
|
+
tta_parts: list[np.ndarray] = []
|
|
45
|
+
labels_parts: list[np.ndarray] = []
|
|
46
|
+
index_parts: list[np.ndarray] = []
|
|
47
|
+
use_hflip = config.tta.enabled and "hflip" in config.tta.transforms
|
|
48
|
+
for images, labels, indices in loader:
|
|
49
|
+
images = images.to(device)
|
|
50
|
+
base = torch.softmax(model(images), dim=1)
|
|
51
|
+
base_parts.append(base.cpu().numpy())
|
|
52
|
+
if use_hflip:
|
|
53
|
+
flipped = torch.flip(images, dims=[3])
|
|
54
|
+
tta = (base + torch.softmax(model(flipped), dim=1)) / 2.0
|
|
55
|
+
else:
|
|
56
|
+
tta = base
|
|
57
|
+
tta_parts.append(tta.cpu().numpy())
|
|
58
|
+
labels_parts.append(labels.numpy())
|
|
59
|
+
index_parts.append(indices.numpy())
|
|
60
|
+
if not base_parts:
|
|
61
|
+
raise DataError("evaluation loader produced no samples")
|
|
62
|
+
return (
|
|
63
|
+
np.concatenate(base_parts),
|
|
64
|
+
np.concatenate(tta_parts),
|
|
65
|
+
np.concatenate(labels_parts),
|
|
66
|
+
np.concatenate(index_parts),
|
|
67
|
+
np.concatenate(index_parts),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def evaluate_model(
|
|
72
|
+
model: nn.Module,
|
|
73
|
+
loader: DataLoader[Any],
|
|
74
|
+
device: torch.device | str,
|
|
75
|
+
class_names: tuple[str, ...] | list[str],
|
|
76
|
+
config: EvaluationConfig,
|
|
77
|
+
) -> EvaluationResult:
|
|
78
|
+
"""Evaluate on a loader without touching training or test state."""
|
|
79
|
+
runtime_device = torch.device(device) if isinstance(device, str) else device
|
|
80
|
+
was_training = model.training
|
|
81
|
+
base_probs, tta_probs, y_true, indices, _ = _predict(model, loader, runtime_device, config)
|
|
82
|
+
y_pred = base_probs.argmax(axis=1)
|
|
83
|
+
names = tuple(class_names)
|
|
84
|
+
base_metrics = classification_metrics(y_true, y_pred, base_probs, names, top_k=config.top_k)
|
|
85
|
+
tta_metrics = None
|
|
86
|
+
if config.tta.enabled:
|
|
87
|
+
tta_pred = tta_probs.argmax(axis=1)
|
|
88
|
+
tta_metrics = classification_metrics(y_true, tta_pred, tta_probs, names, top_k=config.top_k)
|
|
89
|
+
metrics = tta_metrics or base_metrics
|
|
90
|
+
intervals: dict[str, ConfidenceInterval] = {}
|
|
91
|
+
if config.bootstrap.enabled:
|
|
92
|
+
used_probs = tta_probs if config.tta.enabled else base_probs
|
|
93
|
+
used_pred = used_probs.argmax(axis=1)
|
|
94
|
+
intervals["accuracy"] = bootstrap_ci(
|
|
95
|
+
y_true,
|
|
96
|
+
used_pred,
|
|
97
|
+
accuracy,
|
|
98
|
+
n_resamples=config.bootstrap.n_resamples,
|
|
99
|
+
confidence=config.bootstrap.confidence,
|
|
100
|
+
seed=0,
|
|
101
|
+
stratify=config.bootstrap.stratify,
|
|
102
|
+
)
|
|
103
|
+
latency = None
|
|
104
|
+
if config.latency.enabled:
|
|
105
|
+
sample_shape = loader.dataset[0][0].shape
|
|
106
|
+
input_shape = (
|
|
107
|
+
int(sample_shape[0]),
|
|
108
|
+
int(sample_shape[1]),
|
|
109
|
+
int(sample_shape[2]),
|
|
110
|
+
)
|
|
111
|
+
latency = measure_latency(
|
|
112
|
+
model,
|
|
113
|
+
input_shape,
|
|
114
|
+
runtime_device,
|
|
115
|
+
config.latency.warmup,
|
|
116
|
+
config.latency.iterations,
|
|
117
|
+
)
|
|
118
|
+
model.train(was_training)
|
|
119
|
+
return EvaluationResult(
|
|
120
|
+
metrics=metrics,
|
|
121
|
+
probabilities=tta_probs if config.tta.enabled else base_probs,
|
|
122
|
+
y_true=y_true,
|
|
123
|
+
y_pred=tta_probs.argmax(axis=1) if config.tta.enabled else y_pred,
|
|
124
|
+
indices=indices,
|
|
125
|
+
paths=tuple(str(int(index)) for index in indices),
|
|
126
|
+
latency=latency,
|
|
127
|
+
base_metrics=base_metrics,
|
|
128
|
+
tta_metrics=tta_metrics,
|
|
129
|
+
confidence_intervals=intervals,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_predictions(result: EvaluationResult, path: Path) -> Path:
|
|
134
|
+
"""Save prediction arrays in a portable ``.npz`` archive."""
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
np.savez_compressed(
|
|
137
|
+
path,
|
|
138
|
+
probabilities=result.probabilities,
|
|
139
|
+
y_true=result.y_true,
|
|
140
|
+
y_pred=result.y_pred,
|
|
141
|
+
indices=result.indices,
|
|
142
|
+
)
|
|
143
|
+
return path
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Inference latency and throughput measurement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
from torch import nn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class LatencyReport:
|
|
15
|
+
mean_ms: float
|
|
16
|
+
p50_ms: float
|
|
17
|
+
p95_ms: float
|
|
18
|
+
throughput: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _synchronize(device: torch.device) -> None:
|
|
22
|
+
if device.type == "cuda":
|
|
23
|
+
torch.cuda.synchronize(device)
|
|
24
|
+
elif device.type == "mps":
|
|
25
|
+
torch.mps.synchronize()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@torch.no_grad()
|
|
29
|
+
def measure_latency(
|
|
30
|
+
model: nn.Module,
|
|
31
|
+
input_shape: tuple[int, int, int],
|
|
32
|
+
device: torch.device | str,
|
|
33
|
+
warmup: int = 20,
|
|
34
|
+
iterations: int = 100,
|
|
35
|
+
) -> LatencyReport:
|
|
36
|
+
"""Measure single-image forward-pass latency with warmup and sync."""
|
|
37
|
+
runtime_device = torch.device(device) if isinstance(device, str) else device
|
|
38
|
+
was_training = model.training
|
|
39
|
+
model.eval()
|
|
40
|
+
sample = torch.zeros(1, *input_shape, device=runtime_device)
|
|
41
|
+
for _ in range(max(0, warmup)):
|
|
42
|
+
model(sample)
|
|
43
|
+
_synchronize(runtime_device)
|
|
44
|
+
timings = []
|
|
45
|
+
for _ in range(max(1, iterations)):
|
|
46
|
+
start = time.perf_counter()
|
|
47
|
+
model(sample)
|
|
48
|
+
_synchronize(runtime_device)
|
|
49
|
+
timings.append((time.perf_counter() - start) * 1000.0)
|
|
50
|
+
model.train(was_training)
|
|
51
|
+
values = np.array(timings, dtype=np.float64)
|
|
52
|
+
mean_ms = float(values.mean())
|
|
53
|
+
return LatencyReport(
|
|
54
|
+
mean_ms=mean_ms,
|
|
55
|
+
p50_ms=float(np.percentile(values, 50)),
|
|
56
|
+
p95_ms=float(np.percentile(values, 95)),
|
|
57
|
+
throughput=float(1000.0 / mean_ms) if mean_ms > 0 else 0.0,
|
|
58
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Classification metrics with JSON-friendly reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from cnn_classifier.evaluation.calibration import CalibrationReport, expected_calibration_error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ClassMetrics:
|
|
14
|
+
precision: float
|
|
15
|
+
recall: float
|
|
16
|
+
f1: float
|
|
17
|
+
support: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MetricsReport:
|
|
22
|
+
accuracy: float
|
|
23
|
+
top_k_accuracy: float
|
|
24
|
+
macro_f1: float
|
|
25
|
+
per_class: dict[str, ClassMetrics]
|
|
26
|
+
confusion_matrix: list[list[int]]
|
|
27
|
+
nll: float | None
|
|
28
|
+
calibration: CalibrationReport | None
|
|
29
|
+
support: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def accuracy(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
|
33
|
+
"""Return top-1 accuracy for equal-length arrays."""
|
|
34
|
+
truth = np.asarray(y_true)
|
|
35
|
+
predictions = np.asarray(y_pred)
|
|
36
|
+
if len(truth) != len(predictions):
|
|
37
|
+
raise ValueError("y_true and y_pred must have the same length")
|
|
38
|
+
if len(truth) == 0:
|
|
39
|
+
return 0.0
|
|
40
|
+
return float(np.mean(truth == predictions))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classification_metrics(
|
|
44
|
+
y_true: np.ndarray,
|
|
45
|
+
y_pred: np.ndarray,
|
|
46
|
+
probabilities: np.ndarray,
|
|
47
|
+
class_names: list[str] | tuple[str, ...],
|
|
48
|
+
top_k: int = 3,
|
|
49
|
+
) -> MetricsReport:
|
|
50
|
+
"""Compute top-k, per-class, confusion, NLL, and calibration metrics."""
|
|
51
|
+
truth = np.asarray(y_true, dtype=np.int64)
|
|
52
|
+
predictions = np.asarray(y_pred, dtype=np.int64)
|
|
53
|
+
probs = np.asarray(probabilities, dtype=np.float64)
|
|
54
|
+
names = tuple(class_names)
|
|
55
|
+
if len(truth) != len(predictions) or len(truth) != len(probs):
|
|
56
|
+
raise ValueError("y_true, y_pred, and probabilities must have equal length")
|
|
57
|
+
if probs.ndim != 2 or probs.shape[1] != len(names):
|
|
58
|
+
raise ValueError("probabilities must be [N, len(class_names)]")
|
|
59
|
+
count = len(names)
|
|
60
|
+
confusion = [[0 for _ in range(count)] for _ in range(count)]
|
|
61
|
+
for true_label, predicted in zip(truth, predictions, strict=True):
|
|
62
|
+
confusion[int(true_label)][int(predicted)] += 1
|
|
63
|
+
per_class: dict[str, ClassMetrics] = {}
|
|
64
|
+
f1_scores: list[float] = []
|
|
65
|
+
for index, name in enumerate(names):
|
|
66
|
+
true_positive = confusion[index][index]
|
|
67
|
+
false_positive = sum(confusion[row][index] for row in range(count) if row != index)
|
|
68
|
+
false_negative = sum(confusion[index][column] for column in range(count) if column != index)
|
|
69
|
+
predicted_positive = true_positive + false_positive
|
|
70
|
+
actual_positive = true_positive + false_negative
|
|
71
|
+
precision = true_positive / predicted_positive if predicted_positive else 0.0
|
|
72
|
+
recall = true_positive / actual_positive if actual_positive else 0.0
|
|
73
|
+
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
|
|
74
|
+
support = sum(confusion[index])
|
|
75
|
+
per_class[name] = ClassMetrics(
|
|
76
|
+
precision=float(precision), recall=float(recall), f1=float(f1), support=int(support)
|
|
77
|
+
)
|
|
78
|
+
f1_scores.append(f1)
|
|
79
|
+
top_k = max(1, min(top_k, count))
|
|
80
|
+
top_indices = np.argsort(probs, axis=1)[:, -top_k:]
|
|
81
|
+
top_k_accuracy = float(
|
|
82
|
+
np.mean([truth[index] in top_indices[index] for index in range(len(truth))])
|
|
83
|
+
)
|
|
84
|
+
nll = float(-np.mean(np.log(np.clip(probs[np.arange(len(truth)), truth], 1e-12, 1.0))))
|
|
85
|
+
return MetricsReport(
|
|
86
|
+
accuracy=accuracy(truth, predictions),
|
|
87
|
+
top_k_accuracy=top_k_accuracy,
|
|
88
|
+
macro_f1=float(np.mean(f1_scores)),
|
|
89
|
+
per_class=per_class,
|
|
90
|
+
confusion_matrix=confusion,
|
|
91
|
+
nll=nll,
|
|
92
|
+
calibration=expected_calibration_error(probs, truth),
|
|
93
|
+
support=len(truth),
|
|
94
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Single-image and folder prediction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
from PIL import Image
|
|
13
|
+
from torch import nn
|
|
14
|
+
|
|
15
|
+
from cnn_classifier.core.errors import DataError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Predictions:
|
|
20
|
+
paths: tuple[Path, ...]
|
|
21
|
+
probabilities: np.ndarray
|
|
22
|
+
predicted_labels: np.ndarray
|
|
23
|
+
class_names: tuple[str, ...]
|
|
24
|
+
|
|
25
|
+
def top_k(self, k: int = 3) -> list[list[tuple[str, float]]]:
|
|
26
|
+
order = np.argsort(self.probabilities, axis=1)[:, -k:][:, ::-1]
|
|
27
|
+
return [
|
|
28
|
+
[(self.class_names[int(index)], float(row[index])) for index in row] for row in order
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@torch.no_grad()
|
|
33
|
+
def predict_paths(
|
|
34
|
+
model: nn.Module,
|
|
35
|
+
paths: Sequence[Path],
|
|
36
|
+
transform: Any,
|
|
37
|
+
device: torch.device | str,
|
|
38
|
+
class_names: Sequence[str],
|
|
39
|
+
) -> Predictions:
|
|
40
|
+
"""Predict a sequence of images without requiring a DataLoader."""
|
|
41
|
+
runtime_device = torch.device(device) if isinstance(device, str) else device
|
|
42
|
+
was_training = model.training
|
|
43
|
+
model.eval()
|
|
44
|
+
batch = []
|
|
45
|
+
for path in paths:
|
|
46
|
+
try:
|
|
47
|
+
with Image.open(path) as image:
|
|
48
|
+
tensor = transform(image.copy())
|
|
49
|
+
except (OSError, ValueError) as exc:
|
|
50
|
+
raise DataError(f"cannot read prediction image {path}: {exc}") from exc
|
|
51
|
+
if tensor.ndim == 3:
|
|
52
|
+
tensor = tensor.unsqueeze(0)
|
|
53
|
+
batch.append(tensor)
|
|
54
|
+
if not batch:
|
|
55
|
+
raise DataError("predict_paths received no paths")
|
|
56
|
+
probabilities = torch.softmax(model(torch.cat(batch).to(runtime_device)), dim=1)
|
|
57
|
+
model.train(was_training)
|
|
58
|
+
labels = probabilities.argmax(dim=1).cpu().numpy()
|
|
59
|
+
return Predictions(
|
|
60
|
+
paths=tuple(Path(path) for path in paths),
|
|
61
|
+
probabilities=probabilities.cpu().numpy(),
|
|
62
|
+
predicted_labels=np.asarray(labels),
|
|
63
|
+
class_names=tuple(class_names),
|
|
64
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Model builders and registry."""
|
|
2
|
+
|
|
3
|
+
from cnn_classifier.models.base import (
|
|
4
|
+
MODELS,
|
|
5
|
+
ModelBuildContext,
|
|
6
|
+
ModelBuilder,
|
|
7
|
+
build_model,
|
|
8
|
+
register_model,
|
|
9
|
+
)
|
|
10
|
+
from cnn_classifier.models.residual_cnn import (
|
|
11
|
+
ResidualCNN,
|
|
12
|
+
ResidualCNNBuilder,
|
|
13
|
+
ResidualCNNParams,
|
|
14
|
+
StageConfig,
|
|
15
|
+
StemConfig,
|
|
16
|
+
)
|
|
17
|
+
from cnn_classifier.models.simple_cnn import SimpleCNN, SimpleCNNBuilder, SimpleCNNParams
|
|
18
|
+
from cnn_classifier.models.summary import model_summary
|
|
19
|
+
from cnn_classifier.models.torchvision import TorchvisionBuilder, TorchvisionParams
|
|
20
|
+
from cnn_classifier.models.transfer import (
|
|
21
|
+
apply_stem_adaptation,
|
|
22
|
+
apply_transfer,
|
|
23
|
+
build_param_groups,
|
|
24
|
+
replace_classifier,
|
|
25
|
+
unfreeze_modules,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"MODELS",
|
|
30
|
+
"ModelBuildContext",
|
|
31
|
+
"ModelBuilder",
|
|
32
|
+
"ResidualCNN",
|
|
33
|
+
"ResidualCNNBuilder",
|
|
34
|
+
"ResidualCNNParams",
|
|
35
|
+
"SimpleCNN",
|
|
36
|
+
"SimpleCNNBuilder",
|
|
37
|
+
"SimpleCNNParams",
|
|
38
|
+
"StageConfig",
|
|
39
|
+
"StemConfig",
|
|
40
|
+
"TorchvisionBuilder",
|
|
41
|
+
"TorchvisionParams",
|
|
42
|
+
"apply_stem_adaptation",
|
|
43
|
+
"apply_transfer",
|
|
44
|
+
"build_model",
|
|
45
|
+
"build_param_groups",
|
|
46
|
+
"model_summary",
|
|
47
|
+
"register_model",
|
|
48
|
+
"replace_classifier",
|
|
49
|
+
"unfreeze_modules",
|
|
50
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Model builder protocol, registry, and construction entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from torch import nn
|
|
11
|
+
|
|
12
|
+
from cnn_classifier.config.schema import ModelConfig, TransferConfig
|
|
13
|
+
from cnn_classifier.core.errors import ConfigError, RegistryError
|
|
14
|
+
from cnn_classifier.core.registry import Registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ModelBuildContext:
|
|
19
|
+
num_classes: int
|
|
20
|
+
in_channels: int
|
|
21
|
+
image_size: tuple[int, int]
|
|
22
|
+
transfer: TransferConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelBuilder(Protocol):
|
|
26
|
+
Params: type[BaseModel]
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def build(params: Any, context: ModelBuildContext) -> nn.Module:
|
|
30
|
+
"""Construct a model from validated parameters and context."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
MODELS: Registry[type[Any]] = Registry("model")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_model(name: str) -> Callable[[type[Any]], type[Any]]:
|
|
38
|
+
"""Register a model builder class under ``name``."""
|
|
39
|
+
return MODELS.register(name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_model(
|
|
43
|
+
config: ModelConfig,
|
|
44
|
+
*,
|
|
45
|
+
num_classes: int,
|
|
46
|
+
in_channels: int,
|
|
47
|
+
image_size: tuple[int, int],
|
|
48
|
+
) -> nn.Module:
|
|
49
|
+
"""Resolve, validate, and construct the configured model."""
|
|
50
|
+
try:
|
|
51
|
+
builder = MODELS.get(config.name)
|
|
52
|
+
except RegistryError as exc:
|
|
53
|
+
raise ConfigError(f"unknown model {config.name!r}") from exc
|
|
54
|
+
params = builder.Params.model_validate(config.params)
|
|
55
|
+
context = ModelBuildContext(
|
|
56
|
+
num_classes=num_classes,
|
|
57
|
+
in_channels=in_channels,
|
|
58
|
+
image_size=image_size,
|
|
59
|
+
transfer=config.transfer,
|
|
60
|
+
)
|
|
61
|
+
model: nn.Module = builder.build(params, context)
|
|
62
|
+
return model
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Configurable residual CNN with an explicit stem and stage description."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
from torch import nn
|
|
10
|
+
|
|
11
|
+
from cnn_classifier.models.base import ModelBuildContext, register_model
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StemConfig(BaseModel):
|
|
15
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
16
|
+
|
|
17
|
+
channels: int = Field(gt=0)
|
|
18
|
+
kernel_size: int = Field(default=3, ge=1, le=7)
|
|
19
|
+
stride: int = Field(default=1, ge=1)
|
|
20
|
+
pool: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StageConfig(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
25
|
+
|
|
26
|
+
channels: int = Field(gt=0)
|
|
27
|
+
conv: bool = True
|
|
28
|
+
blocks: int = Field(default=1, ge=1)
|
|
29
|
+
downsample: Literal["none", "maxpool", "stride"] = "none"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResidualCNNParams(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
34
|
+
|
|
35
|
+
stem: StemConfig
|
|
36
|
+
stages: tuple[StageConfig, ...] = Field(min_length=1)
|
|
37
|
+
dropout: float = Field(default=0.3, ge=0.0, lt=1.0)
|
|
38
|
+
norm: Literal["batch", "none"] = "batch"
|
|
39
|
+
residual: bool = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _norm_layer(channels: int, norm: str) -> nn.Module:
|
|
43
|
+
if norm == "batch":
|
|
44
|
+
return nn.BatchNorm2d(channels)
|
|
45
|
+
return nn.Identity()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ResidualBlock(nn.Module):
|
|
49
|
+
def __init__(self, channels: int, norm: str, residual: bool) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
bias = norm == "none"
|
|
52
|
+
self.residual = residual
|
|
53
|
+
self.conv1 = nn.Conv2d(channels, channels, 3, 1, 1, bias=bias)
|
|
54
|
+
self.norm1 = _norm_layer(channels, norm)
|
|
55
|
+
self.conv2 = nn.Conv2d(channels, channels, 3, 1, 1, bias=bias)
|
|
56
|
+
self.norm2 = _norm_layer(channels, norm)
|
|
57
|
+
|
|
58
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
59
|
+
out = torch.relu(self.norm1(self.conv1(x)))
|
|
60
|
+
out = self.norm2(self.conv2(out))
|
|
61
|
+
if self.residual:
|
|
62
|
+
out = out + x
|
|
63
|
+
return torch.relu(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ResidualCNN(nn.Module):
|
|
67
|
+
def __init__(self, params: ResidualCNNParams, context: ModelBuildContext) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
layers: list[nn.Module] = []
|
|
70
|
+
stem = params.stem
|
|
71
|
+
layers.append(
|
|
72
|
+
nn.Conv2d(
|
|
73
|
+
context.in_channels,
|
|
74
|
+
stem.channels,
|
|
75
|
+
stem.kernel_size,
|
|
76
|
+
stem.stride,
|
|
77
|
+
stem.kernel_size // 2,
|
|
78
|
+
bias=params.norm == "none",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
layers.append(_norm_layer(stem.channels, params.norm))
|
|
82
|
+
layers.append(nn.ReLU(inplace=True))
|
|
83
|
+
if stem.pool:
|
|
84
|
+
layers.append(nn.MaxPool2d(2))
|
|
85
|
+
stage_spans: list[tuple[str, int, int, bool]] = [("stem", 0, len(layers), False)]
|
|
86
|
+
current = stem.channels
|
|
87
|
+
for stage_index, stage in enumerate(params.stages, start=1):
|
|
88
|
+
start = len(layers)
|
|
89
|
+
if stage.conv:
|
|
90
|
+
layers.append(
|
|
91
|
+
nn.Conv2d(current, stage.channels, 3, 1, 1, bias=params.norm == "none")
|
|
92
|
+
)
|
|
93
|
+
layers.append(_norm_layer(stage.channels, params.norm))
|
|
94
|
+
layers.append(nn.ReLU(inplace=True))
|
|
95
|
+
current = stage.channels
|
|
96
|
+
elif stage.channels != current:
|
|
97
|
+
raise ValueError("a stage without conv must keep the channel count")
|
|
98
|
+
for _ in range(stage.blocks):
|
|
99
|
+
layers.append(ResidualBlock(current, params.norm, params.residual))
|
|
100
|
+
if stage.downsample == "maxpool":
|
|
101
|
+
layers.append(nn.MaxPool2d(2))
|
|
102
|
+
elif stage.downsample == "stride":
|
|
103
|
+
layers.append(
|
|
104
|
+
nn.Sequential(
|
|
105
|
+
nn.Conv2d(current, current, 3, 2, 1, bias=params.norm == "none"),
|
|
106
|
+
_norm_layer(current, params.norm),
|
|
107
|
+
nn.ReLU(inplace=True),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
stage_spans.append(
|
|
111
|
+
(
|
|
112
|
+
f"stage {stage_index}",
|
|
113
|
+
start,
|
|
114
|
+
len(layers),
|
|
115
|
+
params.residual and stage.blocks > 0,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
self.stage_spans = tuple(stage_spans)
|
|
119
|
+
self.features = nn.Sequential(*layers)
|
|
120
|
+
self.head = nn.Sequential(
|
|
121
|
+
nn.AdaptiveAvgPool2d(1),
|
|
122
|
+
nn.Flatten(),
|
|
123
|
+
nn.Dropout(params.dropout),
|
|
124
|
+
nn.Linear(current, context.num_classes),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
128
|
+
result: torch.Tensor = self.head(self.features(x))
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@register_model("residual_cnn")
|
|
133
|
+
class ResidualCNNBuilder:
|
|
134
|
+
Params = ResidualCNNParams
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def build(params: ResidualCNNParams, context: ModelBuildContext) -> nn.Module:
|
|
138
|
+
return ResidualCNN(params, context)
|