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,17 @@
1
+ """Typed configuration package."""
2
+
3
+ from cnn_classifier.config.loader import apply_overrides, load_config
4
+ from cnn_classifier.config.resolve import resolve_config, validate_config_semantics
5
+ from cnn_classifier.config.schema import (
6
+ RunConfig,
7
+ TransformConfig,
8
+ )
9
+
10
+ __all__ = [
11
+ "RunConfig",
12
+ "TransformConfig",
13
+ "apply_overrides",
14
+ "load_config",
15
+ "resolve_config",
16
+ "validate_config_semantics",
17
+ ]
@@ -0,0 +1,102 @@
1
+ """YAML configuration loading, composition, and CLI overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from cnn_classifier.config.resolve import resolve_config, validate_config_semantics
13
+ from cnn_classifier.config.schema import RunConfig
14
+ from cnn_classifier.core.errors import ConfigError
15
+
16
+ _DEFAULT_CONFIG = Path(__file__).with_name("presets") / "default.yaml"
17
+
18
+
19
+ def _load_yaml(path: Path) -> dict[str, Any]:
20
+ try:
21
+ payload = yaml.safe_load(path.read_text(encoding="utf-8"))
22
+ except OSError as exc:
23
+ raise ConfigError(f"cannot read config {path}: {exc}") from exc
24
+ except yaml.YAMLError as exc:
25
+ raise ConfigError(f"invalid YAML in {path}: {exc}") from exc
26
+ if not isinstance(payload, dict):
27
+ raise ConfigError(f"config {path} must contain a YAML mapping")
28
+ return payload
29
+
30
+
31
+ def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
32
+ """Merge mappings recursively; lists and scalars replace."""
33
+ merged = copy.deepcopy(dict(base))
34
+ for key, value in override.items():
35
+ if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping):
36
+ merged[key] = deep_merge(merged[key], value)
37
+ else:
38
+ merged[key] = copy.deepcopy(value)
39
+ return merged
40
+
41
+
42
+ def _load_chain(path: Path, seen: set[Path]) -> dict[str, Any]:
43
+ resolved = path.resolve()
44
+ if resolved in seen:
45
+ raise ConfigError(f"cyclic defaults chain at {path}")
46
+ seen = {*seen, resolved}
47
+ payload = _load_yaml(resolved)
48
+ defaults = payload.pop("defaults", [])
49
+ if not isinstance(defaults, list):
50
+ raise ConfigError(f"defaults in {path} must be a list")
51
+ merged: dict[str, Any] = {}
52
+ for entry in defaults:
53
+ if not isinstance(entry, str):
54
+ raise ConfigError(f"defaults entries in {path} must be strings")
55
+ merged = deep_merge(merged, _load_chain(resolved.parent / entry, seen))
56
+ return deep_merge(merged, payload)
57
+
58
+
59
+ def apply_overrides(raw: Mapping[str, Any], overrides: Sequence[str]) -> dict[str, Any]:
60
+ """Apply ``a.b=value`` overrides without silently growing the schema."""
61
+ result = copy.deepcopy(dict(raw))
62
+ for override in overrides:
63
+ if "=" not in override:
64
+ raise ConfigError(f"override must be key=value; got {override!r}")
65
+ dotted, value_text = override.split("=", 1)
66
+ parts = dotted.split(".")
67
+ cursor: Any = result
68
+ for part in parts[:-1]:
69
+ if not isinstance(cursor, dict) or part not in cursor:
70
+ raise ConfigError(f"unknown override path: {dotted}")
71
+ cursor = cursor[part]
72
+ leaf = parts[-1]
73
+ if not isinstance(cursor, dict) or leaf not in cursor:
74
+ raise ConfigError(f"unknown override path: {dotted}")
75
+ try:
76
+ parsed = yaml.safe_load(value_text)
77
+ except yaml.YAMLError as exc:
78
+ raise ConfigError(f"invalid override value for {dotted}: {value_text!r}") from exc
79
+ cursor[leaf] = parsed
80
+ return result
81
+
82
+
83
+ def load_config(path: str | Path, overrides: Sequence[str] = ()) -> RunConfig:
84
+ """Load, compose, resolve, and validate an experiment configuration."""
85
+ config_path = Path(path)
86
+ if not config_path.is_file():
87
+ raise ConfigError(f"config file does not exist: {config_path}")
88
+ packaged = _load_yaml(_DEFAULT_CONFIG)
89
+ merged = deep_merge(packaged, _load_chain(config_path, seen=set()))
90
+ if overrides:
91
+ try:
92
+ resolved_defaults = RunConfig.model_validate(merged)
93
+ except Exception as exc:
94
+ raise ConfigError(f"invalid configuration {config_path}: {exc}") from exc
95
+ merged = apply_overrides(resolved_defaults.model_dump(mode="json"), overrides)
96
+ try:
97
+ config = RunConfig.model_validate(merged)
98
+ except Exception as exc:
99
+ raise ConfigError(f"invalid configuration {config_path}: {exc}") from exc
100
+ config = resolve_config(config, config_path.resolve().parent)
101
+ validate_config_semantics(config)
102
+ return config
@@ -0,0 +1,47 @@
1
+ schema_version: "1.0"
2
+
3
+ runtime:
4
+ device: auto
5
+ deterministic: true
6
+ num_workers: auto
7
+ persistent_workers: true
8
+ pin_memory: auto
9
+ channels_last: false
10
+ compile: false
11
+
12
+ tracking:
13
+ console: true
14
+ csv: true
15
+ tensorboard: true
16
+
17
+ report:
18
+ enabled: true
19
+ formats: [html, markdown]
20
+ figures:
21
+ - training_history
22
+ - confusion_matrix
23
+ - per_class
24
+ - calibration
25
+ - errors
26
+ - model_summary
27
+
28
+ evaluation:
29
+ metrics: [accuracy, top_k, macro_f1, per_class, confusion_matrix, ece, nll]
30
+ top_k: 3
31
+ bootstrap:
32
+ enabled: true
33
+ n_resamples: 1000
34
+ confidence: 0.95
35
+ stratify: true
36
+ calibration:
37
+ bins: 15
38
+ latency:
39
+ enabled: true
40
+ warmup: 20
41
+ iterations: 100
42
+ error_analysis:
43
+ max_examples: 32
44
+ tta:
45
+ enabled: false
46
+ transforms: [identity, hflip]
47
+ reduction: mean_probabilities
@@ -0,0 +1,108 @@
1
+ """Path resolution and semantic validation for configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from cnn_classifier.config.schema import DataConfig, RunConfig
8
+ from cnn_classifier.core.errors import ConfigError
9
+
10
+
11
+ def _absolute(path: Path | None, base_dir: Path) -> Path | None:
12
+ if path is None:
13
+ return None
14
+ return path if path.is_absolute() else (base_dir / path).resolve()
15
+
16
+
17
+ def resolve_config(config: RunConfig, base_dir: Path) -> RunConfig:
18
+ """Resolve data paths relative to the experiment file's directory."""
19
+ data = config.data.model_copy(
20
+ update={
21
+ "root": _absolute(config.data.root, base_dir),
22
+ "manifest": _absolute(config.data.manifest, base_dir),
23
+ "image_root": _absolute(config.data.image_root, base_dir),
24
+ }
25
+ )
26
+ return config.model_copy(update={"data": data})
27
+
28
+
29
+ def _check_channel_lengths(data: DataConfig) -> None:
30
+ norm = data.normalization
31
+ if norm.mode != "supplied":
32
+ return
33
+ if norm.mean is None or norm.std is None:
34
+ raise ConfigError("supplied normalization requires mean and std")
35
+ if len(norm.mean) != len(norm.std):
36
+ raise ConfigError("normalization mean and std must have the same length")
37
+ expected = {"rgb": 3, "gray": 1}.get(data.channels)
38
+ if expected is not None and len(norm.mean) != expected:
39
+ raise ConfigError(
40
+ f"normalization has {len(norm.mean)} channels but data.channels={data.channels!r}"
41
+ )
42
+
43
+
44
+ def validate_config_semantics(config: RunConfig) -> None:
45
+ """Reject cross-field combinations that would fail midway through a run."""
46
+ training = config.training
47
+ data = config.data
48
+ scheduler = training.scheduler
49
+
50
+ if training.epochs <= 0:
51
+ raise ConfigError("training.epochs must be positive")
52
+ if training.batch_size <= 0:
53
+ raise ConfigError("training.batch_size must be positive")
54
+ if training.grad_accum_steps <= 0:
55
+ raise ConfigError("training.grad_accum_steps must be positive")
56
+ if scheduler.warmup_epochs >= training.epochs:
57
+ raise ConfigError("scheduler.warmup_epochs must be smaller than training.epochs")
58
+ if not 0.0 <= scheduler.min_lr_ratio < 1.0:
59
+ raise ConfigError("scheduler.min_lr_ratio must be in [0, 1)")
60
+ if training.optimizer.lr <= 0:
61
+ raise ConfigError("optimizer.lr must be positive")
62
+ if training.optimizer.weight_decay < 0:
63
+ raise ConfigError("optimizer.weight_decay must be non-negative")
64
+ if not 0.0 <= training.loss.label_smoothing < 1.0:
65
+ raise ConfigError("loss.label_smoothing must be in [0, 1)")
66
+ if not 0.0 <= training.mixup_alpha <= 1.0:
67
+ raise ConfigError("training.mixup_alpha must be in [0, 1]")
68
+ if not 0.0 <= training.cutmix_alpha <= 1.0:
69
+ raise ConfigError("training.cutmix_alpha must be in [0, 1]")
70
+ if training.ema.enabled and not 0.0 < training.ema.decay < 1.0:
71
+ raise ConfigError("training.ema.decay must be in (0, 1)")
72
+ if training.early_stopping.patience < 0:
73
+ raise ConfigError("early_stopping.patience must be non-negative")
74
+ if training.amp == "fp16" and config.runtime.device in {"cpu", "mps"}:
75
+ raise ConfigError("fp16 AMP is only supported on CUDA devices")
76
+
77
+ if data.image_size[0] <= 0 or data.image_size[1] <= 0:
78
+ raise ConfigError("data.image_size dimensions must be positive")
79
+ if data.num_classes is not None and data.num_classes <= 0:
80
+ raise ConfigError("data.num_classes must be positive")
81
+ if data.split.mode in {"random", "random_stratified", "group"} and not (
82
+ 0.0 < data.split.val_fraction < 1.0
83
+ ):
84
+ raise ConfigError("data.split.val_fraction must be in (0, 1)")
85
+ if (
86
+ isinstance(data.class_names, tuple)
87
+ and data.num_classes is not None
88
+ and len(data.class_names) != data.num_classes
89
+ ):
90
+ raise ConfigError("data.class_names length must match data.num_classes")
91
+ if data.value_range.mode == "fixed":
92
+ if data.value_range.min is None or data.value_range.max is None:
93
+ raise ConfigError("fixed value_range requires min and max")
94
+ if data.value_range.min >= data.value_range.max:
95
+ raise ConfigError("value_range.min must be smaller than value_range.max")
96
+ _check_channel_lengths(data)
97
+
98
+ bootstrap = config.evaluation.bootstrap
99
+ if bootstrap.n_resamples <= 0:
100
+ raise ConfigError("bootstrap.n_resamples must be positive")
101
+ if not 0.0 < bootstrap.confidence < 1.0:
102
+ raise ConfigError("bootstrap.confidence must be in (0, 1)")
103
+ if config.evaluation.calibration.bins <= 0:
104
+ raise ConfigError("calibration.bins must be positive")
105
+ if config.evaluation.latency.warmup < 0:
106
+ raise ConfigError("latency.warmup must be non-negative")
107
+ if config.evaluation.latency.iterations <= 0:
108
+ raise ConfigError("latency.iterations must be positive")
@@ -0,0 +1,251 @@
1
+ """Typed configuration models for cnn-classifier.
2
+
3
+ Known keys are validated strictly; adapter and model parameters remain flexible
4
+ and are validated by their registered builders. Cross-field physical checks live
5
+ in :mod:`cnn_classifier.config.resolve` so they raise :class:`ConfigError`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Literal
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+ SCHEMA_VERSION = "1.0"
16
+
17
+
18
+ class ConfigModel(BaseModel):
19
+ """Base for strict configuration models."""
20
+
21
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
22
+
23
+
24
+ class ExperimentConfig(ConfigModel):
25
+ name: str
26
+ tags: tuple[str, ...] = ()
27
+ notes: str = ""
28
+ seed: int = 42
29
+
30
+
31
+ class SplitConfig(ConfigModel):
32
+ mode: Literal["random", "random_stratified", "group", "explicit", "train_only"] = (
33
+ "random_stratified"
34
+ )
35
+ val_fraction: float = 0.1
36
+ seed: int = 42
37
+ group_column: str | None = None
38
+
39
+
40
+ class NormalizationConfig(ConfigModel):
41
+ mode: Literal["compute", "supplied", "none"] = "compute"
42
+ mean: tuple[float, ...] | None = None
43
+ std: tuple[float, ...] | None = None
44
+ cache: bool = True
45
+
46
+
47
+ class ValueRangeConfig(ConfigModel):
48
+ mode: Literal["auto", "fixed"] = "auto"
49
+ min: float | None = None
50
+ max: float | None = None
51
+
52
+
53
+ class TransformConfig(ConfigModel):
54
+ model_config = ConfigDict(extra="allow", allow_inf_nan=False)
55
+
56
+ name: str
57
+
58
+ def params(self) -> dict[str, Any]:
59
+ """Return the transform-specific parameters."""
60
+ return dict(self.model_extra or {})
61
+
62
+
63
+ def _empty_transforms() -> dict[Literal["train", "eval"], list[TransformConfig]]:
64
+ return {"train": [], "eval": []}
65
+
66
+
67
+ class DataConfig(ConfigModel):
68
+ adapter: str
69
+ root: Path | None = None
70
+ manifest: Path | None = None
71
+ image_root: Path | None = None
72
+ class_names: tuple[str, ...] | Literal["auto"] = "auto"
73
+ num_classes: int | None = None
74
+ channels: Literal["rgb", "gray", "as_is"] = "rgb"
75
+ image_size: tuple[int, int] = (32, 32)
76
+ split: SplitConfig = Field(default_factory=SplitConfig)
77
+ normalization: NormalizationConfig = Field(default_factory=NormalizationConfig)
78
+ transforms: dict[Literal["train", "eval"], list[TransformConfig]] = Field(
79
+ default_factory=_empty_transforms
80
+ )
81
+ value_range: ValueRangeConfig = Field(default_factory=ValueRangeConfig)
82
+ adapter_params: dict[str, Any] = Field(default_factory=dict)
83
+
84
+
85
+ class ParamGroupConfig(ConfigModel):
86
+ pattern: str
87
+ lr_multiplier: float = 1.0
88
+ weight_decay_multiplier: float = 1.0
89
+
90
+
91
+ class UnfreezeEvent(ConfigModel):
92
+ epoch: int
93
+ modules: tuple[str, ...]
94
+
95
+
96
+ class TransferConfig(ConfigModel):
97
+ weights: str | None = None
98
+ freeze: Literal["none", "backbone", "all_but_head"] = "none"
99
+ train_bn: bool = False
100
+ unfreeze_schedule: tuple[UnfreezeEvent, ...] = ()
101
+ param_groups: tuple[ParamGroupConfig, ...] = ()
102
+
103
+
104
+ class ModelConfig(ConfigModel):
105
+ name: str
106
+ params: dict[str, Any] = Field(default_factory=dict)
107
+ transfer: TransferConfig = Field(default_factory=TransferConfig)
108
+
109
+
110
+ class OptimizerConfig(ConfigModel):
111
+ name: Literal["adam", "adamw", "sgd"] = "adamw"
112
+ lr: float = 1e-3
113
+ weight_decay: float = 0.0
114
+ momentum: float = 0.9
115
+
116
+
117
+ class SchedulerConfig(ConfigModel):
118
+ name: Literal["constant", "cosine", "cosine_warmup", "step", "plateau"] = "cosine"
119
+ warmup_epochs: int = 0
120
+ min_lr_ratio: float = 0.0
121
+ step_size: int = 10
122
+ gamma: float = 0.1
123
+ step_unit: Literal["epoch", "batch"] = "epoch"
124
+
125
+
126
+ class LossConfig(ConfigModel):
127
+ name: Literal["cross_entropy"] = "cross_entropy"
128
+ label_smoothing: float = 0.0
129
+
130
+
131
+ class EMAConfig(ConfigModel):
132
+ enabled: bool = False
133
+ decay: float = 0.999
134
+
135
+
136
+ class EarlyStoppingConfig(ConfigModel):
137
+ enabled: bool = True
138
+ monitor: str = "val_accuracy"
139
+ mode: Literal["min", "max"] = "max"
140
+ patience: int = 10
141
+ min_delta: float = 0.0
142
+
143
+
144
+ class CheckpointConfig(ConfigModel):
145
+ monitor: str = "val_accuracy"
146
+ mode: Literal["min", "max"] = "max"
147
+ save_last: bool = True
148
+
149
+
150
+ class TrainingConfig(ConfigModel):
151
+ epochs: int = 50
152
+ batch_size: int = 128
153
+ grad_accum_steps: int = 1
154
+ selection_metric: str = "val_accuracy"
155
+ optimizer: OptimizerConfig = Field(default_factory=OptimizerConfig)
156
+ scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
157
+ loss: LossConfig = Field(default_factory=LossConfig)
158
+ grad_clip_norm: float | None = None
159
+ amp: Literal["auto", "off", "fp16", "bf16"] = "auto"
160
+ mixup_alpha: float = 0.0
161
+ cutmix_alpha: float = 0.0
162
+ ema: EMAConfig = Field(default_factory=EMAConfig)
163
+ early_stopping: EarlyStoppingConfig = Field(default_factory=EarlyStoppingConfig)
164
+ checkpoint: CheckpointConfig = Field(default_factory=CheckpointConfig)
165
+
166
+
167
+ class BootstrapConfig(ConfigModel):
168
+ enabled: bool = True
169
+ n_resamples: int = 1000
170
+ confidence: float = 0.95
171
+ stratify: bool = True
172
+
173
+
174
+ class CalibrationConfig(ConfigModel):
175
+ bins: int = 15
176
+
177
+
178
+ class LatencyConfig(ConfigModel):
179
+ enabled: bool = True
180
+ warmup: int = 20
181
+ iterations: int = 100
182
+
183
+
184
+ class ErrorAnalysisConfig(ConfigModel):
185
+ max_examples: int = 32
186
+
187
+
188
+ class TTAConfig(ConfigModel):
189
+ enabled: bool = False
190
+ transforms: tuple[Literal["identity", "hflip"], ...] = ("identity", "hflip")
191
+ reduction: Literal["mean_probabilities"] = "mean_probabilities"
192
+
193
+
194
+ class EvaluationConfig(ConfigModel):
195
+ metrics: tuple[str, ...] = (
196
+ "accuracy",
197
+ "top_k",
198
+ "macro_f1",
199
+ "per_class",
200
+ "confusion_matrix",
201
+ "ece",
202
+ "nll",
203
+ )
204
+ top_k: int = 3
205
+ bootstrap: BootstrapConfig = Field(default_factory=BootstrapConfig)
206
+ calibration: CalibrationConfig = Field(default_factory=CalibrationConfig)
207
+ latency: LatencyConfig = Field(default_factory=LatencyConfig)
208
+ error_analysis: ErrorAnalysisConfig = Field(default_factory=ErrorAnalysisConfig)
209
+ tta: TTAConfig = Field(default_factory=TTAConfig)
210
+
211
+
212
+ class TrackingConfig(ConfigModel):
213
+ console: bool = True
214
+ csv: bool = True
215
+ tensorboard: bool = True
216
+
217
+
218
+ class ReportConfig(ConfigModel):
219
+ enabled: bool = True
220
+ formats: tuple[Literal["html", "markdown"], ...] = ("html", "markdown")
221
+ figures: tuple[str, ...] = (
222
+ "training_history",
223
+ "confusion_matrix",
224
+ "per_class",
225
+ "calibration",
226
+ "errors",
227
+ "model_summary",
228
+ )
229
+
230
+
231
+ class RuntimeConfig(ConfigModel):
232
+ device: str = "auto"
233
+ deterministic: bool = True
234
+ num_workers: int | Literal["auto"] = "auto"
235
+ persistent_workers: bool = True
236
+ pin_memory: bool | Literal["auto"] = "auto"
237
+ channels_last: bool = False
238
+ compile: bool = False
239
+
240
+
241
+ class RunConfig(ConfigModel):
242
+ schema_version: Literal["1.0"] = "1.0"
243
+ defaults: tuple[str, ...] = ()
244
+ experiment: ExperimentConfig
245
+ data: DataConfig
246
+ model: ModelConfig
247
+ training: TrainingConfig = Field(default_factory=TrainingConfig)
248
+ evaluation: EvaluationConfig = Field(default_factory=EvaluationConfig)
249
+ tracking: TrackingConfig = Field(default_factory=TrackingConfig)
250
+ report: ReportConfig = Field(default_factory=ReportConfig)
251
+ runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
@@ -0,0 +1,19 @@
1
+ """Core package: errors, registries, device policy, seeding, and hashing."""
2
+
3
+ from cnn_classifier.core.errors import (
4
+ CnnClassifierError,
5
+ ConfigError,
6
+ DataError,
7
+ RegistryError,
8
+ RunError,
9
+ )
10
+ from cnn_classifier.core.registry import Registry
11
+
12
+ __all__ = [
13
+ "CnnClassifierError",
14
+ "ConfigError",
15
+ "DataError",
16
+ "Registry",
17
+ "RegistryError",
18
+ "RunError",
19
+ ]
@@ -0,0 +1,50 @@
1
+ """Device resolution for CUDA, Apple MPS, and CPU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+
7
+ import torch
8
+
9
+ from cnn_classifier.core.errors import ConfigError
10
+
11
+
12
+ def resolve_device(spec: str = "auto") -> torch.device:
13
+ """Resolve ``auto`` or an explicit device string into a ``torch.device``.
14
+
15
+ ``auto`` prefers CUDA, then MPS, then CPU. Explicit CUDA or MPS requests are
16
+ rejected when the backend is unavailable, so a run never silently changes
17
+ hardware.
18
+ """
19
+ value = spec.strip().lower()
20
+ if value == "auto":
21
+ if torch.cuda.is_available():
22
+ return torch.device("cuda")
23
+ if torch.backends.mps.is_available():
24
+ return torch.device("mps")
25
+ return torch.device("cpu")
26
+
27
+ try:
28
+ device = torch.device(value)
29
+ except (RuntimeError, ValueError) as exc:
30
+ raise ConfigError(f"invalid device {spec!r}") from exc
31
+
32
+ if device.type == "cuda" and not torch.cuda.is_available():
33
+ raise ConfigError(f"CUDA device {spec!r} requested but CUDA is unavailable")
34
+ if device.type == "mps" and not torch.backends.mps.is_available():
35
+ raise ConfigError(f"MPS device {spec!r} requested but MPS is unavailable")
36
+ if device.type not in {"cpu", "cuda", "mps"}:
37
+ raise ConfigError(f"unsupported device type {device.type!r}")
38
+ return device
39
+
40
+
41
+ def device_report() -> dict[str, object]:
42
+ """Return runtime and device availability information."""
43
+ return {
44
+ "device": str(resolve_device("auto")),
45
+ "cuda_available": torch.cuda.is_available(),
46
+ "mps_available": torch.backends.mps.is_available(),
47
+ "torch_version": torch.__version__,
48
+ "python_version": platform.python_version(),
49
+ "platform": platform.platform(),
50
+ }
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy for cnn-classifier."""
2
+
3
+
4
+ class CnnClassifierError(Exception):
5
+ """Base class for all package-specific errors."""
6
+
7
+
8
+ class ConfigError(CnnClassifierError):
9
+ """A configuration is malformed or incompatible with the selected device."""
10
+
11
+
12
+ class DataError(CnnClassifierError):
13
+ """A dataset, image, manifest, or split is invalid."""
14
+
15
+
16
+ class RegistryError(CnnClassifierError):
17
+ """A registry lookup or registration failed."""
18
+
19
+
20
+ class RunError(CnnClassifierError):
21
+ """A run, checkpoint, or artifact is invalid."""
@@ -0,0 +1,18 @@
1
+ """Stable hashing for configs, manifests, and run fingerprints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+
9
+ def stable_hash(payload: object) -> str:
10
+ """Return a short canonical SHA-256 hash of a JSON-compatible payload."""
11
+ text = json.dumps(
12
+ payload,
13
+ sort_keys=True,
14
+ separators=(",", ":"),
15
+ default=str,
16
+ allow_nan=False,
17
+ )
18
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
@@ -0,0 +1,46 @@
1
+ """Small typed registries for datasets, models, transforms, and components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Generic, TypeVar
7
+
8
+ from cnn_classifier.core.errors import RegistryError
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class Registry(Generic[T]):
14
+ """A name-to-factory registry with explicit duplicate and lookup errors."""
15
+
16
+ def __init__(self, name: str) -> None:
17
+ self._name = name
18
+ self._items: dict[str, T] = {}
19
+
20
+ def register(self, name: str) -> Callable[[T], T]:
21
+ """Return a decorator that registers an item under ``name``."""
22
+ cleaned = name.strip()
23
+ if not cleaned:
24
+ raise RegistryError(f"{self._name} registry cannot register an empty name")
25
+ if cleaned in self._items:
26
+ raise RegistryError(f"{cleaned!r} is already registered in {self._name}")
27
+
28
+ def decorator(item: T) -> T:
29
+ self._items[cleaned] = item
30
+ return item
31
+
32
+ return decorator
33
+
34
+ def get(self, name: str) -> T:
35
+ """Return the item registered under ``name``."""
36
+ try:
37
+ return self._items[name]
38
+ except KeyError as exc:
39
+ available = ", ".join(self._items) or "<none>"
40
+ raise RegistryError(
41
+ f"{name!r} is not registered in {self._name}; available: {available}"
42
+ ) from exc
43
+
44
+ def names(self) -> tuple[str, ...]:
45
+ """Return registered names in insertion order."""
46
+ return tuple(self._items)