added analytics and logs

This commit is contained in:
2026-10-03 10:45:48 +02:00
parent c36f34dea9
commit 0715c66a64
11 changed files with 1597 additions and 11 deletions
+1
View File
@@ -1,6 +1,7 @@
api/data/ api/data/
api/data.* api/data.*
api/saved_models/ api/saved_models/
api/runs/
api/testimages/ api/testimages/
**/__pycache__/ **/__pycache__/
File diff suppressed because it is too large Load Diff
+4
View File
@@ -28,6 +28,10 @@ class Config:
model_name: str = "bird_cnn" model_name: str = "bird_cnn"
save: bool = True save: bool = True
# Run log (src/common/run_log.py): one folder per training run
runs_dir: str = "runs" # empty = no run log
run_name: str = "" # added to the run folder name, e.g. "bn-lr3e-4"
# predict task # predict task
image: str = "test1.jpg" image: str = "test1.jpg"
checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name> checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name>
+1 -1
View File
@@ -56,7 +56,7 @@ def train(cfg: Config):
fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device, fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save, epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save,
monitor="acc", track_accuracy=True) monitor="acc", track_accuracy=True, config=cfg)
def view_data(cfg: Config): def view_data(cfg: Config):
+197
View File
@@ -0,0 +1,197 @@
"""Records every training run in its own folder, so runs with different
settings or architectures can be compared later.
<runs_dir>/<model_name>/<YYYYmmdd-HHMMSS>[_<run_name>]/
run.json everything about the run: config, command, model
(class, parameter counts, architecture hash),
optimizer, loss, data sizes, environment, git state
and the result (status, best epoch and metric,
duration). Rewritten after every epoch.
metrics.csv one row per epoch
architecture.txt the model's module tree, str(model)
changes.diff uncommitted code changes (only if there are any)
best.pt state_dict of the best epoch so far
result.status in run.json is "running" during training, then "completed",
"failed" or "interrupted". A run whose process was killed stays "running".
"""
import csv
import dataclasses
import hashlib
import json
import os
import platform
import re
import socket
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import torch
REPO_DIR = Path(__file__).resolve().parent
def _git(*args):
try:
return subprocess.run(["git", *args], cwd=REPO_DIR, capture_output=True, text=True,
timeout=10, check=True).stdout
except (OSError, subprocess.SubprocessError):
return None
def _jsonable(value):
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, torch.Tensor):
return value.tolist() if value.numel() <= 16 else f"tensor{tuple(value.shape)}"
return str(value)
def _module_settings(module):
"""Public attributes and small buffers, e.g. a loss's reduction and class weights."""
settings = {k: v for k, v in vars(module).items() if not k.startswith("_") and k != "training"}
settings.update(dict(module.named_buffers()))
return _jsonable(settings)
def _dataset_size(loader):
try:
return len(loader.dataset)
except TypeError:
return None
class RunLog:
def __init__(self, runs_dir, model_name, config, model, optimizer, loss_fn,
train_loader, val_loader, device, monitor):
self.monitor = monitor
self.started = time.time()
self.dir = self._make_dir(Path(runs_dir) / model_name, getattr(config, "run_name", ""))
self._csv_fields = None
architecture = str(model)
status = _git("status", "--porcelain")
diff = _git("diff", "HEAD")
gpu = torch.cuda.get_device_name(device) if torch.device(device).type == "cuda" else None
self.info = {
"id": f"{model_name}/{self.dir.name}",
"model_name": model_name,
"started": datetime.fromtimestamp(self.started).isoformat(timespec="seconds"),
"command": " ".join(sys.argv),
"config": _jsonable(dataclasses.asdict(config)),
"model": {
"class": type(model).__name__,
"params": sum(p.numel() for p in model.parameters()),
"trainable_params": sum(p.numel() for p in model.parameters() if p.requires_grad),
"architecture_hash": hashlib.sha256(architecture.encode()).hexdigest()[:12],
},
"optimizer": {
"class": type(optimizer).__name__,
**_jsonable({k: v for k, v in optimizer.param_groups[0].items() if k != "params"}),
},
"loss": {"class": type(loss_fn).__name__, **_module_settings(loss_fn)},
"data": {
"train_size": _dataset_size(train_loader),
"val_size": _dataset_size(val_loader),
"batch_size": train_loader.batch_size,
"train_workers": train_loader.num_workers,
},
"environment": {
"host": socket.gethostname(),
"platform": platform.platform(),
"python": platform.python_version(),
"torch": torch.__version__,
"cuda": torch.version.cuda,
"device": str(device),
"gpu": gpu,
"seed": torch.initial_seed(),
},
"git": {
"commit": (_git("rev-parse", "HEAD") or "").strip() or None,
"branch": (_git("rev-parse", "--abbrev-ref", "HEAD") or "").strip() or None,
"dirty": bool(status.strip()) if status is not None else None,
"changed_files": [line[3:] for line in status.splitlines()] if status else [],
},
"result": {
"status": "running",
"monitor": monitor,
"epochs_done": 0,
"best_epoch": None,
f"best_val_{monitor}": None,
"last": None,
"duration_s": 0,
"finished": None,
"error": None,
},
}
(self.dir / "architecture.txt").write_text(architecture + "\n")
if diff:
(self.dir / "changes.diff").write_text(diff)
self._write_info()
print(f"Logging run to {self.dir}")
@staticmethod
def _make_dir(parent, run_name):
name = datetime.now().strftime("%Y%m%d-%H%M%S")
if run_name:
name += "_" + re.sub(r"[^\w.-]+", "-", run_name)
parent.mkdir(parents=True, exist_ok=True)
for i in range(1, 100):
path = parent / (name if i == 1 else f"{name}-{i}")
try:
path.mkdir()
return path
except FileExistsError:
continue
raise FileExistsError(f"no free run folder for {parent / name}")
def _write_info(self):
# Write-then-rename so a reader never sees a half written file.
tmp = self.dir / "run.json.tmp"
tmp.write_text(json.dumps(self.info, indent=2) + "\n")
os.replace(tmp, self.dir / "run.json")
def log_epoch(self, epoch, train, val, lr, seconds, improved):
row = {"epoch": epoch,
**{f"train_{k}": v for k, v in train.items()},
**{f"val_{k}": v for k, v in val.items()},
"lr": lr, "seconds": round(seconds, 2), "improved": improved}
path = self.dir / "metrics.csv"
with path.open("a", newline="") as f:
if self._csv_fields is None:
self._csv_fields = list(row)
writer = csv.DictWriter(f, fieldnames=self._csv_fields)
writer.writeheader()
else:
writer = csv.DictWriter(f, fieldnames=self._csv_fields)
writer.writerow(row)
result = self.info["result"]
result["epochs_done"] = epoch
result["last"] = {k: v for k, v in row.items() if k != "improved"}
if improved:
result["best_epoch"] = epoch
result[f"best_val_{self.monitor}"] = val[self.monitor]
result["duration_s"] = round(time.time() - self.started, 1)
self._write_info()
def save_weights(self, model):
torch.save(model.state_dict(), self.dir / "best.pt")
def finish(self, status, error=None):
result = self.info["result"]
result["status"] = status
result["error"] = error
result["duration_s"] = round(time.time() - self.started, 1)
result["finished"] = datetime.now().isoformat(timespec="seconds")
self._write_info()
+34 -1
View File
@@ -2,7 +2,10 @@
Each epoch trains, validates, prints the metrics and saves the model when the Each epoch trains, validates, prints the metrics and saves the model when the
monitored metric improves. `prepare_batch` turns a loader batch into monitored metric improves. `prepare_batch` turns a loader batch into
(inputs, targets); the default just moves both to the device.""" (inputs, targets); the default just moves both to the device.
Passing the task's `config` also records the run (settings, architecture,
metrics per epoch, best weights) under config.runs_dir, see run_log.py."""
import math import math
import time import time
@@ -12,6 +15,7 @@ from torch.utils.data import DataLoader
from tqdm import tqdm from tqdm import tqdm
from src.common.checkpoints import save_checkpoint from src.common.checkpoints import save_checkpoint
from src.common.run_log import RunLog
def _default_prepare(batch, device): def _default_prepare(batch, device):
@@ -64,10 +68,34 @@ def fit(
monitor: str = "loss", # "loss" (lower is better) or "acc" (higher is better) monitor: str = "loss", # "loss" (lower is better) or "acc" (higher is better)
track_accuracy: bool = False, # for classifiers track_accuracy: bool = False, # for classifiers
prepare_batch=_default_prepare, prepare_batch=_default_prepare,
config=None, # the task's Config; enables the run log
): ):
run = None
if config is not None and config.runs_dir:
run = RunLog(config.runs_dir, model_name, config, model, optimizer, loss_fn,
train_loader, val_loader, device, monitor)
try:
best = _fit_epochs(model, loss_fn, optimizer, train_loader, val_loader, device, epochs,
save_dir, model_name, save, monitor, track_accuracy, prepare_batch, run)
except KeyboardInterrupt:
if run:
run.finish("interrupted")
raise
except Exception as e:
if run:
run.finish("failed", error=repr(e))
raise
if run:
run.finish("completed")
return best
def _fit_epochs(model, loss_fn, optimizer, train_loader, val_loader, device, epochs,
save_dir, model_name, save, monitor, track_accuracy, prepare_batch, run):
best = math.inf if monitor == "loss" else -math.inf best = math.inf if monitor == "loss" else -math.inf
for epoch in range(1, epochs + 1): for epoch in range(1, epochs + 1):
start = time.perf_counter()
train = _run_epoch(model, loss_fn, train_loader, device, prepare_batch, track_accuracy, train = _run_epoch(model, loss_fn, train_loader, device, prepare_batch, track_accuracy,
optimizer=optimizer, desc=f"Train {epoch}") optimizer=optimizer, desc=f"Train {epoch}")
val = _run_epoch(model, loss_fn, val_loader, device, prepare_batch, track_accuracy, val = _run_epoch(model, loss_fn, val_loader, device, prepare_batch, track_accuracy,
@@ -78,6 +106,11 @@ def fit(
best = val[monitor] best = val[monitor]
if save: if save:
save_checkpoint(model, save_dir, model_name) save_checkpoint(model, save_dir, model_name)
if run:
run.save_weights(model)
if run:
run.log_epoch(epoch, train, val, lr=optimizer.param_groups[0]["lr"],
seconds=time.perf_counter() - start, improved=improved)
# Loss is the summed batch loss per sample, shown x1000. # Loss is the summed batch loss per sample, shown x1000.
parts = [f"epoch: {epoch}"] parts = [f"epoch: {epoch}"]
+6 -6
View File
@@ -14,20 +14,20 @@ class SkipBlock(nn.Module):
super().__init__() super().__init__()
self.conv = nn.Sequential( self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size // 2), nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out), nn.BatchNorm2d(c_out),
nn.LeakyReLU(inplace=True), nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2), nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out), nn.BatchNorm2d(c_out),
nn.LeakyReLU(inplace=True), nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2), nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out), nn.BatchNorm2d(c_out),
nn.LeakyReLU(inplace=True) nn.LeakyReLU(inplace=True)
) )
self.conv_skip = nn.Sequential( self.conv_skip = nn.Sequential(
nn.Conv2d(c_in, c_out, 1), nn.Conv2d(c_in, c_out, 1),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out), nn.BatchNorm2d(c_out),
nn.LeakyReLU(inplace=True) nn.LeakyReLU(inplace=True)
) )
@@ -54,7 +54,7 @@ class Yolo_model(nn.Module):
self.model = nn.Sequential( self.model = nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1), nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden // 8, num_channels=c_hidden), nn.BatchNorm2d(c_hidden),
nn.LeakyReLU(inplace=True), nn.LeakyReLU(inplace=True),
SkipBlock(c_in=c_hidden, c_out=c_hidden), SkipBlock(c_in=c_hidden, c_out=c_hidden),
@@ -73,7 +73,7 @@ class Yolo_model(nn.Module):
SkipBlock(c_in=c_hidden * 4, c_out=c_hidden * 4), SkipBlock(c_in=c_hidden * 4, c_out=c_hidden * 4),
nn.Conv2d(c_hidden * 4, c_hidden * 8, kernel_size=3, padding=1), nn.Conv2d(c_hidden * 4, c_hidden * 8, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden // 2, num_channels=c_hidden * 8), nn.BatchNorm2d(c_hidden * 8),
nn.LeakyReLU(inplace=True), nn.LeakyReLU(inplace=True),
nn.Dropout(0.3), nn.Dropout(0.3),
+4
View File
@@ -40,6 +40,10 @@ class Config:
football_model_name: str = "object_detection" football_model_name: str = "object_detection"
save: bool = True save: bool = True
# Run log (src/common/run_log.py): one folder per training run
runs_dir: str = "runs" # empty = no run log
run_name: str = "" # added to the run folder name, e.g. "bn-lr3e-4"
# detect tasks: proposals smaller than min_size or larger than max_size are ignored # detect tasks: proposals smaller than min_size or larger than max_size are ignored
image: str = "" # detect-faces: image to run on image: str = "" # detect-faces: image to run on
min_size: int = 5 min_size: int = 5
+2 -2
View File
@@ -71,7 +71,7 @@ def train_faces(cfg: Config):
fit(model, nn.CrossEntropyLoss(weight=weights), optimizer, train_loader, val_loader, device, fit(model, nn.CrossEntropyLoss(weight=weights), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.faces_model_name, save=cfg.save, epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.faces_model_name, save=cfg.save,
monitor="loss", track_accuracy=True) monitor="loss", track_accuracy=True, config=cfg)
def detect_faces(cfg: Config): def detect_faces(cfg: Config):
@@ -103,7 +103,7 @@ def train_football(cfg: Config):
fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device, fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.football_model_name, save=cfg.save, epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.football_model_name, save=cfg.save,
monitor="loss", track_accuracy=True, prepare_batch=prepare_batch) monitor="loss", track_accuracy=True, prepare_batch=prepare_batch, config=cfg)
def detect_football(cfg: Config): def detect_football(cfg: Config):
+4
View File
@@ -35,6 +35,10 @@ class Config:
model_name: str = "face_detection_yolo" model_name: str = "face_detection_yolo"
save: bool = True save: bool = True
# Run log (src/common/run_log.py): one folder per training run
runs_dir: str = "runs" # empty = no run log
run_name: str = "" # added to the run folder name, e.g. "bn-lr3e-4"
# sample / webcam tasks # sample / webcam tasks
checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name> checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name>
threshold: float = 0.95 # confidence needed to draw a box threshold: float = 0.95 # confidence needed to draw a box
+1 -1
View File
@@ -62,7 +62,7 @@ def train(cfg: Config):
fit(model, YoloLoss(), optimizer, train_loader, val_loader, device, fit(model, YoloLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save, epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save,
monitor="loss") monitor="loss", config=cfg)
def view_data(cfg: Config): def view_data(cfg: Config):