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,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,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()
|