cnn-classifier-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cnn_classifier/__init__.py +5 -0
- cnn_classifier/_version.py +1 -0
- cnn_classifier/cli.py +1085 -0
- cnn_classifier/config/__init__.py +17 -0
- cnn_classifier/config/loader.py +102 -0
- cnn_classifier/config/presets/default.yaml +47 -0
- cnn_classifier/config/resolve.py +108 -0
- cnn_classifier/config/schema.py +251 -0
- cnn_classifier/core/__init__.py +19 -0
- cnn_classifier/core/device.py +50 -0
- cnn_classifier/core/errors.py +21 -0
- cnn_classifier/core/hashing.py +18 -0
- cnn_classifier/core/registry.py +46 -0
- cnn_classifier/core/seeding.py +37 -0
- cnn_classifier/core/types.py +97 -0
- cnn_classifier/data/__init__.py +22 -0
- cnn_classifier/data/adapters/__init__.py +19 -0
- cnn_classifier/data/adapters/_common.py +241 -0
- cnn_classifier/data/adapters/image_folder.py +65 -0
- cnn_classifier/data/adapters/manifest.py +62 -0
- cnn_classifier/data/adapters/numpy.py +96 -0
- cnn_classifier/data/adapters/registry.py +29 -0
- cnn_classifier/data/adapters/synthetic.py +114 -0
- cnn_classifier/data/adapters/torchvision.py +192 -0
- cnn_classifier/data/base.py +17 -0
- cnn_classifier/data/contract.py +28 -0
- cnn_classifier/data/image_io.py +116 -0
- cnn_classifier/data/inspection.py +111 -0
- cnn_classifier/data/loaders.py +79 -0
- cnn_classifier/data/manifest.py +98 -0
- cnn_classifier/data/splits.py +136 -0
- cnn_classifier/data/stats.py +106 -0
- cnn_classifier/data/transforms.py +192 -0
- cnn_classifier/diagnostics/__init__.py +22 -0
- cnn_classifier/diagnostics/activations.py +56 -0
- cnn_classifier/diagnostics/architecture.py +172 -0
- cnn_classifier/diagnostics/determinism.py +25 -0
- cnn_classifier/diagnostics/gradients.py +32 -0
- cnn_classifier/diagnostics/sanity.py +49 -0
- cnn_classifier/diagnostics/tracing.py +56 -0
- cnn_classifier/evaluation/__init__.py +53 -0
- cnn_classifier/evaluation/bootstrap.py +96 -0
- cnn_classifier/evaluation/calibration.py +74 -0
- cnn_classifier/evaluation/compare.py +111 -0
- cnn_classifier/evaluation/error_analysis.py +61 -0
- cnn_classifier/evaluation/evaluator.py +143 -0
- cnn_classifier/evaluation/latency.py +58 -0
- cnn_classifier/evaluation/metrics.py +94 -0
- cnn_classifier/evaluation/predict.py +64 -0
- cnn_classifier/models/__init__.py +50 -0
- cnn_classifier/models/base.py +62 -0
- cnn_classifier/models/residual_cnn.py +138 -0
- cnn_classifier/models/simple_cnn.py +55 -0
- cnn_classifier/models/summary.py +39 -0
- cnn_classifier/models/torchvision.py +46 -0
- cnn_classifier/models/transfer.py +193 -0
- cnn_classifier/runs/__init__.py +6 -0
- cnn_classifier/runs/artifacts.py +50 -0
- cnn_classifier/runs/context.py +130 -0
- cnn_classifier/tracking/__init__.py +34 -0
- cnn_classifier/tracking/base.py +16 -0
- cnn_classifier/tracking/composite.py +20 -0
- cnn_classifier/tracking/console.py +14 -0
- cnn_classifier/tracking/csv.py +19 -0
- cnn_classifier/tracking/tensorboard.py +22 -0
- cnn_classifier/training/__init__.py +37 -0
- cnn_classifier/training/amp.py +51 -0
- cnn_classifier/training/callbacks.py +229 -0
- cnn_classifier/training/checkpoint.py +151 -0
- cnn_classifier/training/losses.py +67 -0
- cnn_classifier/training/optim.py +40 -0
- cnn_classifier/training/schedulers.py +53 -0
- cnn_classifier/training/state.py +35 -0
- cnn_classifier/training/trainer.py +357 -0
- cnn_classifier/viz/__init__.py +42 -0
- cnn_classifier/viz/architecture.py +276 -0
- cnn_classifier/viz/compare.py +36 -0
- cnn_classifier/viz/data.py +56 -0
- cnn_classifier/viz/evaluation.py +134 -0
- cnn_classifier/viz/features.py +122 -0
- cnn_classifier/viz/report.py +821 -0
- cnn_classifier/viz/style.py +69 -0
- cnn_classifier/viz/templates/report.html.j2 +355 -0
- cnn_classifier/viz/training.py +83 -0
- cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
- cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
- cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
- cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- cnn_classifier_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CSV manifest parsing and deterministic dataset fingerprints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from cnn_classifier.core.errors import DataError
|
|
11
|
+
from cnn_classifier.core.hashing import stable_hash
|
|
12
|
+
from cnn_classifier.core.types import SampleRecord
|
|
13
|
+
|
|
14
|
+
SplitName = Literal["train", "val", "test"]
|
|
15
|
+
_REQUIRED_COLUMNS = ("path", "label", "split")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Manifest:
|
|
20
|
+
records: tuple[SampleRecord, ...]
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def class_names(self) -> tuple[str, ...]:
|
|
24
|
+
return tuple(sorted({record.label for record in self.records}))
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_csv(
|
|
28
|
+
cls,
|
|
29
|
+
path: Path,
|
|
30
|
+
image_root: Path,
|
|
31
|
+
columns: dict[str, str],
|
|
32
|
+
) -> Manifest:
|
|
33
|
+
"""Read a CSV manifest and validate every referenced image path."""
|
|
34
|
+
for logical in _REQUIRED_COLUMNS:
|
|
35
|
+
if logical not in columns:
|
|
36
|
+
raise DataError(f"manifest columns must include {logical!r}")
|
|
37
|
+
records: list[SampleRecord] = []
|
|
38
|
+
try:
|
|
39
|
+
handle = path.open("r", encoding="utf-8", newline="")
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
raise DataError(f"cannot read manifest {path}: {exc}") from exc
|
|
42
|
+
with handle:
|
|
43
|
+
reader = csv.DictReader(handle)
|
|
44
|
+
for row_number, row in enumerate(reader, start=2):
|
|
45
|
+
raw_path = row.get(columns["path"], "").strip()
|
|
46
|
+
label = row.get(columns["label"], "").strip()
|
|
47
|
+
split = row.get(columns["split"], "").strip()
|
|
48
|
+
if not raw_path or not label:
|
|
49
|
+
raise DataError(f"manifest row {row_number} is missing path or label")
|
|
50
|
+
if split not in {"train", "val", "test"}:
|
|
51
|
+
raise DataError(f"manifest row {row_number} has invalid split {split!r}")
|
|
52
|
+
image_path = Path(raw_path)
|
|
53
|
+
if not image_path.is_absolute():
|
|
54
|
+
image_path = image_root / image_path
|
|
55
|
+
if not image_path.is_file():
|
|
56
|
+
raise DataError(
|
|
57
|
+
f"manifest row {row_number} references missing file {image_path}"
|
|
58
|
+
)
|
|
59
|
+
group = None
|
|
60
|
+
if "group" in columns:
|
|
61
|
+
group = row.get(columns["group"], "").strip() or None
|
|
62
|
+
severity = None
|
|
63
|
+
if "severity" in columns:
|
|
64
|
+
raw_severity = row.get(columns["severity"], "").strip()
|
|
65
|
+
if raw_severity:
|
|
66
|
+
try:
|
|
67
|
+
severity = float(raw_severity)
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise DataError(
|
|
70
|
+
f"manifest row {row_number} has invalid severity {raw_severity!r}"
|
|
71
|
+
) from exc
|
|
72
|
+
records.append(
|
|
73
|
+
SampleRecord(
|
|
74
|
+
path=image_path,
|
|
75
|
+
label=label,
|
|
76
|
+
split=split, # type: ignore[arg-type]
|
|
77
|
+
group=group,
|
|
78
|
+
severity=severity,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if not records:
|
|
82
|
+
raise DataError(f"manifest {path} contains no records")
|
|
83
|
+
return cls(records=tuple(records))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fingerprint_records(records: tuple[SampleRecord, ...]) -> str:
|
|
87
|
+
"""Return a deterministic fingerprint independent of manifest row order."""
|
|
88
|
+
payload = [
|
|
89
|
+
{
|
|
90
|
+
"path": str(record.path),
|
|
91
|
+
"label": record.label,
|
|
92
|
+
"split": record.split,
|
|
93
|
+
"group": record.group,
|
|
94
|
+
"severity": record.severity,
|
|
95
|
+
}
|
|
96
|
+
for record in sorted(records, key=lambda item: str(item.path))
|
|
97
|
+
]
|
|
98
|
+
return stable_hash(payload)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Deterministic split policies and group-leakage checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from cnn_classifier.core.errors import DataError
|
|
13
|
+
from cnn_classifier.core.types import SplitInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class SplitIndices:
|
|
18
|
+
train_indices: tuple[int, ...]
|
|
19
|
+
val_indices: tuple[int, ...]
|
|
20
|
+
test_indices: tuple[int, ...] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _validate_fraction(val_fraction: float) -> None:
|
|
24
|
+
if not 0.0 < val_fraction < 1.0:
|
|
25
|
+
raise DataError("val_fraction must be in (0, 1)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def random_split_indices(
|
|
29
|
+
labels: Sequence[Any],
|
|
30
|
+
val_fraction: float,
|
|
31
|
+
seed: int,
|
|
32
|
+
) -> SplitIndices:
|
|
33
|
+
"""Split indices with a seeded permutation, without stratification."""
|
|
34
|
+
_validate_fraction(val_fraction)
|
|
35
|
+
count = len(labels)
|
|
36
|
+
if count < 2:
|
|
37
|
+
return SplitIndices(tuple(range(count)), ())
|
|
38
|
+
rng = np.random.default_rng(seed)
|
|
39
|
+
order = rng.permutation(count).tolist()
|
|
40
|
+
val_count = min(count - 1, max(1, round(count * val_fraction)))
|
|
41
|
+
val = tuple(sorted(order[:val_count]))
|
|
42
|
+
train = tuple(sorted(order[val_count:]))
|
|
43
|
+
return SplitIndices(train_indices=train, val_indices=val)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def stratified_split_indices(
|
|
47
|
+
labels: Sequence[Any],
|
|
48
|
+
val_fraction: float,
|
|
49
|
+
seed: int,
|
|
50
|
+
) -> SplitIndices:
|
|
51
|
+
"""Split each class proportionally, then combine the class splits."""
|
|
52
|
+
_validate_fraction(val_fraction)
|
|
53
|
+
by_class: dict[Any, list[int]] = defaultdict(list)
|
|
54
|
+
for index, label in enumerate(labels):
|
|
55
|
+
by_class[label].append(index)
|
|
56
|
+
rng = np.random.default_rng(seed)
|
|
57
|
+
train: list[int] = []
|
|
58
|
+
val: list[int] = []
|
|
59
|
+
for label in sorted(by_class):
|
|
60
|
+
indices = np.array(by_class[label])
|
|
61
|
+
order = rng.permutation(len(indices))
|
|
62
|
+
shuffled = indices[order].tolist()
|
|
63
|
+
val_count = min(len(shuffled) - 1, max(1, round(len(shuffled) * val_fraction)))
|
|
64
|
+
if len(shuffled) == 1:
|
|
65
|
+
val_count = 0
|
|
66
|
+
val.extend(shuffled[:val_count])
|
|
67
|
+
train.extend(shuffled[val_count:])
|
|
68
|
+
return SplitIndices(tuple(sorted(train)), tuple(sorted(val)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def group_split_indices(
|
|
72
|
+
groups: Sequence[str],
|
|
73
|
+
val_fraction: float,
|
|
74
|
+
seed: int,
|
|
75
|
+
) -> SplitIndices:
|
|
76
|
+
"""Assign whole groups to train or validation, never splitting a group."""
|
|
77
|
+
_validate_fraction(val_fraction)
|
|
78
|
+
group_to_indices: dict[str, list[int]] = defaultdict(list)
|
|
79
|
+
for index, group in enumerate(groups):
|
|
80
|
+
group_to_indices[group].append(index)
|
|
81
|
+
unique_groups = sorted(group_to_indices)
|
|
82
|
+
if len(unique_groups) < 2:
|
|
83
|
+
raise DataError("group split requires at least two groups")
|
|
84
|
+
rng = np.random.default_rng(seed)
|
|
85
|
+
order = rng.permutation(len(unique_groups)).tolist()
|
|
86
|
+
target = round(len(groups) * val_fraction)
|
|
87
|
+
val_groups: list[str] = []
|
|
88
|
+
val_count = 0
|
|
89
|
+
for position in order:
|
|
90
|
+
if val_count >= target and val_groups:
|
|
91
|
+
break
|
|
92
|
+
if len(val_groups) >= len(unique_groups) - 1:
|
|
93
|
+
break
|
|
94
|
+
group = unique_groups[position]
|
|
95
|
+
val_groups.append(group)
|
|
96
|
+
val_count += len(group_to_indices[group])
|
|
97
|
+
val_group_set = set(val_groups)
|
|
98
|
+
train_indices: list[int] = []
|
|
99
|
+
val_indices: list[int] = []
|
|
100
|
+
for group in unique_groups:
|
|
101
|
+
target_list = val_indices if group in val_group_set else train_indices
|
|
102
|
+
target_list.extend(group_to_indices[group])
|
|
103
|
+
return SplitIndices(tuple(sorted(train_indices)), tuple(sorted(val_indices)))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def assert_no_group_overlap(split: SplitIndices, groups: Sequence[str]) -> None:
|
|
107
|
+
"""Raise when a group appears in more than one split."""
|
|
108
|
+
members = {
|
|
109
|
+
"train": {groups[index] for index in split.train_indices},
|
|
110
|
+
"val": {groups[index] for index in split.val_indices},
|
|
111
|
+
"test": {groups[index] for index in (split.test_indices or ())},
|
|
112
|
+
}
|
|
113
|
+
for left, right in (("train", "val"), ("train", "test"), ("val", "test")):
|
|
114
|
+
overlap = members[left] & members[right]
|
|
115
|
+
if overlap:
|
|
116
|
+
raise DataError(f"groups leak across {left}/{right}: {sorted(overlap)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def make_split_info(
|
|
120
|
+
split: SplitIndices,
|
|
121
|
+
*,
|
|
122
|
+
mode: str,
|
|
123
|
+
seed: int,
|
|
124
|
+
group_column: str | None,
|
|
125
|
+
fingerprint: str,
|
|
126
|
+
) -> SplitInfo:
|
|
127
|
+
"""Package a split for the dataset bundle."""
|
|
128
|
+
return SplitInfo(
|
|
129
|
+
mode=mode, # type: ignore[arg-type]
|
|
130
|
+
train_indices=split.train_indices,
|
|
131
|
+
val_indices=split.val_indices,
|
|
132
|
+
test_indices=split.test_indices,
|
|
133
|
+
seed=seed,
|
|
134
|
+
group_column=group_column,
|
|
135
|
+
fingerprint=fingerprint,
|
|
136
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Channel normalization statistics with caching helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
from torch.utils.data import DataLoader, Dataset
|
|
13
|
+
|
|
14
|
+
from cnn_classifier.core.errors import DataError
|
|
15
|
+
from cnn_classifier.core.hashing import stable_hash
|
|
16
|
+
from cnn_classifier.core.types import NormalizationStats
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _as_batch(images: torch.Tensor) -> torch.Tensor:
|
|
20
|
+
if images.ndim == 3:
|
|
21
|
+
return images.unsqueeze(0)
|
|
22
|
+
if images.ndim != 4:
|
|
23
|
+
raise DataError(f"normalization expects [C,H,W] or [B,C,H,W]; got {tuple(images.shape)}")
|
|
24
|
+
return images
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_normalization(
|
|
28
|
+
dataset: Dataset[Any],
|
|
29
|
+
batch_size: int = 256,
|
|
30
|
+
) -> NormalizationStats:
|
|
31
|
+
"""Compute per-channel mean and standard deviation over a dataset.
|
|
32
|
+
|
|
33
|
+
Accumulation is in float64. A constant channel gets a standard deviation of
|
|
34
|
+
1.0 so downstream normalization never divides by zero.
|
|
35
|
+
"""
|
|
36
|
+
if len(dataset) == 0: # type: ignore[arg-type]
|
|
37
|
+
raise DataError("cannot compute normalization for an empty dataset")
|
|
38
|
+
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0)
|
|
39
|
+
sums: torch.Tensor | None = None
|
|
40
|
+
squares: torch.Tensor | None = None
|
|
41
|
+
count = 0
|
|
42
|
+
for item in loader:
|
|
43
|
+
images = item[0] if isinstance(item, (tuple, list)) else item
|
|
44
|
+
batch = _as_batch(images).to(torch.float64)
|
|
45
|
+
if sums is None:
|
|
46
|
+
sums = torch.zeros(batch.shape[1], dtype=torch.float64)
|
|
47
|
+
squares = torch.zeros(batch.shape[1], dtype=torch.float64)
|
|
48
|
+
assert sums is not None and squares is not None
|
|
49
|
+
sums += batch.sum(dim=(0, 2, 3))
|
|
50
|
+
squares += (batch * batch).sum(dim=(0, 2, 3))
|
|
51
|
+
count += batch.shape[0] * batch.shape[2] * batch.shape[3]
|
|
52
|
+
if sums is None or squares is None or count == 0:
|
|
53
|
+
raise DataError("dataset produced no samples for normalization")
|
|
54
|
+
mean = sums / count
|
|
55
|
+
variance = (squares / count) - (mean * mean)
|
|
56
|
+
std = torch.sqrt(torch.clamp(variance, min=0.0))
|
|
57
|
+
std = torch.where(std == 0, torch.ones_like(std), std)
|
|
58
|
+
return NormalizationStats(
|
|
59
|
+
mean=tuple(float(value) for value in mean.tolist()),
|
|
60
|
+
std=tuple(float(value) for value in std.tolist()),
|
|
61
|
+
source="computed",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_normalization(stats: NormalizationStats, path: Path) -> None:
|
|
66
|
+
"""Write normalization statistics as deterministic JSON."""
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
payload = {"mean": list(stats.mean), "std": list(stats.std), "source": stats.source}
|
|
69
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_normalization(path: Path) -> NormalizationStats:
|
|
73
|
+
"""Load normalization statistics written by :func:`save_normalization`."""
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
mean = tuple(float(value) for value in payload["mean"])
|
|
77
|
+
std = tuple(float(value) for value in payload["std"])
|
|
78
|
+
source = str(payload["source"])
|
|
79
|
+
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
80
|
+
raise DataError(f"cannot load normalization {path}: {exc}") from exc
|
|
81
|
+
if not all(math.isfinite(value) for value in (*mean, *std)):
|
|
82
|
+
raise DataError(f"normalization {path} contains non-finite values")
|
|
83
|
+
return NormalizationStats(mean=mean, std=std, source=source) # type: ignore[arg-type]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalization_cache_key(
|
|
87
|
+
dataset_fingerprint: str,
|
|
88
|
+
image_size: tuple[int, int],
|
|
89
|
+
channels: str,
|
|
90
|
+
value_range: Mapping[str, Any] | Any,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Return a stable cache key for a normalization computation."""
|
|
93
|
+
if hasattr(value_range, "model_dump"):
|
|
94
|
+
policy = value_range.model_dump()
|
|
95
|
+
elif isinstance(value_range, Mapping):
|
|
96
|
+
policy = dict(value_range)
|
|
97
|
+
else:
|
|
98
|
+
policy = str(value_range)
|
|
99
|
+
return stable_hash(
|
|
100
|
+
{
|
|
101
|
+
"fingerprint": dataset_fingerprint,
|
|
102
|
+
"image_size": list(image_size),
|
|
103
|
+
"channels": channels,
|
|
104
|
+
"value_range": policy,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Declarative torchvision v2 transform construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import torch
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from torchvision.transforms import v2
|
|
12
|
+
|
|
13
|
+
from cnn_classifier.config.schema import DataConfig, TransformConfig
|
|
14
|
+
from cnn_classifier.core.errors import ConfigError
|
|
15
|
+
from cnn_classifier.core.registry import Registry
|
|
16
|
+
from cnn_classifier.core.types import NormalizationStats
|
|
17
|
+
|
|
18
|
+
Transform = Callable[[Any], torch.Tensor]
|
|
19
|
+
TransformFactory = Callable[[TransformConfig, NormalizationStats | None], Callable[[Any], Any]]
|
|
20
|
+
TRANSFORMS: Registry[TransformFactory] = Registry("transform")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EnsureTensor:
|
|
24
|
+
"""Convert PIL/NumPy input to a float32 tensor, leaving tensors untouched."""
|
|
25
|
+
|
|
26
|
+
def __call__(self, value: Any) -> torch.Tensor:
|
|
27
|
+
if torch.is_tensor(value):
|
|
28
|
+
return value.to(torch.float32)
|
|
29
|
+
if isinstance(value, (Image.Image, np.ndarray)):
|
|
30
|
+
image = v2.functional.to_image(value)
|
|
31
|
+
return v2.functional.to_dtype(image, dtype=torch.float32, scale=True)
|
|
32
|
+
raise ConfigError(f"unsupported image type: {type(value).__name__}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_pair(
|
|
36
|
+
spec: TransformConfig,
|
|
37
|
+
normalization: NormalizationStats | None,
|
|
38
|
+
) -> tuple[tuple[float, ...], tuple[float, ...]]:
|
|
39
|
+
params = spec.params()
|
|
40
|
+
mean = params.get("mean", "auto")
|
|
41
|
+
std = params.get("std", "auto")
|
|
42
|
+
if mean == "auto" or std == "auto":
|
|
43
|
+
if normalization is None:
|
|
44
|
+
raise ConfigError(
|
|
45
|
+
f"transform {spec.name!r} needs normalization statistics; "
|
|
46
|
+
"set data.normalization.mode=supplied or add a compute pipeline"
|
|
47
|
+
)
|
|
48
|
+
return normalization.mean, normalization.std
|
|
49
|
+
if not isinstance(mean, (list, tuple)) or not isinstance(std, (list, tuple)):
|
|
50
|
+
raise ConfigError("Normalize mean/std must be lists/tuples or 'auto'")
|
|
51
|
+
return tuple(float(value) for value in mean), tuple(float(value) for value in std)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resize(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
55
|
+
params = spec.params()
|
|
56
|
+
return v2.Resize(params.get("size"), antialias=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _random_crop(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
60
|
+
params = spec.params()
|
|
61
|
+
return v2.RandomCrop(params.get("size"), padding=params.get("padding", 0))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _random_resized_crop(
|
|
65
|
+
spec: TransformConfig, _: NormalizationStats | None
|
|
66
|
+
) -> Callable[[Any], Any]:
|
|
67
|
+
params = spec.params()
|
|
68
|
+
return v2.RandomResizedCrop(
|
|
69
|
+
params.get("size"),
|
|
70
|
+
scale=tuple(params.get("scale", (0.08, 1.0))),
|
|
71
|
+
ratio=tuple(params.get("ratio", (0.75, 1.3333333333333333))),
|
|
72
|
+
antialias=True,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _color_jitter(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
77
|
+
params = spec.params()
|
|
78
|
+
return v2.ColorJitter(
|
|
79
|
+
brightness=params.get("brightness", 0.0),
|
|
80
|
+
contrast=params.get("contrast", 0.0),
|
|
81
|
+
saturation=params.get("saturation", 0.0),
|
|
82
|
+
hue=params.get("hue", 0.0),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _gaussian_blur(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
87
|
+
params = spec.params()
|
|
88
|
+
return v2.GaussianBlur(
|
|
89
|
+
kernel_size=params.get("kernel_size", 3), sigma=params.get("sigma", (0.1, 2.0))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _rand_augment(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
94
|
+
params = spec.params()
|
|
95
|
+
return v2.RandAugment(
|
|
96
|
+
num_ops=params.get("num_ops", 2),
|
|
97
|
+
magnitude=params.get("magnitude", 9),
|
|
98
|
+
num_magnitude_bins=params.get("num_magnitude_bins", 31),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _auto_augment(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
103
|
+
params = spec.params()
|
|
104
|
+
policy_name = str(params.get("policy", "imagenet")).lower()
|
|
105
|
+
policies = {
|
|
106
|
+
"imagenet": v2.AutoAugmentPolicy.IMAGENET,
|
|
107
|
+
"cifar10": v2.AutoAugmentPolicy.CIFAR10,
|
|
108
|
+
"svhn": v2.AutoAugmentPolicy.SVHN,
|
|
109
|
+
}
|
|
110
|
+
if policy_name not in policies:
|
|
111
|
+
raise ConfigError(f"unknown AutoAugment policy {policy_name!r}")
|
|
112
|
+
return v2.AutoAugment(policy=policies[policy_name])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _random_erasing(spec: TransformConfig, _: NormalizationStats | None) -> Callable[[Any], Any]:
|
|
116
|
+
params = spec.params()
|
|
117
|
+
return v2.RandomErasing(
|
|
118
|
+
p=params.get("p", 0.5),
|
|
119
|
+
scale=tuple(params.get("scale", (0.02, 0.33))),
|
|
120
|
+
ratio=tuple(params.get("ratio", (0.3, 3.3))),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _normalize(
|
|
125
|
+
spec: TransformConfig, normalization: NormalizationStats | None
|
|
126
|
+
) -> Callable[[Any], Any]:
|
|
127
|
+
mean, std = _normalize_pair(spec, normalization)
|
|
128
|
+
return v2.Normalize(mean=mean, std=std)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
_BUILTINS: dict[str, TransformFactory] = {
|
|
132
|
+
"resize": _resize,
|
|
133
|
+
"randomcrop": _random_crop,
|
|
134
|
+
"randomresizedcrop": _random_resized_crop,
|
|
135
|
+
"colorjitter": _color_jitter,
|
|
136
|
+
"gaussianblur": _gaussian_blur,
|
|
137
|
+
"randaugment": _rand_augment,
|
|
138
|
+
"autoaugment": _auto_augment,
|
|
139
|
+
"random erasing": _random_erasing,
|
|
140
|
+
"random_erasing": _random_erasing,
|
|
141
|
+
"randomerasing": _random_erasing,
|
|
142
|
+
"normalize": _normalize,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register_transform(name: str) -> Callable[[TransformFactory], TransformFactory]:
|
|
147
|
+
"""Register a custom transform factory."""
|
|
148
|
+
return TRANSFORMS.register(name)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _factory_for(name: str) -> TransformFactory:
|
|
152
|
+
lowered = name.lower()
|
|
153
|
+
if lowered == "totensor":
|
|
154
|
+
return lambda _spec, _norm: EnsureTensor()
|
|
155
|
+
if lowered == "randomhorizontalflip":
|
|
156
|
+
return lambda spec, _norm: v2.RandomHorizontalFlip(p=spec.params().get("p", 0.5))
|
|
157
|
+
if lowered == "randomverticalflip":
|
|
158
|
+
return lambda spec, _norm: v2.RandomVerticalFlip(p=spec.params().get("p", 0.5))
|
|
159
|
+
if lowered == "randomrotation":
|
|
160
|
+
return lambda spec, _norm: v2.RandomRotation(spec.params().get("degrees", 10))
|
|
161
|
+
if lowered == "centercrop":
|
|
162
|
+
return lambda spec, _norm: v2.CenterCrop(spec.params().get("size"))
|
|
163
|
+
if lowered == "grayscale":
|
|
164
|
+
return lambda spec, _norm: v2.Grayscale(
|
|
165
|
+
num_output_channels=spec.params().get("num_output_channels", 1)
|
|
166
|
+
)
|
|
167
|
+
if lowered in _BUILTINS:
|
|
168
|
+
return _BUILTINS[lowered]
|
|
169
|
+
try:
|
|
170
|
+
return TRANSFORMS.get(name)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
raise ConfigError(f"unknown transform {name!r}") from exc
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_transforms(
|
|
176
|
+
config: DataConfig,
|
|
177
|
+
normalization: NormalizationStats | None,
|
|
178
|
+
train: bool,
|
|
179
|
+
) -> Transform:
|
|
180
|
+
"""Build the train or evaluation transform pipeline."""
|
|
181
|
+
specs = config.transforms["train" if train else "eval"]
|
|
182
|
+
operations: list[Callable[[Any], Any]] = [EnsureTensor()]
|
|
183
|
+
for spec in specs:
|
|
184
|
+
if spec.name.lower() == "totensor":
|
|
185
|
+
continue
|
|
186
|
+
operations.append(_factory_for(spec.name)(spec, normalization))
|
|
187
|
+
return v2.Compose(operations)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def transform_params(spec: TransformConfig) -> Mapping[str, Any]:
|
|
191
|
+
"""Return transform parameters; useful for logging and tests."""
|
|
192
|
+
return spec.params()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Diagnostics: overfit checks, shape tracing, gradients, activations, determinism."""
|
|
2
|
+
|
|
3
|
+
from cnn_classifier.diagnostics.activations import ActivationStat, activation_report
|
|
4
|
+
from cnn_classifier.diagnostics.architecture import ArchitectureStage, architecture_stages
|
|
5
|
+
from cnn_classifier.diagnostics.determinism import determinism_check
|
|
6
|
+
from cnn_classifier.diagnostics.gradients import GradientStat, gradient_report
|
|
7
|
+
from cnn_classifier.diagnostics.sanity import OverfitResult, overfit_batch
|
|
8
|
+
from cnn_classifier.diagnostics.tracing import LayerShape, trace_shapes
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ActivationStat",
|
|
12
|
+
"ArchitectureStage",
|
|
13
|
+
"GradientStat",
|
|
14
|
+
"LayerShape",
|
|
15
|
+
"OverfitResult",
|
|
16
|
+
"activation_report",
|
|
17
|
+
"architecture_stages",
|
|
18
|
+
"determinism_check",
|
|
19
|
+
"gradient_report",
|
|
20
|
+
"overfit_batch",
|
|
21
|
+
"trace_shapes",
|
|
22
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Activation statistics for ReLU layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
from torch import nn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ActivationStat:
|
|
15
|
+
name: str
|
|
16
|
+
type: str
|
|
17
|
+
mean: float
|
|
18
|
+
std: float
|
|
19
|
+
zero_fraction: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def activation_report(model: nn.Module, batch: torch.Tensor) -> list[ActivationStat]:
|
|
23
|
+
"""Record mean, std, and zero fraction for each ReLU activation."""
|
|
24
|
+
parameter = next(model.parameters(), None)
|
|
25
|
+
device = parameter.device if parameter is not None else torch.device("cpu")
|
|
26
|
+
was_training = model.training
|
|
27
|
+
records: list[ActivationStat] = []
|
|
28
|
+
handles = []
|
|
29
|
+
|
|
30
|
+
def hook(name: str, module: nn.Module) -> Callable[[nn.Module, Any, Any], None]:
|
|
31
|
+
def _record(_module: nn.Module, _inputs: Any, output: Any) -> None:
|
|
32
|
+
tensor = output.detach().float()
|
|
33
|
+
records.append(
|
|
34
|
+
ActivationStat(
|
|
35
|
+
name=name,
|
|
36
|
+
type=type(module).__name__,
|
|
37
|
+
mean=float(tensor.mean().item()),
|
|
38
|
+
std=float(tensor.std().item()) if tensor.numel() > 1 else 0.0,
|
|
39
|
+
zero_fraction=float((tensor == 0).float().mean().item()),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return _record
|
|
44
|
+
|
|
45
|
+
for name, module in model.named_modules():
|
|
46
|
+
if isinstance(module, nn.ReLU):
|
|
47
|
+
handles.append(module.register_forward_hook(hook(name, module)))
|
|
48
|
+
try:
|
|
49
|
+
model.eval()
|
|
50
|
+
with torch.no_grad():
|
|
51
|
+
model(batch.to(device))
|
|
52
|
+
finally:
|
|
53
|
+
for handle in handles:
|
|
54
|
+
handle.remove()
|
|
55
|
+
model.train(was_training)
|
|
56
|
+
return records
|