cnn-classifier-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. cnn_classifier/__init__.py +5 -0
  2. cnn_classifier/_version.py +1 -0
  3. cnn_classifier/cli.py +1085 -0
  4. cnn_classifier/config/__init__.py +17 -0
  5. cnn_classifier/config/loader.py +102 -0
  6. cnn_classifier/config/presets/default.yaml +47 -0
  7. cnn_classifier/config/resolve.py +108 -0
  8. cnn_classifier/config/schema.py +251 -0
  9. cnn_classifier/core/__init__.py +19 -0
  10. cnn_classifier/core/device.py +50 -0
  11. cnn_classifier/core/errors.py +21 -0
  12. cnn_classifier/core/hashing.py +18 -0
  13. cnn_classifier/core/registry.py +46 -0
  14. cnn_classifier/core/seeding.py +37 -0
  15. cnn_classifier/core/types.py +97 -0
  16. cnn_classifier/data/__init__.py +22 -0
  17. cnn_classifier/data/adapters/__init__.py +19 -0
  18. cnn_classifier/data/adapters/_common.py +241 -0
  19. cnn_classifier/data/adapters/image_folder.py +65 -0
  20. cnn_classifier/data/adapters/manifest.py +62 -0
  21. cnn_classifier/data/adapters/numpy.py +96 -0
  22. cnn_classifier/data/adapters/registry.py +29 -0
  23. cnn_classifier/data/adapters/synthetic.py +114 -0
  24. cnn_classifier/data/adapters/torchvision.py +192 -0
  25. cnn_classifier/data/base.py +17 -0
  26. cnn_classifier/data/contract.py +28 -0
  27. cnn_classifier/data/image_io.py +116 -0
  28. cnn_classifier/data/inspection.py +111 -0
  29. cnn_classifier/data/loaders.py +79 -0
  30. cnn_classifier/data/manifest.py +98 -0
  31. cnn_classifier/data/splits.py +136 -0
  32. cnn_classifier/data/stats.py +106 -0
  33. cnn_classifier/data/transforms.py +192 -0
  34. cnn_classifier/diagnostics/__init__.py +22 -0
  35. cnn_classifier/diagnostics/activations.py +56 -0
  36. cnn_classifier/diagnostics/architecture.py +172 -0
  37. cnn_classifier/diagnostics/determinism.py +25 -0
  38. cnn_classifier/diagnostics/gradients.py +32 -0
  39. cnn_classifier/diagnostics/sanity.py +49 -0
  40. cnn_classifier/diagnostics/tracing.py +56 -0
  41. cnn_classifier/evaluation/__init__.py +53 -0
  42. cnn_classifier/evaluation/bootstrap.py +96 -0
  43. cnn_classifier/evaluation/calibration.py +74 -0
  44. cnn_classifier/evaluation/compare.py +111 -0
  45. cnn_classifier/evaluation/error_analysis.py +61 -0
  46. cnn_classifier/evaluation/evaluator.py +143 -0
  47. cnn_classifier/evaluation/latency.py +58 -0
  48. cnn_classifier/evaluation/metrics.py +94 -0
  49. cnn_classifier/evaluation/predict.py +64 -0
  50. cnn_classifier/models/__init__.py +50 -0
  51. cnn_classifier/models/base.py +62 -0
  52. cnn_classifier/models/residual_cnn.py +138 -0
  53. cnn_classifier/models/simple_cnn.py +55 -0
  54. cnn_classifier/models/summary.py +39 -0
  55. cnn_classifier/models/torchvision.py +46 -0
  56. cnn_classifier/models/transfer.py +193 -0
  57. cnn_classifier/runs/__init__.py +6 -0
  58. cnn_classifier/runs/artifacts.py +50 -0
  59. cnn_classifier/runs/context.py +130 -0
  60. cnn_classifier/tracking/__init__.py +34 -0
  61. cnn_classifier/tracking/base.py +16 -0
  62. cnn_classifier/tracking/composite.py +20 -0
  63. cnn_classifier/tracking/console.py +14 -0
  64. cnn_classifier/tracking/csv.py +19 -0
  65. cnn_classifier/tracking/tensorboard.py +22 -0
  66. cnn_classifier/training/__init__.py +37 -0
  67. cnn_classifier/training/amp.py +51 -0
  68. cnn_classifier/training/callbacks.py +229 -0
  69. cnn_classifier/training/checkpoint.py +151 -0
  70. cnn_classifier/training/losses.py +67 -0
  71. cnn_classifier/training/optim.py +40 -0
  72. cnn_classifier/training/schedulers.py +53 -0
  73. cnn_classifier/training/state.py +35 -0
  74. cnn_classifier/training/trainer.py +357 -0
  75. cnn_classifier/viz/__init__.py +42 -0
  76. cnn_classifier/viz/architecture.py +276 -0
  77. cnn_classifier/viz/compare.py +36 -0
  78. cnn_classifier/viz/data.py +56 -0
  79. cnn_classifier/viz/evaluation.py +134 -0
  80. cnn_classifier/viz/features.py +122 -0
  81. cnn_classifier/viz/report.py +821 -0
  82. cnn_classifier/viz/style.py +69 -0
  83. cnn_classifier/viz/templates/report.html.j2 +355 -0
  84. cnn_classifier/viz/training.py +83 -0
  85. cnn_classifier_toolkit-0.1.0.dist-info/METADATA +249 -0
  86. cnn_classifier_toolkit-0.1.0.dist-info/RECORD +89 -0
  87. cnn_classifier_toolkit-0.1.0.dist-info/WHEEL +4 -0
  88. cnn_classifier_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  89. cnn_classifier_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,55 @@
1
+ """Minimal stacked CNN baseline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import torch
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+ from torch import nn
10
+
11
+ from cnn_classifier.models.base import (
12
+ ModelBuildContext,
13
+ register_model,
14
+ )
15
+
16
+
17
+ class SimpleCNNParams(BaseModel):
18
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
19
+
20
+ channels: tuple[int, ...] = Field(default=(32, 64, 128), min_length=1)
21
+ dropout: float = Field(default=0.5, ge=0.0, lt=1.0)
22
+ norm: Literal["batch", "none"] = "batch"
23
+
24
+
25
+ class SimpleCNN(nn.Module):
26
+ def __init__(self, params: SimpleCNNParams, context: ModelBuildContext) -> None:
27
+ super().__init__()
28
+ layers: list[nn.Module] = []
29
+ in_channels = context.in_channels
30
+ for out_channels in params.channels:
31
+ layers.append(nn.Conv2d(in_channels, out_channels, 3, 1, 1, bias=params.norm == "none"))
32
+ if params.norm == "batch":
33
+ layers.append(nn.BatchNorm2d(out_channels))
34
+ layers.extend([nn.ReLU(inplace=True), nn.MaxPool2d(2)])
35
+ in_channels = out_channels
36
+ self.features = nn.Sequential(*layers)
37
+ self.head = nn.Sequential(
38
+ nn.AdaptiveAvgPool2d(1),
39
+ nn.Flatten(),
40
+ nn.Dropout(params.dropout),
41
+ nn.Linear(in_channels, context.num_classes),
42
+ )
43
+
44
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
45
+ result: torch.Tensor = self.head(self.features(x))
46
+ return result
47
+
48
+
49
+ @register_model("simple_cnn")
50
+ class SimpleCNNBuilder:
51
+ Params = SimpleCNNParams
52
+
53
+ @staticmethod
54
+ def build(params: SimpleCNNParams, context: ModelBuildContext) -> nn.Module:
55
+ return SimpleCNN(params, context)
@@ -0,0 +1,39 @@
1
+ """Model parameter and shape summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+
11
+ def model_summary(model: nn.Module, input_shape: tuple[int, int, int]) -> dict[str, Any]:
12
+ """Return parameter counts, module details, and output shape."""
13
+ was_training = model.training
14
+ parameter = next(model.parameters(), None)
15
+ device = parameter.device if parameter is not None else torch.device("cpu")
16
+ model.eval()
17
+ try:
18
+ with torch.no_grad():
19
+ output = model(torch.zeros(1, *input_shape, device=device))
20
+ finally:
21
+ model.train(was_training)
22
+ modules = [
23
+ {
24
+ "name": name or "model",
25
+ "type": type(module).__name__,
26
+ "params": sum(parameter.numel() for parameter in module.parameters()),
27
+ }
28
+ for name, module in model.named_modules()
29
+ ]
30
+ return {
31
+ "input_shape": list(input_shape),
32
+ "output_shape": list(output.shape),
33
+ "total_params": sum(parameter.numel() for parameter in model.parameters()),
34
+ "trainable_params": sum(
35
+ parameter.numel() for parameter in model.parameters() if parameter.requires_grad
36
+ ),
37
+ "modules": modules,
38
+ "macs": None,
39
+ }
@@ -0,0 +1,46 @@
1
+ """Torchvision model builder with transfer-learning controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import torchvision
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+ from torch import nn
10
+
11
+ from cnn_classifier.core.errors import ConfigError
12
+ from cnn_classifier.models.base import ModelBuildContext, register_model
13
+ from cnn_classifier.models.transfer import (
14
+ apply_stem_adaptation,
15
+ apply_transfer,
16
+ replace_classifier,
17
+ )
18
+
19
+
20
+ class TorchvisionParams(BaseModel):
21
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
22
+
23
+ model: str = "resnet18"
24
+ stem_adaptation: Literal["none", "keep_7x7_stride1", "adapt_3x3"] = "none"
25
+ dropout: float = Field(default=0.0, ge=0.0, lt=1.0)
26
+
27
+
28
+ @register_model("torchvision")
29
+ class TorchvisionBuilder:
30
+ Params = TorchvisionParams
31
+
32
+ @staticmethod
33
+ def build(params: TorchvisionParams, context: ModelBuildContext) -> nn.Module:
34
+ try:
35
+ model = torchvision.models.get_model(
36
+ params.model,
37
+ weights=context.transfer.weights,
38
+ )
39
+ except (RuntimeError, ValueError) as exc:
40
+ raise ConfigError(f"cannot build torchvision model {params.model!r}: {exc}") from exc
41
+ if not isinstance(model, nn.Module):
42
+ raise ConfigError(f"torchvision model {params.model!r} did not return an nn.Module")
43
+ replace_classifier(model, context.num_classes, dropout=params.dropout)
44
+ apply_stem_adaptation(model, params.stem_adaptation, context.in_channels)
45
+ apply_transfer(model, context.transfer, stem_adaptation=params.stem_adaptation)
46
+ return model
@@ -0,0 +1,193 @@
1
+ """Transfer-learning helpers for stem adaptation, freezing, and param groups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from typing import Any, Literal
8
+
9
+ import torch
10
+ from torch import nn
11
+
12
+ from cnn_classifier.config.schema import TransferConfig
13
+ from cnn_classifier.core.errors import ConfigError
14
+
15
+ StemMode = Literal["none", "keep_7x7_stride1", "adapt_3x3"]
16
+
17
+
18
+ def _head_name(model: nn.Module) -> str:
19
+ if hasattr(model, "fc") and isinstance(model.fc, (nn.Linear, nn.Sequential)):
20
+ return "fc"
21
+ if hasattr(model, "classifier") and isinstance(model.classifier, (nn.Linear, nn.Sequential)):
22
+ return "classifier"
23
+ raise ConfigError("cannot identify the classifier head for this model")
24
+
25
+
26
+ def _last_linear(module: nn.Module) -> nn.Linear:
27
+ if isinstance(module, nn.Linear):
28
+ return module
29
+ if isinstance(module, nn.Sequential):
30
+ for child in reversed(module):
31
+ if isinstance(child, nn.Linear):
32
+ return child
33
+ raise ConfigError("classifier does not contain a Linear layer")
34
+
35
+
36
+ def replace_classifier(model: nn.Module, num_classes: int, dropout: float = 0.0) -> nn.Module:
37
+ """Replace the final classifier with a task-specific head."""
38
+ if hasattr(model, "fc") and isinstance(model.fc, (nn.Linear, nn.Sequential)):
39
+ in_features = _last_linear(model.fc).in_features
40
+ head = nn.Linear(in_features, num_classes)
41
+ model.fc = nn.Sequential(nn.Dropout(dropout), head) if dropout > 0 else head
42
+ return model
43
+ if hasattr(model, "classifier") and isinstance(model.classifier, (nn.Linear, nn.Sequential)):
44
+ classifier = model.classifier
45
+ in_features = _last_linear(classifier).in_features
46
+ new_layer = nn.Linear(in_features, num_classes)
47
+ if isinstance(classifier, nn.Linear):
48
+ model.classifier = new_layer
49
+ else:
50
+ for index in range(len(classifier) - 1, -1, -1):
51
+ if isinstance(classifier[index], nn.Linear):
52
+ classifier[index] = new_layer
53
+ break
54
+ return model
55
+ raise ConfigError("unsupported torchvision classifier head")
56
+
57
+
58
+ def apply_stem_adaptation(
59
+ model: nn.Module,
60
+ mode: StemMode,
61
+ in_channels: int = 3,
62
+ ) -> nn.Module:
63
+ """Adapt a torchvision stem for native 32x32 inputs."""
64
+ if mode == "none":
65
+ return model
66
+ if not hasattr(model, "conv1") or not isinstance(model.conv1, nn.Conv2d):
67
+ raise ConfigError(f"model {type(model).__name__} has no supported conv1 stem")
68
+ conv = model.conv1
69
+ if conv.in_channels != in_channels:
70
+ raise ConfigError(
71
+ f"stem expects {conv.in_channels} input channels but data has {in_channels}"
72
+ )
73
+ if mode == "keep_7x7_stride1":
74
+ conv.stride = (1, 1)
75
+ model.maxpool = nn.Identity()
76
+ return model
77
+ if mode == "adapt_3x3":
78
+ new_conv = nn.Conv2d(
79
+ in_channels,
80
+ conv.out_channels,
81
+ 3,
82
+ 1,
83
+ 1,
84
+ bias=conv.bias is not None,
85
+ device=conv.weight.device,
86
+ dtype=conv.weight.dtype,
87
+ )
88
+ kernel = conv.weight.data
89
+ height, width = kernel.shape[-2:]
90
+ top = (height - 3) // 2
91
+ left = (width - 3) // 2
92
+ with torch.no_grad():
93
+ new_conv.weight.copy_(kernel[:, :, top : top + 3, left : left + 3])
94
+ if conv.bias is not None and new_conv.bias is not None:
95
+ new_conv.bias.copy_(conv.bias.data)
96
+ model.conv1 = new_conv
97
+ model.maxpool = nn.Identity()
98
+ return model
99
+ raise ConfigError(f"unknown stem adaptation {mode!r}")
100
+
101
+
102
+ def _head_parameters(model: nn.Module) -> set[str]:
103
+ head = _head_name(model)
104
+ return {
105
+ name for name, _ in model.named_parameters() if name == head or name.startswith(f"{head}.")
106
+ }
107
+
108
+
109
+ def apply_transfer(
110
+ model: nn.Module,
111
+ transfer: TransferConfig,
112
+ *,
113
+ stem_adaptation: str,
114
+ ) -> nn.Module:
115
+ """Apply freezing, BatchNorm policy, and validation for a transfer run."""
116
+ if transfer.freeze in {"backbone", "all_but_head"}:
117
+ if stem_adaptation == "adapt_3x3":
118
+ raise ConfigError(
119
+ "frozen adapt_3x3 is invalid: the uninitialized stem would block ImageNet features"
120
+ )
121
+ head_names = _head_parameters(model)
122
+ for name, parameter in model.named_parameters():
123
+ parameter.requires_grad = name in head_names
124
+ if transfer.train_bn:
125
+ for module in model.modules():
126
+ if isinstance(module, nn.modules.batchnorm._BatchNorm):
127
+ for parameter in module.parameters():
128
+ parameter.requires_grad = True
129
+ return model
130
+
131
+
132
+ def build_param_groups(
133
+ model: nn.Module,
134
+ transfer: TransferConfig,
135
+ *,
136
+ base_lr: float,
137
+ base_weight_decay: float,
138
+ ) -> list[dict[str, Any]]:
139
+ """Build disjoint optimizer groups, validating every regex pattern."""
140
+ trainable = {
141
+ name: parameter for name, parameter in model.named_parameters() if parameter.requires_grad
142
+ }
143
+ if not trainable:
144
+ raise ConfigError("model has no trainable parameters")
145
+ remaining = dict(trainable)
146
+ groups: list[dict[str, Any]] = []
147
+ for group_config in transfer.param_groups:
148
+ pattern = re.compile(group_config.pattern)
149
+ matched = [
150
+ (name, parameter) for name, parameter in remaining.items() if pattern.search(name)
151
+ ]
152
+ if not matched:
153
+ raise ConfigError(
154
+ f"param group pattern {group_config.pattern!r} did not match "
155
+ "any trainable parameter"
156
+ )
157
+ groups.append(
158
+ {
159
+ "params": [parameter for _, parameter in matched],
160
+ "lr": base_lr * group_config.lr_multiplier,
161
+ "weight_decay": base_weight_decay * group_config.weight_decay_multiplier,
162
+ }
163
+ )
164
+ for name, _ in matched:
165
+ del remaining[name]
166
+ if remaining:
167
+ groups.insert(
168
+ 0,
169
+ {
170
+ "params": list(remaining.values()),
171
+ "lr": base_lr,
172
+ "weight_decay": base_weight_decay,
173
+ },
174
+ )
175
+ if not groups:
176
+ groups = [
177
+ {
178
+ "params": list(trainable.values()),
179
+ "lr": base_lr,
180
+ "weight_decay": base_weight_decay,
181
+ }
182
+ ]
183
+ return groups
184
+
185
+
186
+ def unfreeze_modules(model: nn.Module, names: Sequence[str]) -> None:
187
+ """Unfreeze every parameter under the named submodules."""
188
+ modules = dict(model.named_modules())
189
+ for name in names:
190
+ if name not in modules:
191
+ raise ConfigError(f"cannot unfreeze unknown module {name!r}")
192
+ for parameter in modules[name].parameters():
193
+ parameter.requires_grad = True
@@ -0,0 +1,6 @@
1
+ """Run directory and artifact helpers."""
2
+
3
+ from cnn_classifier.runs.artifacts import MetricsWriter
4
+ from cnn_classifier.runs.context import RunContext
5
+
6
+ __all__ = ["MetricsWriter", "RunContext"]
@@ -0,0 +1,50 @@
1
+ """CSV and JSONL metrics writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from cnn_classifier.runs.context import RunContext
12
+
13
+
14
+ @dataclass
15
+ class MetricsWriter:
16
+ run: RunContext
17
+ _header: list[str] | None = field(default=None, init=False)
18
+
19
+ def _metrics_path(self) -> Path:
20
+ return self.run.directory / "metrics.jsonl"
21
+
22
+ def _history_path(self) -> Path:
23
+ return self.run.directory / "history.csv"
24
+
25
+ def append(self, metrics: Mapping[str, float], step: int) -> None:
26
+ payload = {"step": int(step), **{key: float(value) for key, value in metrics.items()}}
27
+ with self._metrics_path().open("a", encoding="utf-8") as handle:
28
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
29
+ if self._header is None:
30
+ self._header = self._existing_header()
31
+ if self._header is None:
32
+ self._header = ["step", *sorted(metrics)]
33
+ with self._history_path().open("w", encoding="utf-8", newline="") as handle:
34
+ writer = csv.DictWriter(handle, fieldnames=self._header)
35
+ writer.writeheader()
36
+ with self._history_path().open("a", encoding="utf-8", newline="") as handle:
37
+ writer = csv.DictWriter(handle, fieldnames=self._header, extrasaction="ignore")
38
+ writer.writerow(payload)
39
+
40
+ def _existing_header(self) -> list[str] | None:
41
+ path = self._history_path()
42
+ if not path.is_file() or path.stat().st_size == 0:
43
+ return None
44
+ with path.open(encoding="utf-8", newline="") as handle:
45
+ reader = csv.reader(handle)
46
+ try:
47
+ header = next(reader)
48
+ except StopIteration:
49
+ return None
50
+ return header or None
@@ -0,0 +1,130 @@
1
+ """Run directories, metadata, and atomic artifact writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import torch
17
+ import torchvision
18
+ import yaml
19
+
20
+ from cnn_classifier.config.schema import RunConfig
21
+ from cnn_classifier.core.device import device_report
22
+ from cnn_classifier.core.hashing import stable_hash
23
+
24
+
25
+ def _slug(value: str) -> str:
26
+ cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")
27
+ return cleaned or "run"
28
+
29
+
30
+ def _git_metadata() -> dict[str, Any]:
31
+ try:
32
+ commit = subprocess.run(
33
+ ["git", "rev-parse", "--short", "HEAD"],
34
+ capture_output=True,
35
+ text=True,
36
+ check=True,
37
+ ).stdout.strip()
38
+ branch = subprocess.run(
39
+ ["git", "branch", "--show-current"],
40
+ capture_output=True,
41
+ text=True,
42
+ check=True,
43
+ ).stdout.strip()
44
+ dirty = bool(
45
+ subprocess.run(
46
+ ["git", "status", "--porcelain"],
47
+ capture_output=True,
48
+ text=True,
49
+ check=True,
50
+ ).stdout.strip()
51
+ )
52
+ except (OSError, subprocess.CalledProcessError):
53
+ return {"commit": None, "branch": None, "dirty": None}
54
+ return {"commit": commit, "branch": branch, "dirty": dirty}
55
+
56
+
57
+ @dataclass
58
+ class RunContext:
59
+ """A self-describing directory for one experiment run."""
60
+
61
+ directory: Path
62
+ config: RunConfig
63
+ config_hash: str
64
+
65
+ @classmethod
66
+ def create(cls, config: RunConfig, root: Path = Path("runs")) -> RunContext:
67
+ config_hash = stable_hash(config.model_dump(mode="json"))
68
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
69
+ base = root / f"{timestamp}_{_slug(config.experiment.name)}_{config_hash[:8]}"
70
+ directory = base
71
+ suffix = 1
72
+ while directory.exists():
73
+ directory = base.with_name(f"{base.name}_{suffix}")
74
+ suffix += 1
75
+ for child in ("data", "checkpoints", "predictions", "figures"):
76
+ (directory / child).mkdir(parents=True, exist_ok=True)
77
+ run = cls(directory=directory, config=config, config_hash=config_hash)
78
+ run.write_text(
79
+ "config.resolved.yaml",
80
+ yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False),
81
+ )
82
+ run.write_json(
83
+ "environment.json",
84
+ {
85
+ "python": platform.python_version(),
86
+ "platform": platform.platform(),
87
+ "torch": torch.__version__,
88
+ "torchvision": torchvision.__version__,
89
+ "device": device_report(),
90
+ },
91
+ )
92
+ run.write_json("git.json", _git_metadata())
93
+ run.set_status("running")
94
+ return run
95
+
96
+ def _atomic_write(self, relative_path: str, data: bytes) -> Path:
97
+ path = self.directory / relative_path
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ descriptor, temporary = tempfile.mkstemp(
100
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
101
+ )
102
+ temporary_path = Path(temporary)
103
+ try:
104
+ with os.fdopen(descriptor, "wb") as handle:
105
+ handle.write(data)
106
+ os.replace(temporary_path, path)
107
+ except BaseException:
108
+ temporary_path.unlink(missing_ok=True)
109
+ raise
110
+ return path
111
+
112
+ def write_json(self, relative_path: str, payload: Any) -> Path:
113
+ text = json.dumps(payload, indent=2, sort_keys=True, allow_nan=False) + "\n"
114
+ return self._atomic_write(relative_path, text.encode("utf-8"))
115
+
116
+ def write_text(self, relative_path: str, text: str) -> Path:
117
+ return self._atomic_write(relative_path, text.encode("utf-8"))
118
+
119
+ def set_status(self, status: str, error: str | None = None) -> None:
120
+ self.write_json(
121
+ "status.json",
122
+ {
123
+ "status": status,
124
+ "updated_at": datetime.now(UTC).isoformat(),
125
+ "error": error,
126
+ },
127
+ )
128
+
129
+ def close(self, status: str = "completed") -> None:
130
+ self.set_status(status)
@@ -0,0 +1,34 @@
1
+ """Tracker construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ from cnn_classifier.config.schema import TrackingConfig
8
+ from cnn_classifier.runs.context import RunContext
9
+ from cnn_classifier.tracking.base import Tracker
10
+ from cnn_classifier.tracking.composite import CompositeTracker
11
+ from cnn_classifier.tracking.console import ConsoleTracker
12
+ from cnn_classifier.tracking.csv import CSVTracker
13
+ from cnn_classifier.tracking.tensorboard import TensorBoardTracker
14
+
15
+ __all__ = [
16
+ "CSVTracker",
17
+ "CompositeTracker",
18
+ "ConsoleTracker",
19
+ "TensorBoardTracker",
20
+ "build_trackers",
21
+ ]
22
+
23
+
24
+ def build_trackers(config: TrackingConfig, run: RunContext) -> CompositeTracker:
25
+ """Build the enabled trackers for one run."""
26
+ trackers: list[Tracker] = []
27
+ if config.console:
28
+ trackers.append(ConsoleTracker())
29
+ if config.csv:
30
+ trackers.append(CSVTracker(run))
31
+ if config.tensorboard:
32
+ with contextlib.suppress(RuntimeError):
33
+ trackers.append(TensorBoardTracker(str(run.directory / "tensorboard")))
34
+ return CompositeTracker(trackers)
@@ -0,0 +1,16 @@
1
+ """Tracker protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Protocol
7
+
8
+
9
+ class Tracker(Protocol):
10
+ def log_metrics(self, metrics: Mapping[str, float], step: int) -> None:
11
+ """Record one step of metrics."""
12
+ ...
13
+
14
+ def close(self) -> None:
15
+ """Release resources."""
16
+ ...
@@ -0,0 +1,20 @@
1
+ """Composite tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ from cnn_classifier.tracking.base import Tracker
8
+
9
+
10
+ class CompositeTracker:
11
+ def __init__(self, trackers: Sequence[Tracker]) -> None:
12
+ self.trackers = list(trackers)
13
+
14
+ def log_metrics(self, metrics: Mapping[str, float], step: int) -> None:
15
+ for tracker in self.trackers:
16
+ tracker.log_metrics(metrics, step)
17
+
18
+ def close(self) -> None:
19
+ for tracker in self.trackers:
20
+ tracker.close()
@@ -0,0 +1,14 @@
1
+ """Console tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+
8
+ class ConsoleTracker:
9
+ def log_metrics(self, metrics: Mapping[str, float], step: int) -> None:
10
+ rendered = " | ".join(f"{key}={value:.4f}" for key, value in metrics.items())
11
+ print(f"step {step}: {rendered}")
12
+
13
+ def close(self) -> None:
14
+ return None
@@ -0,0 +1,19 @@
1
+ """CSV/JSONL tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from cnn_classifier.runs.artifacts import MetricsWriter
8
+ from cnn_classifier.runs.context import RunContext
9
+
10
+
11
+ class CSVTracker:
12
+ def __init__(self, run: RunContext) -> None:
13
+ self.writer = MetricsWriter(run)
14
+
15
+ def log_metrics(self, metrics: Mapping[str, float], step: int) -> None:
16
+ self.writer.append(metrics, step)
17
+
18
+ def close(self) -> None:
19
+ return None
@@ -0,0 +1,22 @@
1
+ """Optional TensorBoard tracker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ class TensorBoardTracker:
10
+ def __init__(self, log_dir: str) -> None:
11
+ try:
12
+ from torch.utils.tensorboard import SummaryWriter
13
+ except ImportError as exc:
14
+ raise RuntimeError("TensorBoard tracking requires the 'tracking' extra") from exc
15
+ self.writer: Any = SummaryWriter(log_dir)
16
+
17
+ def log_metrics(self, metrics: Mapping[str, float], step: int) -> None:
18
+ for key, value in metrics.items():
19
+ self.writer.add_scalar(key, value, step)
20
+
21
+ def close(self) -> None:
22
+ self.writer.close()