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,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)