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
cnn_classifier/cli.py ADDED
@@ -0,0 +1,1085 @@
1
+ """Command-line entry point for cnn-classifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import importlib.util
7
+ import json
8
+ import platform
9
+ import resource
10
+ import time
11
+ from collections.abc import Sequence
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ import torch
17
+ import typer
18
+ import yaml
19
+ from torch import nn
20
+ from torch.utils.data import DataLoader
21
+
22
+ from cnn_classifier._version import __version__
23
+ from cnn_classifier.config.loader import load_config
24
+ from cnn_classifier.config.resolve import validate_config_semantics
25
+ from cnn_classifier.config.schema import RunConfig
26
+ from cnn_classifier.core.device import device_report, resolve_device
27
+ from cnn_classifier.core.hashing import stable_hash
28
+ from cnn_classifier.core.seeding import seed_everything
29
+ from cnn_classifier.core.types import DatasetBundle, NormalizationStats
30
+ from cnn_classifier.data.adapters import build_dataset
31
+ from cnn_classifier.data.inspection import inspect_dataset
32
+ from cnn_classifier.data.loaders import DataLoaderBundle, build_loaders
33
+ from cnn_classifier.data.transforms import build_transforms
34
+ from cnn_classifier.diagnostics.determinism import determinism_check
35
+ from cnn_classifier.diagnostics.gradients import gradient_report
36
+ from cnn_classifier.diagnostics.sanity import overfit_batch
37
+ from cnn_classifier.diagnostics.tracing import trace_shapes
38
+ from cnn_classifier.evaluation.compare import compare_runs
39
+ from cnn_classifier.evaluation.evaluator import EvaluationResult, evaluate_model, save_predictions
40
+ from cnn_classifier.evaluation.predict import predict_paths
41
+ from cnn_classifier.models import build_model
42
+ from cnn_classifier.models.summary import model_summary
43
+ from cnn_classifier.runs.context import RunContext
44
+ from cnn_classifier.training.checkpoint import (
45
+ load_checkpoint,
46
+ validate_checkpoint_identity,
47
+ )
48
+ from cnn_classifier.training.losses import build_loss
49
+ from cnn_classifier.training.trainer import Trainer
50
+
51
+ app = typer.Typer(
52
+ no_args_is_help=True,
53
+ add_completion=False,
54
+ help="Config-driven PyTorch toolkit for CNN image classification.",
55
+ )
56
+
57
+ _IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
58
+ _SET_OPTION = typer.Option(None, "--set", "-s", help="Override a config value: key=value.")
59
+
60
+
61
+ def _version_callback(value: bool) -> None:
62
+ """Print the package version and exit when --version is supplied."""
63
+ if value:
64
+ typer.echo(__version__)
65
+ raise typer.Exit()
66
+
67
+
68
+ @app.callback()
69
+ def _main(
70
+ version: bool = typer.Option(
71
+ False,
72
+ "--version",
73
+ callback=_version_callback,
74
+ is_eager=True,
75
+ help="Show the package version and exit.",
76
+ ),
77
+ ) -> None:
78
+ """Run the cnn-classifier command line."""
79
+ _ = version
80
+
81
+
82
+ def _read_json(path: Path) -> dict[str, Any]:
83
+ if not path.is_file():
84
+ return {}
85
+ try:
86
+ payload = json.loads(path.read_text(encoding="utf-8"))
87
+ except (OSError, json.JSONDecodeError):
88
+ return {}
89
+ return payload if isinstance(payload, dict) else {}
90
+
91
+
92
+ def _optional_dependency(name: str) -> str:
93
+ return "installed" if importlib.util.find_spec(name) is not None else "missing"
94
+
95
+
96
+ def _prepare(config: RunConfig) -> tuple[DatasetBundle, DataLoaderBundle, nn.Module, torch.device]:
97
+ device = resolve_device(config.runtime.device)
98
+ seed_everything(config.experiment.seed, deterministic=config.runtime.deterministic)
99
+ bundle = build_dataset(config.data)
100
+ loaders = build_loaders(bundle, config.training, config.runtime, device)
101
+ model = build_model(
102
+ config.model,
103
+ num_classes=len(bundle.class_names),
104
+ in_channels=bundle.in_channels,
105
+ image_size=bundle.image_size,
106
+ ).to(device)
107
+ return bundle, loaders, model, device
108
+
109
+
110
+ def _split_digest(bundle: DatasetBundle, indices: Sequence[int]) -> str:
111
+ payload = [
112
+ {
113
+ "path": str(bundle.sample_metadata.paths[index]),
114
+ "label": int(bundle.sample_metadata.labels[index]),
115
+ }
116
+ for index in indices
117
+ ]
118
+ return stable_hash(payload)
119
+
120
+
121
+ def _split_groups(bundle: DatasetBundle, indices: Sequence[int]) -> set[str]:
122
+ return {
123
+ str(bundle.sample_metadata.groups[index])
124
+ for index in indices
125
+ if bundle.sample_metadata.groups[index] is not None
126
+ }
127
+
128
+
129
+ def _split_paths(bundle: DatasetBundle, indices: Sequence[int]) -> set[str]:
130
+ return {str(bundle.sample_metadata.paths[index]) for index in indices}
131
+
132
+
133
+ def _fingerprint_payload(bundle: DatasetBundle) -> dict[str, Any]:
134
+ split_info = bundle.split_info
135
+ split_indices: dict[str, tuple[int, ...]] = {
136
+ "train": split_info.train_indices,
137
+ "val": split_info.val_indices,
138
+ }
139
+ if split_info.test_indices is not None:
140
+ split_indices["test"] = split_info.test_indices
141
+ split_rows = {
142
+ name: {"count": len(indices), "fingerprint": _split_digest(bundle, indices)}
143
+ for name, indices in split_indices.items()
144
+ }
145
+ if "test" not in split_rows and bundle.test_fingerprint:
146
+ split_rows["test"] = {
147
+ "count": len(bundle.test) if bundle.test is not None else 0, # type: ignore[arg-type]
148
+ "fingerprint": bundle.test_fingerprint,
149
+ }
150
+ primary = split_rows.get("test", split_rows["val"])["fingerprint"]
151
+ train_groups = _split_groups(bundle, split_info.train_indices)
152
+ val_groups = _split_groups(bundle, split_info.val_indices)
153
+ group_overlap = sorted(train_groups & val_groups)
154
+ train_paths = _split_paths(bundle, split_info.train_indices)
155
+ val_paths = _split_paths(bundle, split_info.val_indices)
156
+ duplicate_overlap = sorted(train_paths & val_paths)
157
+ return {
158
+ "dataset_fingerprint": bundle.fingerprint,
159
+ "fingerprint": primary,
160
+ "test_fingerprint": split_rows.get("test", {}).get("fingerprint"),
161
+ "split_mode": split_info.mode,
162
+ "seed": split_info.seed,
163
+ "splits": split_rows,
164
+ "group_overlap": group_overlap,
165
+ "duplicate_overlap": duplicate_overlap,
166
+ }
167
+
168
+
169
+ def _write_data_artifacts(run: RunContext, bundle: DatasetBundle) -> dict[str, Any]:
170
+ payload = _fingerprint_payload(bundle)
171
+ run.write_json("data/fingerprint.json", payload)
172
+ run.write_json(
173
+ "data/splits.json",
174
+ {
175
+ "mode": bundle.split_info.mode,
176
+ "seed": bundle.split_info.seed,
177
+ "train": list(bundle.split_info.train_indices),
178
+ "val": list(bundle.split_info.val_indices),
179
+ "test": list(bundle.split_info.test_indices or ()),
180
+ "test_count": (
181
+ len(bundle.test) # type: ignore[arg-type]
182
+ if bundle.split_info.test_indices is None and bundle.test is not None
183
+ else len(bundle.split_info.test_indices or ())
184
+ ),
185
+ "test_fingerprint": (
186
+ bundle.test_fingerprint if bundle.split_info.test_indices is None else None
187
+ ),
188
+ },
189
+ )
190
+ run.write_json("data/class_names.json", list(bundle.class_names))
191
+ normalization = asdict(bundle.normalization) if bundle.normalization is not None else None
192
+ run.write_json("data/normalization.json", normalization)
193
+ return payload
194
+
195
+
196
+ def _echo_contract(config: RunConfig, bundle: DatasetBundle) -> None:
197
+ contract = bundle.model_contract
198
+ typer.echo("Raw data -> model intake")
199
+ typer.echo(f" Adapter: {config.data.adapter}")
200
+ typer.echo(f" Classes ({len(bundle.class_names)}): {', '.join(bundle.class_names)}")
201
+ typer.echo(
202
+ " Raw images are decoded, channel-converted, resized when configured, "
203
+ "and scaled to [0, 1]."
204
+ )
205
+ typer.echo(
206
+ f"Model input: float32 {contract.input_shape}, dtype={contract.dtype}, "
207
+ f"value range={contract.value_range}"
208
+ )
209
+ if bundle.normalization is not None:
210
+ typer.echo(
211
+ f" Normalization: mean={bundle.normalization.mean}, "
212
+ f"std={bundle.normalization.std} ({bundle.normalization.source})"
213
+ )
214
+ else:
215
+ typer.echo(" Normalization: none")
216
+ typer.echo(f" Channel policy: {contract.channel_policy}")
217
+
218
+
219
+ def _echo_summary(summary: dict[str, Any]) -> None:
220
+ typer.echo(f"Parameters: {summary['total_params']:,} total")
221
+ typer.echo(f" Trainable: {summary['trainable_params']:,}")
222
+ typer.echo(f" Output shape: {summary['output_shape']}")
223
+
224
+
225
+ def _spinner(config: RunConfig, bundle: DatasetBundle) -> None:
226
+ typer.echo(f"Device: {resolve_device(config.runtime.device)}")
227
+ typer.echo(f"Planned epochs: {config.training.epochs}")
228
+ test_count = (
229
+ len(bundle.test) # type: ignore[arg-type]
230
+ if bundle.test is not None
231
+ else len(bundle.split_info.test_indices or ())
232
+ )
233
+ typer.echo(
234
+ f"Samples: train={len(bundle.split_info.train_indices)}, "
235
+ f"val={len(bundle.split_info.val_indices)}, test={test_count}"
236
+ )
237
+
238
+
239
+ @app.command()
240
+ def doctor() -> None:
241
+ """Print package and runtime information."""
242
+ typer.echo(f"cnn-classifier {__version__}")
243
+ typer.echo(f"python {platform.python_version()}")
244
+ typer.echo(f"platform {platform.platform()}")
245
+ typer.echo(f"pytorch {torch.__version__}")
246
+ try:
247
+ import torchvision
248
+
249
+ typer.echo(f"torchvision {torchvision.__version__}")
250
+ except ImportError: # pragma: no cover - torchvision is a required dependency
251
+ typer.echo("torchvision missing")
252
+ try:
253
+ device = resolve_device("auto")
254
+ typer.echo(f"device: {device} ({device.type})")
255
+ except Exception as exc: # pragma: no cover - defensive hardware reporting
256
+ typer.echo(f"device: unavailable ({exc})")
257
+ report = device_report()
258
+ typer.echo(f"mps available: {report.get('mps_available')}")
259
+ typer.echo(f"cuda available: {report.get('cuda_available')}")
260
+ for name in ("tensorboard", "sklearn", "torchinfo", "matplotlib", "jinja2"):
261
+ typer.echo(f"{name}: {_optional_dependency(name)}")
262
+
263
+
264
+ _INIT_CONFIG = """schema_version: "1.0"
265
+
266
+ experiment:
267
+ name: my-classifier
268
+ seed: 42
269
+
270
+ data:
271
+ adapter: image_folder
272
+ root: data
273
+ class_names: auto
274
+ channels: rgb
275
+ image_size: [224, 224]
276
+ split:
277
+ mode: explicit
278
+ normalization:
279
+ mode: compute
280
+ transforms:
281
+ train:
282
+ - name: RandomResizedCrop
283
+ size: [224, 224]
284
+ - name: RandomHorizontalFlip
285
+ p: 0.5
286
+ - name: ToTensor
287
+ - name: Normalize
288
+ mean: auto
289
+ std: auto
290
+ eval:
291
+ - name: Resize
292
+ size: [224, 224]
293
+ - name: ToTensor
294
+ - name: Normalize
295
+ mean: auto
296
+ std: auto
297
+
298
+ model:
299
+ name: residual_cnn
300
+
301
+ training:
302
+ epochs: 50
303
+ batch_size: 64
304
+ optimizer:
305
+ name: adamw
306
+ lr: 0.001
307
+ weight_decay: 0.0001
308
+ scheduler:
309
+ name: cosine
310
+ early_stopping:
311
+ enabled: true
312
+ patience: 10
313
+
314
+ evaluation:
315
+ bootstrap:
316
+ enabled: true
317
+ latency:
318
+ enabled: true
319
+
320
+ report:
321
+ enabled: true
322
+ formats: [html, markdown]
323
+
324
+ runtime:
325
+ device: auto
326
+ """
327
+
328
+ _INIT_README = """# Image classification project
329
+
330
+ Put images in `data/train/<class>/`, `data/val/<class>/`, and optionally
331
+ `data/test/<class>/`. Each class is a directory name.
332
+
333
+ ```bash
334
+ cnncls inspect-data -c config.yaml
335
+ cnncls train -c config.yaml
336
+ ```
337
+
338
+ The command writes a self-describing run directory under `runs/`, including
339
+ the resolved configuration, model contract, data fingerprints, checkpoints,
340
+ predictions, figures, and an HTML report.
341
+ """
342
+
343
+
344
+ @app.command()
345
+ def init(
346
+ directory: Path = typer.Argument(Path("cnn_project"), help="Project directory to create."),
347
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing scaffold."),
348
+ ) -> None:
349
+ """Create a starter config, README, and data layout."""
350
+ config_path = directory / "config.yaml"
351
+ if config_path.exists() and not force:
352
+ typer.echo(f"Error: {config_path} already exists (use --force)", err=True)
353
+ raise typer.Exit(code=1)
354
+ (directory / "data").mkdir(parents=True, exist_ok=True)
355
+ config_path.write_text(_INIT_CONFIG, encoding="utf-8")
356
+ (directory / "README.md").write_text(_INIT_README, encoding="utf-8")
357
+ typer.echo(f"Created project at {directory}")
358
+ typer.echo(f" config: {config_path}")
359
+ typer.echo(" next: fill data/train and data/val, then run cnncls inspect-data -c config.yaml")
360
+
361
+
362
+ @app.command("inspect-data")
363
+ def inspect_data(
364
+ config_path: Path = typer.Option(..., "-c", "--config", exists=True, dir_okay=False),
365
+ set_values: list[str] | None = _SET_OPTION,
366
+ max_samples: int | None = typer.Option(
367
+ None, "--max-samples", help="Limit inspection work per split."
368
+ ),
369
+ ) -> None:
370
+ """Report class balance, duplicates, corruption, leakage, and the model contract."""
371
+ try:
372
+ config = load_config(config_path, set_values or [])
373
+ bundle = build_dataset(config.data)
374
+ report = inspect_dataset(bundle, max_samples=max_samples)
375
+ except Exception as exc:
376
+ typer.echo(f"Error: {exc}", err=True)
377
+ raise typer.Exit(code=1) from exc
378
+ typer.echo("Class counts")
379
+ for split_name, counts in report.class_counts.items():
380
+ details = ", ".join(f"{name}={count}" for name, count in counts.items())
381
+ typer.echo(f" {split_name}: {details}")
382
+ duplicate_groups = sum(1 for count in report.duplicates.values() if count > 1)
383
+ typer.echo(f"Duplicates: {duplicate_groups} content group(s)")
384
+ typer.echo(f"Corrupt samples: {len(report.corrupt)}")
385
+ typer.echo(f"Split overlap: {len(report.split_overlap)}")
386
+ typer.echo(f"Group leakage: {len(report.group_leakage)}")
387
+ for warning in report.warnings:
388
+ typer.echo(f"Warning: {warning}")
389
+ typer.echo("")
390
+ _echo_contract(config, bundle)
391
+
392
+
393
+ @app.command()
394
+ def debug(
395
+ config_path: Path = typer.Option(..., "-c", "--config", exists=True, dir_okay=False),
396
+ set_values: list[str] | None = _SET_OPTION,
397
+ overfit: bool = typer.Option(False, "--overfit-batch", help="Overfit a single batch."),
398
+ gradients: bool = typer.Option(False, "--gradients", help="Print per-layer gradient norms."),
399
+ determinism: bool = typer.Option(
400
+ False, "--determinism", help="Check repeated seeded training steps."
401
+ ),
402
+ trace: bool = typer.Option(False, "--trace", help="Print module shape traces."),
403
+ steps: int = typer.Option(100, "--steps", help="Maximum overfit steps."),
404
+ ) -> None:
405
+ """Run cheap training diagnostics without plotting or writing a run."""
406
+ try:
407
+ config = load_config(config_path, set_values or [])
408
+ bundle, loaders, model, device = _prepare(config)
409
+ except Exception as exc:
410
+ typer.echo(f"Error: {exc}", err=True)
411
+ raise typer.Exit(code=1) from exc
412
+ batch = next(iter(loaders.train))
413
+ images = batch[0].to(device)
414
+ labels = batch[1].to(device)
415
+ if overfit:
416
+ loss_fn = build_loss(config.training.loss)
417
+ optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
418
+ result = overfit_batch(model, (images, labels), loss_fn, optimizer, device, max_steps=steps)
419
+ typer.echo(
420
+ f"Overfit batch: loss {result.initial_loss:.4f} -> {result.final_loss:.4f} "
421
+ f"in {result.steps} step(s)"
422
+ )
423
+ if gradients:
424
+ model.train()
425
+ grad_optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
426
+ grad_optimizer.zero_grad(set_to_none=True)
427
+ loss = build_loss(config.training.loss)(model(images), labels)
428
+ loss.backward()
429
+ stats = sorted(gradient_report(model), key=lambda item: item.norm, reverse=True)
430
+ typer.echo("Gradient norms")
431
+ for stat in stats[:10]:
432
+ typer.echo(
433
+ f" {stat.name}: norm={stat.norm:.4f}, zero_fraction={stat.zero_fraction:.3f}"
434
+ )
435
+ grad_optimizer.zero_grad(set_to_none=True)
436
+ if determinism:
437
+
438
+ def train_once() -> torch.Tensor:
439
+ seed_everything(config.experiment.seed, deterministic=config.runtime.deterministic)
440
+ candidate = build_model(
441
+ config.model,
442
+ num_classes=len(bundle.class_names),
443
+ in_channels=bundle.in_channels,
444
+ image_size=bundle.image_size,
445
+ ).to(device)
446
+ optimizer = torch.optim.SGD(candidate.parameters(), lr=0.01)
447
+ optimizer.zero_grad(set_to_none=True)
448
+ value = build_loss(config.training.loss)(candidate(images), labels)
449
+ value.backward()
450
+ optimizer.step()
451
+ return cast(torch.Tensor, value.detach().clone())
452
+
453
+ equal = determinism_check(train_once, config.experiment.seed)
454
+ typer.echo(f"Determinism: {'passed' if equal else 'FAILED'}")
455
+ if trace or not (overfit or gradients or determinism):
456
+ typer.echo("Shape trace")
457
+ for layer in trace_shapes(model, bundle.model_contract.input_shape)[:24]:
458
+ typer.echo(
459
+ f" {layer.name}: {layer.type} {layer.input_shape} -> "
460
+ f"{layer.output_shape} ({layer.params:,} params)"
461
+ )
462
+
463
+
464
+ def _history_from_run(run_dir: Path) -> list[dict[str, float]]:
465
+ summary = _read_json(run_dir / "summary.json")
466
+ history = summary.get("history")
467
+ if not isinstance(history, list):
468
+ return []
469
+ return [
470
+ {str(key): float(value) for key, value in row.items()}
471
+ for row in history
472
+ if isinstance(row, dict)
473
+ ]
474
+
475
+
476
+ def _evaluation_payload(
477
+ config: RunConfig,
478
+ result: EvaluationResult,
479
+ split: str,
480
+ ) -> dict[str, Any]:
481
+ metrics = result.metrics
482
+ calibration = asdict(metrics.calibration) if metrics.calibration is not None else None
483
+ intervals = {
484
+ name: {
485
+ "low": interval.low,
486
+ "high": interval.high,
487
+ "estimate": interval.estimate,
488
+ "confidence": config.evaluation.bootstrap.confidence,
489
+ }
490
+ for name, interval in result.confidence_intervals.items()
491
+ }
492
+ payload: dict[str, Any] = {
493
+ "split": split,
494
+ "metrics": {
495
+ "accuracy": metrics.accuracy,
496
+ "top_k_accuracy": metrics.top_k_accuracy,
497
+ "macro_f1": metrics.macro_f1,
498
+ "per_class": {name: asdict(value) for name, value in metrics.per_class.items()},
499
+ "confusion_matrix": metrics.confusion_matrix,
500
+ "nll": metrics.nll,
501
+ "calibration": calibration,
502
+ "support": metrics.support,
503
+ },
504
+ "confidence_intervals": intervals,
505
+ "latency": asdict(result.latency) if result.latency is not None else None,
506
+ "tta": {
507
+ "enabled": config.evaluation.tta.enabled,
508
+ "base": None,
509
+ "tta": None,
510
+ },
511
+ }
512
+ if result.base_metrics is not None:
513
+ payload["tta"]["base"] = {"accuracy": result.base_metrics.accuracy}
514
+ if result.tta_metrics is not None:
515
+ payload["tta"]["tta"] = {"accuracy": result.tta_metrics.accuracy}
516
+ return payload
517
+
518
+
519
+ def _peak_memory_mb(device: torch.device) -> float | None:
520
+ if device.type == "cuda" and torch.cuda.is_available():
521
+ return float(torch.cuda.max_memory_allocated(device) / (1024.0 * 1024.0))
522
+ if device.type == "mps":
523
+ try:
524
+ return float(torch.mps.current_allocated_memory() / (1024.0 * 1024.0))
525
+ except (AttributeError, RuntimeError):
526
+ return None
527
+ raw = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
528
+ divisor = 1024.0 * 1024.0 if platform.system() == "Darwin" else 1024.0
529
+ return raw / divisor
530
+
531
+
532
+ def _update_summary(
533
+ run: RunContext,
534
+ model: nn.Module,
535
+ result: EvaluationResult,
536
+ split: str,
537
+ fingerprint: dict[str, Any],
538
+ device: torch.device,
539
+ ) -> None:
540
+ summary = _read_json(run.directory / "summary.json")
541
+ total = sum(parameter.numel() for parameter in model.parameters())
542
+ trainable = sum(
543
+ parameter.numel() for parameter in model.parameters() if parameter.requires_grad
544
+ )
545
+ interval = result.confidence_intervals.get("accuracy")
546
+ summary.update(
547
+ {
548
+ "evaluated_split": split,
549
+ "macro_f1": result.metrics.macro_f1,
550
+ "params": total,
551
+ "trainable_params": trainable,
552
+ "latency_ms": result.latency.mean_ms if result.latency is not None else None,
553
+ "peak_memory_mb": _peak_memory_mb(device),
554
+ "dataset_fingerprint": fingerprint.get("dataset_fingerprint"),
555
+ }
556
+ )
557
+ if split == "test":
558
+ summary["test_accuracy"] = result.metrics.accuracy
559
+ summary["test_fingerprint"] = fingerprint.get("test_fingerprint")
560
+ else:
561
+ summary["val_accuracy"] = result.metrics.accuracy
562
+ if interval is not None:
563
+ summary["accuracy_ci"] = [interval.low, interval.high]
564
+ run.write_json("summary.json", summary)
565
+
566
+
567
+ def _denormalize(image: torch.Tensor, normalization: NormalizationStats | None) -> torch.Tensor:
568
+ if normalization is None:
569
+ return image.clamp(0.0, 1.0)
570
+ mean = torch.tensor(normalization.mean, dtype=image.dtype).view(-1, 1, 1)
571
+ std = torch.tensor(normalization.std, dtype=image.dtype).view(-1, 1, 1)
572
+ return (image * std + mean).clamp(0.0, 1.0)
573
+
574
+
575
+ def _write_figures(
576
+ run: RunContext,
577
+ bundle: DatasetBundle,
578
+ config: RunConfig,
579
+ model: nn.Module,
580
+ loader: DataLoader[Any],
581
+ result: EvaluationResult,
582
+ history: list[dict[str, float]],
583
+ ) -> None:
584
+ try:
585
+ from cnn_classifier.viz.architecture import plot_model_card
586
+ from cnn_classifier.viz.evaluation import (
587
+ plot_calibration,
588
+ plot_confusion_matrix,
589
+ plot_error_gallery,
590
+ plot_per_class_metrics,
591
+ )
592
+ from cnn_classifier.viz.training import plot_training_history
593
+ except ImportError: # pragma: no cover - plotting dependencies are optional
594
+ return
595
+ figures = run.directory / "figures"
596
+ figures.mkdir(parents=True, exist_ok=True)
597
+ if history:
598
+ plot_training_history(history, figures / "training_history.png")
599
+ plot_confusion_matrix(
600
+ result.metrics.confusion_matrix,
601
+ list(bundle.class_names),
602
+ figures / "confusion_matrix.png",
603
+ )
604
+ plot_per_class_metrics(result.metrics.per_class, figures / "per_class.png")
605
+ if result.metrics.calibration is not None:
606
+ plot_calibration(result.metrics.calibration, figures / "calibration.png")
607
+ summary = model_summary(model, bundle.model_contract.input_shape)
608
+ plot_model_card(summary, figures / "model_card.png")
609
+
610
+ dataset = loader.dataset
611
+ dataset_indices = getattr(dataset, "indices", None)
612
+ source_to_position = (
613
+ {int(source): position for position, source in enumerate(dataset_indices)}
614
+ if dataset_indices
615
+ else None
616
+ )
617
+ limit = min(config.evaluation.error_analysis.max_examples, 16)
618
+ images: list[torch.Tensor] = []
619
+ true_labels: list[str] = []
620
+ predicted_labels: list[str] = []
621
+ for source, truth, prediction in zip(result.indices, result.y_true, result.y_pred, strict=True):
622
+ if int(truth) == int(prediction):
623
+ continue
624
+ position = (
625
+ source_to_position.get(int(source)) if source_to_position is not None else int(source)
626
+ )
627
+ if position is None or position >= len(dataset): # type: ignore[arg-type]
628
+ continue
629
+ image, _, _ = dataset[position]
630
+ images.append(_denormalize(image, bundle.normalization))
631
+ true_labels.append(bundle.class_names[int(truth)])
632
+ predicted_labels.append(bundle.class_names[int(prediction)])
633
+ if len(images) >= limit:
634
+ break
635
+ if images:
636
+ plot_error_gallery(
637
+ torch.stack(images),
638
+ true_labels,
639
+ predicted_labels,
640
+ figures / "errors.png",
641
+ )
642
+
643
+
644
+ def _write_report(run: RunContext, config: RunConfig) -> None:
645
+ if not config.report.enabled:
646
+ return
647
+ from cnn_classifier.viz.report import build_markdown_report, build_report
648
+
649
+ if "html" in config.report.formats:
650
+ build_report(run.directory)
651
+ if "markdown" in config.report.formats:
652
+ (run.directory / "report.md").write_text(
653
+ build_markdown_report(run.directory), encoding="utf-8"
654
+ )
655
+
656
+
657
+ def _evaluate_into_run(
658
+ run: RunContext,
659
+ config: RunConfig,
660
+ bundle: DatasetBundle,
661
+ model: nn.Module,
662
+ loader: DataLoader[Any],
663
+ device: torch.device,
664
+ split: str,
665
+ history: list[dict[str, float]],
666
+ figures: bool,
667
+ ) -> EvaluationResult:
668
+ result = evaluate_model(
669
+ model,
670
+ loader,
671
+ device,
672
+ bundle.class_names,
673
+ config.evaluation,
674
+ )
675
+ save_predictions(result, run.directory / "predictions" / f"{split}_predictions.npz")
676
+ fingerprint = _write_data_artifacts(run, bundle)
677
+ run.write_json("evaluation.json", _evaluation_payload(config, result, split))
678
+ _update_summary(run, model, result, split, fingerprint, device)
679
+ if figures:
680
+ _write_figures(run, bundle, config, model, loader, result, history)
681
+ return result
682
+
683
+
684
+ @app.command()
685
+ def train(
686
+ config_path: Path = typer.Option(..., "-c", "--config", exists=True, dir_okay=False),
687
+ set_values: list[str] | None = _SET_OPTION,
688
+ resume: Path | None = typer.Option(None, "--resume", help="Checkpoint to resume from."),
689
+ dry_run: bool = typer.Option(False, "--dry-run", help="Validate without training."),
690
+ runs_dir: Path = typer.Option(Path("runs"), "--runs-dir", help="Directory for new runs."),
691
+ allow_config_change: bool = typer.Option(
692
+ False, "--allow-config-change", help="Allow resuming with a changed config."
693
+ ),
694
+ no_evaluate: bool = typer.Option(False, "--no-evaluate", help="Skip test evaluation."),
695
+ ) -> None:
696
+ """Train a model from a YAML configuration."""
697
+ run: RunContext | None = None
698
+ try:
699
+ config = load_config(config_path, set_values or [])
700
+ if dry_run:
701
+ bundle, loaders, model, device = _prepare(config)
702
+ batch = next(iter(loaders.train))
703
+ images = batch[0].to(device)
704
+ labels = batch[1].to(device)
705
+ model.train()
706
+ loss = build_loss(config.training.loss)(model(images), labels)
707
+ loss.backward()
708
+ _echo_contract(config, bundle)
709
+ _spinner(config, bundle)
710
+ _echo_summary(model_summary(model, bundle.model_contract.input_shape))
711
+ typer.echo(
712
+ f"Dry run complete; one batch loss={float(loss.item()):.4f}; nothing written."
713
+ )
714
+ return
715
+
716
+ if resume is not None:
717
+ checkpoint_path = resume.resolve()
718
+ if not checkpoint_path.is_file():
719
+ raise FileNotFoundError(f"checkpoint does not exist: {checkpoint_path}")
720
+ run_directory = (
721
+ checkpoint_path.parent.parent
722
+ if checkpoint_path.parent.name == "checkpoints"
723
+ else checkpoint_path.parent
724
+ )
725
+ previous_history = _history_from_run(run_directory)
726
+ run = RunContext(
727
+ directory=run_directory,
728
+ config=config,
729
+ config_hash=stable_hash(config.model_dump(mode="json")),
730
+ )
731
+ run.write_text(
732
+ "config.resolved.yaml",
733
+ yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False),
734
+ )
735
+ run.set_status("running")
736
+ else:
737
+ previous_history = []
738
+ run = RunContext.create(config, root=runs_dir)
739
+
740
+ bundle, loaders, model, device = _prepare(config)
741
+ _write_data_artifacts(run, bundle)
742
+ trainer = Trainer(
743
+ model,
744
+ loaders,
745
+ config,
746
+ run,
747
+ device,
748
+ model_contract=bundle.model_contract,
749
+ class_names=bundle.class_names,
750
+ normalization=bundle.normalization,
751
+ )
752
+ if resume is not None:
753
+ trainer.resume(
754
+ resume,
755
+ allow_config_change=allow_config_change,
756
+ history=previous_history,
757
+ )
758
+ started = time.perf_counter()
759
+ result = trainer.fit()
760
+ train_seconds = time.perf_counter() - started
761
+ summary = _read_json(run.directory / "summary.json")
762
+ summary["train_seconds"] = train_seconds
763
+ run.write_json("summary.json", summary)
764
+
765
+ if not no_evaluate and (bundle.test is not None or bundle.val is not None):
766
+ split = "test" if bundle.test is not None else "val"
767
+ loader = loaders.test if split == "test" else loaders.val
768
+ assert loader is not None
769
+ best_path = result.best_checkpoint
770
+ payload = load_checkpoint(
771
+ best_path,
772
+ map_location=device,
773
+ expected_config_hash=run.config_hash,
774
+ allow_config_change=allow_config_change,
775
+ )
776
+ trainer.model.load_state_dict(payload["model"])
777
+ evaluated = _evaluate_into_run(
778
+ run,
779
+ config,
780
+ bundle,
781
+ trainer.model,
782
+ loader,
783
+ device,
784
+ split,
785
+ result.history,
786
+ figures=config.report.enabled,
787
+ )
788
+ typer.echo(
789
+ f"{split} accuracy: {evaluated.metrics.accuracy:.4f} "
790
+ f"macro F1: {evaluated.metrics.macro_f1:.4f}"
791
+ )
792
+ _write_report(run, config)
793
+ typer.echo(f"Run directory: {run.directory}")
794
+ typer.echo(f"Best checkpoint: {result.best_checkpoint}")
795
+ except Exception as exc:
796
+ if run is not None:
797
+ run.set_status("failed", str(exc))
798
+ typer.echo(f"Error: {exc}", err=True)
799
+ raise typer.Exit(code=1) from exc
800
+
801
+
802
+ def _checkpoint_run_dir(checkpoint: Path) -> Path:
803
+ resolved = checkpoint.resolve()
804
+ return resolved.parent.parent if resolved.parent.name == "checkpoints" else resolved.parent
805
+
806
+
807
+ def _config_for_checkpoint(
808
+ config_path: Path | None,
809
+ set_values: list[str] | None,
810
+ checkpoint: Path,
811
+ ) -> RunConfig:
812
+ if config_path is not None:
813
+ return load_config(config_path, set_values or [])
814
+ resolved = _checkpoint_run_dir(checkpoint) / "config.resolved.yaml"
815
+ if not resolved.is_file():
816
+ raise FileNotFoundError(
817
+ f"no resolved config beside checkpoint; pass -c/--config ({resolved} missing)"
818
+ )
819
+ payload = yaml.safe_load(resolved.read_text(encoding="utf-8"))
820
+ if not isinstance(payload, dict):
821
+ raise ValueError(f"invalid resolved config: {resolved}")
822
+ config = RunConfig.model_validate(payload)
823
+ validate_config_semantics(config)
824
+ return config
825
+
826
+
827
+ @app.command("evaluate")
828
+ def evaluate(
829
+ checkpoint: Path = typer.Option(..., "--checkpoint", exists=True, dir_okay=False),
830
+ config_path: Path | None = typer.Option(
831
+ None,
832
+ "-c",
833
+ "--config",
834
+ exists=True,
835
+ dir_okay=False,
836
+ help="Config override; defaults to the run's resolved config.",
837
+ ),
838
+ split: str = typer.Option("test", "--split", help="Split to evaluate: test or val."),
839
+ set_values: list[str] | None = _SET_OPTION,
840
+ run_dir: Path | None = typer.Option(None, "--run", help="Run directory for artifacts."),
841
+ allow_config_change: bool = typer.Option(False, "--allow-config-change"),
842
+ no_figures: bool = typer.Option(False, "--no-figures", help="Skip figure generation."),
843
+ ) -> None:
844
+ """Evaluate a checkpoint on a held-out split and write artifacts."""
845
+ if split not in {"test", "val"}:
846
+ typer.echo("Error: --split must be 'test' or 'val'", err=True)
847
+ raise typer.Exit(code=2)
848
+ try:
849
+ config = _config_for_checkpoint(config_path, set_values, checkpoint)
850
+ bundle, loaders, model, device = _prepare(config)
851
+ loader = loaders.test if split == "test" else loaders.val
852
+ if loader is None:
853
+ raise RuntimeError(f"the configured dataset has no {split} split")
854
+ payload = load_checkpoint(
855
+ checkpoint,
856
+ map_location=device,
857
+ expected_config_hash=stable_hash(config.model_dump(mode="json")),
858
+ allow_config_change=allow_config_change,
859
+ )
860
+ validate_checkpoint_identity(
861
+ payload,
862
+ class_names=bundle.class_names,
863
+ normalization=bundle.normalization,
864
+ model_contract=bundle.model_contract,
865
+ )
866
+ model.load_state_dict(payload["model"])
867
+ destination = run_dir or _checkpoint_run_dir(checkpoint)
868
+ destination.mkdir(parents=True, exist_ok=True)
869
+ run = RunContext(
870
+ directory=destination,
871
+ config=config,
872
+ config_hash=stable_hash(config.model_dump(mode="json")),
873
+ )
874
+ history = _history_from_run(destination)
875
+ result = _evaluate_into_run(
876
+ run,
877
+ config,
878
+ bundle,
879
+ model,
880
+ loader,
881
+ device,
882
+ split,
883
+ history,
884
+ figures=not no_figures,
885
+ )
886
+ _write_report(run, config)
887
+ except Exception as exc:
888
+ typer.echo(f"Error: {exc}", err=True)
889
+ raise typer.Exit(code=1) from exc
890
+ typer.echo(
891
+ f"{split} accuracy: {result.metrics.accuracy:.4f} macro F1: {result.metrics.macro_f1:.4f}"
892
+ )
893
+ typer.echo(f"Wrote {destination / 'evaluation.json'}")
894
+
895
+
896
+ @app.command()
897
+ def report(
898
+ run: Path = typer.Option(..., "--run", exists=True, file_okay=False),
899
+ config_path: Path | None = typer.Option(
900
+ None, "-c", "--config", exists=True, dir_okay=False, help="Optional config to load."
901
+ ),
902
+ ) -> None:
903
+ """Build HTML and Markdown reports from an existing run directory."""
904
+ try:
905
+ config = load_config(config_path) if config_path is not None else None
906
+ from cnn_classifier.viz.report import build_markdown_report, build_report
907
+
908
+ html = build_report(run)
909
+ (run / "report.md").write_text(build_markdown_report(run), encoding="utf-8")
910
+ except Exception as exc:
911
+ typer.echo(f"Error: {exc}", err=True)
912
+ raise typer.Exit(code=1) from exc
913
+ _ = config
914
+ typer.echo(f"Wrote {html}")
915
+ typer.echo(f"Wrote {run / 'report.md'}")
916
+
917
+
918
+ def _prediction_paths(
919
+ input_path: Path,
920
+ image_root: Path | None,
921
+ ) -> list[Path]:
922
+ if input_path.is_dir():
923
+ return sorted(
924
+ path
925
+ for path in input_path.rglob("*")
926
+ if path.is_file() and path.suffix.lower() in _IMAGE_SUFFIXES
927
+ )
928
+ if input_path.suffix.lower() == ".csv":
929
+ paths: list[Path] = []
930
+ with input_path.open(encoding="utf-8", newline="") as handle:
931
+ for row in csv.DictReader(handle):
932
+ value = row.get("path")
933
+ if not value:
934
+ continue
935
+ candidate = Path(value)
936
+ if not candidate.is_absolute():
937
+ candidate = (image_root or input_path.parent) / candidate
938
+ paths.append(candidate)
939
+ return paths
940
+ return [input_path]
941
+
942
+
943
+ def _normalization_from_payload(payload: dict[str, Any]) -> NormalizationStats | None:
944
+ raw = payload.get("normalization")
945
+ if not isinstance(raw, dict):
946
+ return None
947
+ mean = raw.get("mean")
948
+ std = raw.get("std")
949
+ if not isinstance(mean, (list, tuple)) or not isinstance(std, (list, tuple)):
950
+ return None
951
+ return NormalizationStats(
952
+ mean=tuple(float(value) for value in mean),
953
+ std=tuple(float(value) for value in std),
954
+ source=raw.get("source", "none"),
955
+ )
956
+
957
+
958
+ @app.command()
959
+ def predict(
960
+ checkpoint: Path = typer.Option(..., "--checkpoint", exists=True, dir_okay=False),
961
+ input_path: Path = typer.Option(
962
+ ..., "--input", exists=True, help="Image, folder, or manifest CSV."
963
+ ),
964
+ config_path: Path | None = typer.Option(
965
+ None,
966
+ "-c",
967
+ "--config",
968
+ exists=True,
969
+ dir_okay=False,
970
+ help="Config override; defaults to the run's resolved config.",
971
+ ),
972
+ output: Path = typer.Option(Path("predictions.csv"), "--output", help="CSV or JSON output."),
973
+ image_root: Path | None = typer.Option(
974
+ None, "--image-root", help="Base directory for relative manifest paths."
975
+ ),
976
+ top_k: int = typer.Option(3, "--top-k", help="Number of probabilities to report."),
977
+ set_values: list[str] | None = _SET_OPTION,
978
+ allow_config_change: bool = typer.Option(False, "--allow-config-change"),
979
+ ) -> None:
980
+ """Predict an image, a folder of images, or a manifest."""
981
+ try:
982
+ config = _config_for_checkpoint(config_path, set_values, checkpoint)
983
+ device = resolve_device(config.runtime.device)
984
+ checkpoint_payload = load_checkpoint(
985
+ checkpoint,
986
+ map_location=device,
987
+ expected_config_hash=stable_hash(config.model_dump(mode="json")),
988
+ allow_config_change=allow_config_change,
989
+ )
990
+ class_names = tuple(checkpoint_payload.get("class_names", ()))
991
+ contract = checkpoint_payload.get("model_contract")
992
+ if not class_names or not isinstance(contract, dict):
993
+ raise RuntimeError("checkpoint is missing class names or the model contract")
994
+ input_shape = contract.get("input_shape")
995
+ if not isinstance(input_shape, (list, tuple)) or len(input_shape) != 3:
996
+ raise RuntimeError("checkpoint has an invalid model input shape")
997
+ model = build_model(
998
+ config.model,
999
+ num_classes=len(class_names),
1000
+ in_channels=int(input_shape[0]),
1001
+ image_size=(int(input_shape[1]), int(input_shape[2])),
1002
+ )
1003
+ model.load_state_dict(checkpoint_payload["model"])
1004
+ model.to(device)
1005
+ normalization = _normalization_from_payload(checkpoint_payload)
1006
+ transform = build_transforms(config.data, normalization, train=False)
1007
+ paths = _prediction_paths(input_path, image_root)
1008
+ if not paths:
1009
+ raise RuntimeError(f"no images found at {input_path}")
1010
+ predictions = predict_paths(model, paths, transform, device, class_names)
1011
+ except Exception as exc:
1012
+ typer.echo(f"Error: {exc}", err=True)
1013
+ raise typer.Exit(code=1) from exc
1014
+
1015
+ ranks = predictions.top_k(top_k)
1016
+ rows: list[dict[str, Any]] = []
1017
+ for index, path in enumerate(predictions.paths):
1018
+ probabilities = {
1019
+ name: float(predictions.probabilities[index, class_index])
1020
+ for class_index, name in enumerate(class_names)
1021
+ }
1022
+ rows.append(
1023
+ {
1024
+ "path": str(path),
1025
+ "predicted": class_names[int(predictions.predicted_labels[index])],
1026
+ "confidence": float(predictions.probabilities[index].max()),
1027
+ "probabilities": probabilities,
1028
+ "top_k": [
1029
+ {"label": label, "probability": probability}
1030
+ for label, probability in ranks[index]
1031
+ ],
1032
+ }
1033
+ )
1034
+ output.parent.mkdir(parents=True, exist_ok=True)
1035
+ csv_path = output if output.suffix.lower() == ".csv" else output.with_suffix(".csv")
1036
+ json_path = output if output.suffix.lower() == ".json" else output.with_suffix(".json")
1037
+ fieldnames = ["path", "predicted", "confidence", *class_names]
1038
+ with csv_path.open("w", encoding="utf-8", newline="") as handle:
1039
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
1040
+ writer.writeheader()
1041
+ for row in rows:
1042
+ writer.writerow(
1043
+ {
1044
+ "path": row["path"],
1045
+ "predicted": row["predicted"],
1046
+ "confidence": f"{row['confidence']:.6f}",
1047
+ **{name: f"{row['probabilities'][name]:.6f}" for name in class_names},
1048
+ }
1049
+ )
1050
+ json_path.write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1051
+ typer.echo(f"Predicted {len(rows)} image(s)")
1052
+ for row in rows[:20]:
1053
+ typer.echo(f" {row['path']}: {row['predicted']} ({row['confidence']:.3f})")
1054
+ typer.echo(f"Wrote {csv_path}")
1055
+ typer.echo(f"Wrote {json_path}")
1056
+
1057
+
1058
+ @app.command()
1059
+ def compare(
1060
+ run_dirs: list[Path] = typer.Argument(..., exists=True, file_okay=False),
1061
+ output_dir: Path = typer.Option(Path("results/comparison"), "--out", help="Output directory."),
1062
+ no_figure: bool = typer.Option(False, "--no-figure", help="Skip the comparison figure."),
1063
+ ) -> None:
1064
+ """Compare runs that share the same test fingerprint."""
1065
+ try:
1066
+ report = compare_runs(run_dirs, output_dir)
1067
+ if not no_figure:
1068
+ from cnn_classifier.viz.compare import plot_model_comparison
1069
+
1070
+ plot_model_comparison(report.pareto_data, output_dir / "comparison.png")
1071
+ except Exception as exc:
1072
+ typer.echo(f"Error: {exc}", err=True)
1073
+ raise typer.Exit(code=1) from exc
1074
+ typer.echo(report.markdown)
1075
+ for warning in report.warnings:
1076
+ typer.echo(f"Warning: {warning}")
1077
+ typer.echo(f"Wrote {output_dir}")
1078
+
1079
+
1080
+ def main() -> None:
1081
+ """Console-script entry point."""
1082
+ app()
1083
+
1084
+
1085
+ __all__ = ["app", "main"]