cnn-classifier-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cnn_classifier/__init__.py +5 -0
- cnn_classifier/_version.py +1 -0
- cnn_classifier/cli.py +1085 -0
- cnn_classifier/config/__init__.py +17 -0
- cnn_classifier/config/loader.py +102 -0
- cnn_classifier/config/presets/default.yaml +47 -0
- cnn_classifier/config/resolve.py +108 -0
- cnn_classifier/config/schema.py +251 -0
- cnn_classifier/core/__init__.py +19 -0
- cnn_classifier/core/device.py +50 -0
- cnn_classifier/core/errors.py +21 -0
- cnn_classifier/core/hashing.py +18 -0
- cnn_classifier/core/registry.py +46 -0
- cnn_classifier/core/seeding.py +37 -0
- cnn_classifier/core/types.py +97 -0
- cnn_classifier/data/__init__.py +22 -0
- cnn_classifier/data/adapters/__init__.py +19 -0
- cnn_classifier/data/adapters/_common.py +241 -0
- cnn_classifier/data/adapters/image_folder.py +65 -0
- cnn_classifier/data/adapters/manifest.py +62 -0
- cnn_classifier/data/adapters/numpy.py +96 -0
- cnn_classifier/data/adapters/registry.py +29 -0
- cnn_classifier/data/adapters/synthetic.py +114 -0
- cnn_classifier/data/adapters/torchvision.py +192 -0
- cnn_classifier/data/base.py +17 -0
- cnn_classifier/data/contract.py +28 -0
- cnn_classifier/data/image_io.py +116 -0
- cnn_classifier/data/inspection.py +111 -0
- cnn_classifier/data/loaders.py +79 -0
- cnn_classifier/data/manifest.py +98 -0
- cnn_classifier/data/splits.py +136 -0
- cnn_classifier/data/stats.py +106 -0
- cnn_classifier/data/transforms.py +192 -0
- cnn_classifier/diagnostics/__init__.py +22 -0
- cnn_classifier/diagnostics/activations.py +56 -0
- cnn_classifier/diagnostics/architecture.py +172 -0
- cnn_classifier/diagnostics/determinism.py +25 -0
- cnn_classifier/diagnostics/gradients.py +32 -0
- cnn_classifier/diagnostics/sanity.py +49 -0
- cnn_classifier/diagnostics/tracing.py +56 -0
- cnn_classifier/evaluation/__init__.py +53 -0
- cnn_classifier/evaluation/bootstrap.py +96 -0
- cnn_classifier/evaluation/calibration.py +74 -0
- cnn_classifier/evaluation/compare.py +111 -0
- cnn_classifier/evaluation/error_analysis.py +61 -0
- cnn_classifier/evaluation/evaluator.py +143 -0
- cnn_classifier/evaluation/latency.py +58 -0
- cnn_classifier/evaluation/metrics.py +94 -0
- cnn_classifier/evaluation/predict.py +64 -0
- cnn_classifier/models/__init__.py +50 -0
- cnn_classifier/models/base.py +62 -0
- cnn_classifier/models/residual_cnn.py +138 -0
- cnn_classifier/models/simple_cnn.py +55 -0
- cnn_classifier/models/summary.py +39 -0
- cnn_classifier/models/torchvision.py +46 -0
- cnn_classifier/models/transfer.py +193 -0
- cnn_classifier/runs/__init__.py +6 -0
- cnn_classifier/runs/artifacts.py +50 -0
- cnn_classifier/runs/context.py +130 -0
- cnn_classifier/tracking/__init__.py +34 -0
- cnn_classifier/tracking/base.py +16 -0
- cnn_classifier/tracking/composite.py +20 -0
- cnn_classifier/tracking/console.py +14 -0
- cnn_classifier/tracking/csv.py +19 -0
- cnn_classifier/tracking/tensorboard.py +22 -0
- cnn_classifier/training/__init__.py +37 -0
- cnn_classifier/training/amp.py +51 -0
- cnn_classifier/training/callbacks.py +229 -0
- cnn_classifier/training/checkpoint.py +151 -0
- cnn_classifier/training/losses.py +67 -0
- cnn_classifier/training/optim.py +40 -0
- cnn_classifier/training/schedulers.py +53 -0
- cnn_classifier/training/state.py +35 -0
- cnn_classifier/training/trainer.py +357 -0
- cnn_classifier/viz/__init__.py +42 -0
- cnn_classifier/viz/architecture.py +276 -0
- cnn_classifier/viz/compare.py +36 -0
- cnn_classifier/viz/data.py +56 -0
- cnn_classifier/viz/evaluation.py +134 -0
- cnn_classifier/viz/features.py +122 -0
- cnn_classifier/viz/report.py +821 -0
- cnn_classifier/viz/style.py +69 -0
- cnn_classifier/viz/templates/report.html.j2 +355 -0
- cnn_classifier/viz/training.py +83 -0
- cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
- cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
- cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
- cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- cnn_classifier_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
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"]
|