wormocr 1.0.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 (48) hide show
  1. wormocr/__init__.py +18 -0
  2. wormocr/__main__.py +4 -0
  3. wormocr/api.py +229 -0
  4. wormocr/benchmark/__init__.py +5 -0
  5. wormocr/benchmark/report.py +403 -0
  6. wormocr/checkpoints.py +55 -0
  7. wormocr/cli.py +308 -0
  8. wormocr/config.py +220 -0
  9. wormocr/data/__init__.py +5 -0
  10. wormocr/data/dataset.py +110 -0
  11. wormocr/data/fonts.py +173 -0
  12. wormocr/data/handwriting.py +304 -0
  13. wormocr/data/lexicon.py +105 -0
  14. wormocr/data/lexicon_medical.py +48 -0
  15. wormocr/data/mixed.py +211 -0
  16. wormocr/data/synthetic.py +445 -0
  17. wormocr/detection/__init__.py +5 -0
  18. wormocr/detection/lines.py +382 -0
  19. wormocr/evaluation/__init__.py +5 -0
  20. wormocr/evaluation/errors.py +145 -0
  21. wormocr/evaluation/metrics.py +105 -0
  22. wormocr/evaluation/robustness.py +43 -0
  23. wormocr/exceptions.py +25 -0
  24. wormocr/export/__init__.py +5 -0
  25. wormocr/export/onnx_export.py +75 -0
  26. wormocr/hub.py +157 -0
  27. wormocr/inference/__init__.py +5 -0
  28. wormocr/inference/decode.py +113 -0
  29. wormocr/inference/engine.py +122 -0
  30. wormocr/models/__init__.py +5 -0
  31. wormocr/models/crnn.py +236 -0
  32. wormocr/preprocessing.py +135 -0
  33. wormocr/py.typed +0 -0
  34. wormocr/runtime.py +56 -0
  35. wormocr/training/__init__.py +5 -0
  36. wormocr/training/loop.py +645 -0
  37. wormocr/vocabulary.py +69 -0
  38. wormocr/web/__init__.py +5 -0
  39. wormocr/web/server.py +505 -0
  40. wormocr/web/static/app.js +514 -0
  41. wormocr/web/static/index.html +141 -0
  42. wormocr/web/static/style.css +379 -0
  43. wormocr-1.0.0.dist-info/METADATA +377 -0
  44. wormocr-1.0.0.dist-info/RECORD +48 -0
  45. wormocr-1.0.0.dist-info/WHEEL +5 -0
  46. wormocr-1.0.0.dist-info/entry_points.txt +2 -0
  47. wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. wormocr-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,645 @@
1
+ """CPU/GPU training loop for the CTC recognizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import logging
8
+ import math
9
+ import random
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import torch
17
+ from torch import nn
18
+ from torch.utils.data import DataLoader
19
+
20
+ from wormocr.checkpoints import load_checkpoint, public_config, save_checkpoint
21
+ from wormocr.config import dump_config, repo_root
22
+ from wormocr.data.dataset import LabelDataset, OnlineSynthDataset, collate_lines
23
+ from wormocr.evaluation.metrics import MetricTotals
24
+ from wormocr.exceptions import ConfigError, DataError, WormOCRError
25
+ from wormocr.inference.decode import greedy_decode
26
+ from wormocr.models.crnn import build_model, count_parameters
27
+ from wormocr.runtime import resolve_device
28
+ from wormocr.vocabulary import Vocabulary, ctc_time_needed
29
+
30
+ logger = logging.getLogger("wormocr.train")
31
+
32
+
33
+ @dataclass
34
+ class TrainResult:
35
+ last_checkpoint: str
36
+ best_checkpoint: str
37
+ steps: int
38
+ best_cer: float | None
39
+ maturity: str
40
+ final_loss: float | None
41
+
42
+
43
+ def set_seed(seed: int) -> None:
44
+ random.seed(seed)
45
+ np.random.seed(seed)
46
+ torch.manual_seed(seed)
47
+ if torch.cuda.is_available():
48
+ torch.cuda.manual_seed_all(seed)
49
+
50
+
51
+ def schedule_span(start_step: int, epochs: int, batches_per_epoch: int, max_steps: int | None) -> int:
52
+ """Steps this invocation should spread warmup and cosine across.
53
+
54
+ A resumed run schedules from the steps it will still take. Using the global
55
+ step against a short horizon drops the learning rate before characters align.
56
+ """
57
+ local = max(1, int(epochs) * max(1, int(batches_per_epoch)))
58
+ if max_steps is not None:
59
+ remaining = int(max_steps) - int(start_step)
60
+ if remaining > 0:
61
+ local = max(1, min(local, remaining))
62
+ return local
63
+
64
+
65
+ def learning_rate(step: int, total_steps: int, warmup: int, base_lr: float, min_ratio: float) -> float:
66
+ if total_steps <= 0:
67
+ return base_lr
68
+ if step < warmup:
69
+ return base_lr * float(step + 1) / float(max(1, warmup))
70
+ progress = (step - warmup) / float(max(1, total_steps - warmup))
71
+ progress = min(1.0, max(0.0, progress))
72
+ cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
73
+ return base_lr * (min_ratio + (1.0 - min_ratio) * cosine)
74
+
75
+
76
+ def maturity_for(mode: str, steps: int, previous: str | None = None) -> str:
77
+ if mode == "smoke":
78
+ return "smoke-tested"
79
+ if mode == "debug":
80
+ return "debug-trained"
81
+ if mode in {"baseline", "resume"}:
82
+ return "trained" if steps >= 500 else "debug-trained"
83
+ if steps >= 20:
84
+ return "debug-trained"
85
+ return previous or "smoke-tested"
86
+
87
+
88
+ def build_vocabulary(config: dict) -> Vocabulary:
89
+ return Vocabulary(config.get("vocab", {}).get("charset"))
90
+
91
+
92
+ def build_datasets(config: dict) -> tuple[torch.utils.data.Dataset, torch.utils.data.Dataset | None, Vocabulary]:
93
+ vocabulary = build_vocabulary(config)
94
+ data = config["data"]
95
+ if data.get("sources"):
96
+ from wormocr.data.mixed import build_mixed
97
+
98
+ train, val = build_mixed(config, repo_root())
99
+ return train, val, vocabulary
100
+ val: torch.utils.data.Dataset | None = None
101
+ if data.get("val_labels"):
102
+ val_root = data.get("val_image_root") or str(Path(data["val_labels"]).parent)
103
+ val = LabelDataset(data["val_labels"], val_root, config, vocabulary)
104
+ if data.get("labels"):
105
+ root = data.get("image_root") or str(Path(data["labels"]).parent)
106
+ train: torch.utils.data.Dataset = LabelDataset(data["labels"], root, config, vocabulary)
107
+ return train, val, vocabulary
108
+ train = OnlineSynthDataset(
109
+ config,
110
+ int(data["train_samples"]),
111
+ int(config["seed"]),
112
+ bool(data.get("augment", True)),
113
+ )
114
+ if val is not None:
115
+ return train, val, vocabulary
116
+ val_samples = int(data.get("val_samples") or 0)
117
+ if val_samples <= 0:
118
+ return train, None, vocabulary
119
+ val_seed = data.get("val_seed")
120
+ if val_seed is None:
121
+ val_seed = int(config["seed"]) + 1_000_003
122
+ val = OnlineSynthDataset(config, val_samples, int(val_seed), augment=False)
123
+ return train, val, vocabulary
124
+
125
+
126
+ def make_loader(dataset, batch_size: int, shuffle: bool, seed: int, num_workers: int) -> DataLoader:
127
+ generator = torch.Generator()
128
+ generator.manual_seed(int(seed))
129
+ workers = int(num_workers)
130
+ extra: dict = {}
131
+ if workers > 0:
132
+ if sys.platform == "win32":
133
+ # Named shared-memory events vanish when a worker exits, and the
134
+ # parent then dies with "Couldn't open shared event" (WinError 2).
135
+ torch.multiprocessing.set_sharing_strategy("file_system")
136
+ extra = {"persistent_workers": True, "prefetch_factor": 4}
137
+ return DataLoader(
138
+ dataset,
139
+ batch_size=int(batch_size),
140
+ shuffle=shuffle,
141
+ num_workers=workers,
142
+ collate_fn=collate_lines,
143
+ generator=generator,
144
+ **extra,
145
+ )
146
+
147
+
148
+ def compute_loss(model, images, texts, widths, vocabulary: Vocabulary):
149
+ log_probs = model(images)
150
+ time_steps = int(log_probs.shape[1])
151
+ input_lengths = model.output_lengths(widths).clamp(min=1, max=time_steps)
152
+ encoded = [vocabulary.encode(text) for text in texts]
153
+ target_lengths_list = [len(item) for item in encoded]
154
+ for text, target, available in zip(texts, target_lengths_list, input_lengths.tolist()):
155
+ needed = ctc_time_needed(text)
156
+ if target == 0:
157
+ raise DataError("refusing an empty training label")
158
+ if needed > available:
159
+ raise DataError(
160
+ f"label {text!r} needs {needed} CTC time steps but the line only has {available}. "
161
+ "Raise data.max_width or shorten data.max_chars."
162
+ )
163
+ targets = torch.tensor([index for item in encoded for index in item], dtype=torch.long)
164
+ target_lengths = torch.tensor(target_lengths_list, dtype=torch.long)
165
+ loss = nn.functional.ctc_loss(
166
+ log_probs.permute(1, 0, 2).float(),
167
+ targets,
168
+ input_lengths.cpu(),
169
+ target_lengths.cpu(),
170
+ blank=vocabulary.blank,
171
+ zero_infinity=True,
172
+ reduction="mean",
173
+ )
174
+ return loss, log_probs, input_lengths
175
+
176
+
177
+ @torch.no_grad()
178
+ def evaluate_model(
179
+ model,
180
+ loader: DataLoader,
181
+ vocabulary: Vocabulary,
182
+ device: torch.device,
183
+ collect: bool = False,
184
+ ) -> tuple[MetricTotals, list[tuple[str, str]]]:
185
+ was_training = model.training
186
+ model.eval()
187
+ totals = MetricTotals()
188
+ previews: list[tuple[str, str]] = []
189
+ for batch in loader:
190
+ images = batch["images"].to(device)
191
+ log_probs = model(images)
192
+ lengths = model.output_lengths(batch["widths"]).clamp(min=1, max=int(log_probs.shape[1]))
193
+ feasible = [
194
+ index
195
+ for index, (text, available) in enumerate(zip(batch["texts"], lengths.tolist()))
196
+ if text and ctc_time_needed(text) <= available
197
+ ]
198
+ if feasible:
199
+ texts = [batch["texts"][index] for index in feasible]
200
+ targets = torch.tensor([value for text in texts for value in vocabulary.encode(text)], dtype=torch.long)
201
+ loss = nn.functional.ctc_loss(
202
+ log_probs[feasible].permute(1, 0, 2).float(),
203
+ targets,
204
+ lengths[feasible].cpu(),
205
+ torch.tensor([len(text) for text in texts], dtype=torch.long),
206
+ blank=vocabulary.blank,
207
+ zero_infinity=True,
208
+ reduction="mean",
209
+ )
210
+ totals.update_loss(float(loss.detach().cpu()))
211
+ for text, indices in zip(batch["texts"], greedy_decode(log_probs, lengths, vocabulary.blank)):
212
+ hypothesis = vocabulary.decode(indices)
213
+ totals.update(text, hypothesis)
214
+ if collect or len(previews) < 8:
215
+ previews.append((text, hypothesis))
216
+ if was_training:
217
+ model.train()
218
+ return totals, previews
219
+
220
+
221
+ class _MemoryDataset(torch.utils.data.Dataset):
222
+ def __init__(self, items: list[dict]) -> None:
223
+ self.items = items
224
+
225
+ def __len__(self) -> int:
226
+ return len(self.items)
227
+
228
+ def __getitem__(self, index: int) -> dict:
229
+ return self.items[int(index)]
230
+
231
+
232
+ def _optimizer(model: nn.Module, lr: float, weight_decay: float) -> torch.optim.Optimizer:
233
+ decay: list[nn.Parameter] = []
234
+ quiet: list[nn.Parameter] = []
235
+ for name, parameter in model.named_parameters():
236
+ if not parameter.requires_grad:
237
+ continue
238
+ if parameter.ndim == 1 or name.endswith(".bias"):
239
+ quiet.append(parameter)
240
+ else:
241
+ decay.append(parameter)
242
+ return torch.optim.AdamW(
243
+ [
244
+ {"params": decay, "weight_decay": weight_decay},
245
+ {"params": quiet, "weight_decay": 0.0},
246
+ ],
247
+ lr=lr,
248
+ )
249
+
250
+
251
+ def _batchnorm_eval_if_tiny(model: nn.Module, batch_size: int) -> list[nn.BatchNorm2d]:
252
+ if batch_size >= 2:
253
+ return []
254
+ paused: list[nn.BatchNorm2d] = []
255
+ for module in model.modules():
256
+ if isinstance(module, nn.BatchNorm2d) and module.training:
257
+ paused.append(module)
258
+ module.eval()
259
+ return paused
260
+
261
+
262
+ def _make_ema(model: nn.Module) -> nn.Module:
263
+ ema = copy.deepcopy(model)
264
+ ema.eval()
265
+ for parameter in ema.parameters():
266
+ parameter.requires_grad_(False)
267
+ return ema
268
+
269
+
270
+ @torch.no_grad()
271
+ def _update_ema(ema: nn.Module, model: nn.Module, decay: float) -> None:
272
+ for averaged, current in zip(ema.parameters(), model.parameters()):
273
+ averaged.mul_(decay).add_(current.detach(), alpha=1.0 - decay)
274
+ for averaged, current in zip(ema.buffers(), model.buffers()):
275
+ averaged.copy_(current)
276
+
277
+
278
+ def _checkpoint_payload(
279
+ export_model,
280
+ train_model,
281
+ optimizer,
282
+ config,
283
+ vocabulary: Vocabulary,
284
+ epoch: int,
285
+ step: int,
286
+ maturity: str,
287
+ best_cer: float | None,
288
+ history: list[dict],
289
+ total_steps: int,
290
+ ) -> dict:
291
+ payload = {
292
+ "model_state": export_model.state_dict(),
293
+ "optimizer_state": optimizer.state_dict(),
294
+ "config": public_config(config),
295
+ "charset": vocabulary.charset,
296
+ "epoch": epoch,
297
+ "step": step,
298
+ "maturity": maturity,
299
+ "best_cer": best_cer,
300
+ "history": history,
301
+ "total_steps": total_steps,
302
+ }
303
+ if train_model is not export_model:
304
+ payload["train_state"] = train_model.state_dict()
305
+ return payload
306
+
307
+
308
+ def initialize_weights(model: nn.Module, source: str, vocabulary: Vocabulary, device: torch.device) -> None:
309
+ """Start from another model's weights with a fresh optimizer and step count.
310
+
311
+ `source` is a checkpoint path or a released model name. Tensors whose shape
312
+ differs (for example the projection after an image_height change) keep
313
+ their fresh initialization.
314
+ """
315
+ from wormocr.hub import RELEASES, local_weights
316
+
317
+ path = local_weights(source) if source in RELEASES else Path(source)
318
+ if path is None:
319
+ raise WormOCRError(f"{source} is not downloaded. Run: wormocr download {source}")
320
+ payload = load_checkpoint(path, map_location=device)
321
+ if payload["charset"] != vocabulary.charset:
322
+ raise WormOCRError(f"{source} uses a different charset than this config")
323
+ own = model.state_dict()
324
+ loaded = {
325
+ name: tensor
326
+ for name, tensor in payload["model_state"].items()
327
+ if name in own and own[name].shape == tensor.shape
328
+ }
329
+ model.load_state_dict(loaded, strict=False)
330
+ missing = [name for name in own if name not in loaded]
331
+ logger.info(
332
+ "initialized from %s: %s tensors loaded, %s kept fresh %s",
333
+ source, len(loaded), len(missing), missing[:6] if missing else "",
334
+ )
335
+
336
+
337
+ def train(config: dict, mode: str = "baseline", resume_from: str | None = None) -> TrainResult:
338
+ _ensure_logging()
339
+ if mode == "resume" and not resume_from:
340
+ raise ConfigError("resume mode needs --checkpoint pointing at last.pt or best.pt")
341
+ set_seed(int(config["seed"]))
342
+ device = resolve_device(config.get("device"))
343
+ train_cfg = config["train"]
344
+ if train_cfg.get("torch_threads"):
345
+ torch.set_num_threads(int(train_cfg["torch_threads"]))
346
+ dataset, val_dataset, vocabulary = build_datasets(config)
347
+ model = build_model(config, vocabulary.size).to(device)
348
+ optimizer = _optimizer(model, float(train_cfg["lr"]), float(train_cfg["weight_decay"]))
349
+ ema_decay = float(train_cfg.get("ema_decay") or 0.0)
350
+ ema = _make_ema(model) if ema_decay > 0 else None
351
+ start_epoch = 0
352
+ step = 0
353
+ best_cer: float | None = None
354
+ history: list[dict] = []
355
+ previous_maturity = None
356
+ if resume_from:
357
+ payload = load_checkpoint(resume_from, map_location=device)
358
+ if payload["charset"] != vocabulary.charset:
359
+ raise WormOCRError("resume checkpoint charset does not match this config")
360
+ model.load_state_dict(payload.get("train_state") or payload["model_state"])
361
+ if ema is not None:
362
+ ema.load_state_dict(payload["model_state"])
363
+ if payload.get("optimizer_state"):
364
+ optimizer.load_state_dict(payload["optimizer_state"])
365
+ start_epoch = int(payload.get("epoch", 0))
366
+ step = int(payload.get("step", 0))
367
+ best_cer = payload.get("best_cer")
368
+ history = list(payload.get("history") or [])
369
+ previous_maturity = payload.get("maturity")
370
+ logger.info("resumed from %s at step %s", resume_from, step)
371
+ elif train_cfg.get("init_from"):
372
+ initialize_weights(model, str(train_cfg["init_from"]), vocabulary, device)
373
+ if ema is not None:
374
+ ema.load_state_dict(model.state_dict())
375
+ ema.rnn.flatten_parameters()
376
+ export_model = ema if ema is not None else model
377
+
378
+ workers = int(train_cfg.get("num_workers") or 0)
379
+ batch_size = int(train_cfg["batch_size"])
380
+ loader = make_loader(dataset, batch_size, True, int(config["seed"]), workers)
381
+ val_loader = None
382
+ if val_dataset is not None and len(val_dataset) > 0:
383
+ cached = _MemoryDataset([val_dataset[index] for index in range(len(val_dataset))])
384
+ val_loader = make_loader(cached, batch_size, False, int(config["seed"]) + 17, 0)
385
+ epochs = int(train_cfg["epochs"])
386
+ start_step = step
387
+ max_steps = int(train_cfg["max_steps"]) if train_cfg.get("max_steps") else None
388
+ accum = max(1, int(train_cfg.get("grad_accum_steps") or 1))
389
+ # An optimizer step is `accum` loader batches. The schedule and max_steps
390
+ # count those steps, so batch 16 with accum 4 matches a batch-64 update.
391
+ local_steps = schedule_span(start_step, epochs, max(1, len(loader) // accum), max_steps)
392
+ planned = start_step + local_steps
393
+ eval_every = int(train_cfg.get("eval_every") or 0)
394
+ patience = int(train_cfg.get("early_stopping_patience") or 0)
395
+ max_minutes = train_cfg.get("max_minutes")
396
+ log_every = int(train_cfg.get("log_every") or 20)
397
+ checkpoint_dir = Path(train_cfg["checkpoint_dir"])
398
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
399
+ (checkpoint_dir / "config.yaml").write_text(dump_config(config), encoding="utf-8")
400
+ logger.info(
401
+ "training %s on %s | parameters=%s | samples=%s | planned_steps=%s | batch=%s | accum=%s | workers=%s | ema=%s | mode=%s",
402
+ config["model"].get("name", "recognizer"),
403
+ device,
404
+ count_parameters(model),
405
+ len(dataset),
406
+ local_steps,
407
+ batch_size,
408
+ accum,
409
+ workers,
410
+ ema_decay or "off",
411
+ mode,
412
+ )
413
+ log_path = checkpoint_dir / "train_log.jsonl"
414
+ log_file = log_path.open("a" if resume_from else "w", encoding="utf-8")
415
+ use_amp = bool(train_cfg.get("amp")) and device.type == "cuda"
416
+ if train_cfg.get("amp") and not use_amp:
417
+ logger.info("mixed precision was requested but CUDA is unavailable; training in float32")
418
+ scaler = torch.amp.GradScaler("cuda", enabled=use_amp) if use_amp else None
419
+ final_loss: float | None = None
420
+ stale = 0
421
+ last_validated_step = -1
422
+ started = time.perf_counter()
423
+ model.train()
424
+ completed_epoch = start_epoch
425
+
426
+ def validate(epoch: int) -> None:
427
+ nonlocal best_cer, stale, last_validated_step
428
+ last_validated_step = step
429
+ before = best_cer
430
+ best_cer = _validate_and_save(
431
+ export_model, model, optimizer, val_loader, vocabulary, device, config, epoch, step,
432
+ best_cer, history, planned, previous_maturity, mode, checkpoint_dir,
433
+ )
434
+ if val_loader is None:
435
+ return
436
+ if before is None or (best_cer is not None and best_cer < before - 1e-6):
437
+ stale = 0
438
+ else:
439
+ stale += 1
440
+
441
+ def patience_spent() -> bool:
442
+ if patience > 0 and stale >= patience:
443
+ logger.info("early stopping after %s validations without CER improvement", stale)
444
+ return True
445
+ return False
446
+
447
+ try:
448
+ stopped = False
449
+ pending = 0
450
+ optimizer.zero_grad(set_to_none=True)
451
+ for epoch in range(start_epoch, start_epoch + epochs):
452
+ for batch in loader:
453
+ if max_steps is not None and step >= max_steps:
454
+ stopped = True
455
+ break
456
+ if max_minutes is not None and (time.perf_counter() - started) / 60.0 >= float(max_minutes):
457
+ logger.info("stopping because train.max_minutes=%s was reached", max_minutes)
458
+ stopped = True
459
+ break
460
+ lr = learning_rate(
461
+ step - start_step,
462
+ local_steps,
463
+ int(train_cfg.get("warmup_steps") or 0),
464
+ float(train_cfg["lr"]),
465
+ float(train_cfg.get("min_lr_ratio") or 0.1),
466
+ )
467
+ for group in optimizer.param_groups:
468
+ group["lr"] = lr
469
+ images = batch["images"].to(device)
470
+ paused = _batchnorm_eval_if_tiny(model, images.shape[0])
471
+ try:
472
+ if use_amp and scaler is not None:
473
+ with torch.autocast(device_type="cuda", dtype=torch.float16):
474
+ loss, _log_probs, _lengths = compute_loss(
475
+ model, images, batch["texts"], batch["widths"], vocabulary
476
+ )
477
+ scaler.scale(loss / accum).backward()
478
+ else:
479
+ loss, _log_probs, _lengths = compute_loss(
480
+ model, images, batch["texts"], batch["widths"], vocabulary
481
+ )
482
+ (loss / accum).backward()
483
+ except RuntimeError as exc:
484
+ if "out of memory" in str(exc).lower():
485
+ if device.type == "cuda":
486
+ torch.cuda.empty_cache()
487
+ raise MemoryError(
488
+ "wormOCR ran out of memory. Lower train.batch_size or data.max_chars."
489
+ ) from exc
490
+ raise
491
+ finally:
492
+ for module in paused:
493
+ module.train()
494
+ if not torch.isfinite(loss):
495
+ raise WormOCRError("loss became non-finite. Lower train.lr and try again.")
496
+ pending += 1
497
+ if pending < accum:
498
+ continue
499
+ pending = 0
500
+ if use_amp and scaler is not None:
501
+ scaler.unscale_(optimizer)
502
+ nn.utils.clip_grad_norm_(model.parameters(), float(train_cfg["grad_clip"]))
503
+ scaler.step(optimizer)
504
+ scaler.update()
505
+ else:
506
+ nn.utils.clip_grad_norm_(model.parameters(), float(train_cfg["grad_clip"]))
507
+ optimizer.step()
508
+ optimizer.zero_grad(set_to_none=True)
509
+ if ema is not None:
510
+ _update_ema(ema, model, min(ema_decay, (1.0 + step) / (10.0 + step)))
511
+ final_loss = float(loss.detach().cpu())
512
+ if step % log_every == 0:
513
+ record = {"step": step, "epoch": epoch, "loss": final_loss, "lr": lr}
514
+ log_file.write(json.dumps(record) + "\n")
515
+ log_file.flush()
516
+ elapsed = time.perf_counter() - started
517
+ logger.info(
518
+ "step %s epoch %s loss %.4f lr %.6f elapsed %.0fs",
519
+ step, epoch, final_loss, lr, elapsed,
520
+ )
521
+ step += 1
522
+ if eval_every > 0 and step % eval_every == 0:
523
+ validate(epoch)
524
+ if patience_spent():
525
+ stopped = True
526
+ break
527
+ if stopped:
528
+ completed_epoch = epoch
529
+ if last_validated_step != step:
530
+ validate(epoch)
531
+ break
532
+ completed_epoch = epoch + 1
533
+ if last_validated_step != step:
534
+ validate(epoch + 1)
535
+ save_every = int(train_cfg.get("save_every") or 0)
536
+ if save_every > 0 and (epoch + 1) % save_every == 0:
537
+ save_checkpoint(
538
+ checkpoint_dir / f"epoch_{epoch + 1:03d}.pt",
539
+ _checkpoint_payload(
540
+ export_model, model, optimizer, config, vocabulary, epoch + 1, step,
541
+ maturity_for(mode, step, previous_maturity), best_cer, history, planned,
542
+ ),
543
+ )
544
+ if patience_spent():
545
+ break
546
+ finally:
547
+ log_file.close()
548
+ maturity = maturity_for(mode, step, previous_maturity)
549
+ last_path = checkpoint_dir / "last.pt"
550
+ save_checkpoint(
551
+ last_path,
552
+ _checkpoint_payload(
553
+ export_model, model, optimizer, config, vocabulary, completed_epoch, step,
554
+ maturity, best_cer, history, planned,
555
+ ),
556
+ )
557
+ best_path = checkpoint_dir / "best.pt"
558
+ if not best_path.exists():
559
+ save_checkpoint(
560
+ best_path,
561
+ _checkpoint_payload(
562
+ export_model, model, optimizer, config, vocabulary, completed_epoch, step,
563
+ maturity, best_cer, history, planned,
564
+ ),
565
+ )
566
+ _write_metrics(checkpoint_dir, best_cer, step, maturity, final_loss)
567
+ logger.info("saved %s (%s)", best_path, maturity)
568
+ return TrainResult(str(last_path), str(best_path), step, best_cer, maturity, final_loss)
569
+
570
+
571
+ def _validate_and_save(
572
+ export_model,
573
+ train_model,
574
+ optimizer,
575
+ val_loader,
576
+ vocabulary,
577
+ device,
578
+ config,
579
+ epoch,
580
+ step,
581
+ best_cer,
582
+ history,
583
+ planned,
584
+ previous_maturity,
585
+ mode,
586
+ checkpoint_dir: Path,
587
+ ) -> float | None:
588
+ maturity = maturity_for(mode, step, previous_maturity)
589
+ if val_loader is None:
590
+ save_checkpoint(
591
+ checkpoint_dir / "last.pt",
592
+ _checkpoint_payload(
593
+ export_model, train_model, optimizer, config, vocabulary, epoch, step,
594
+ maturity, best_cer, history, planned,
595
+ ),
596
+ )
597
+ return best_cer
598
+ totals, previews = evaluate_model(export_model, val_loader, vocabulary, device)
599
+ metrics = totals.to_dict()
600
+ metrics["step"] = step
601
+ metrics["epoch"] = epoch
602
+ history.append(metrics)
603
+ logger.info(
604
+ "val step %s CER %.4f WER %.4f exact %.3f loss %s",
605
+ step,
606
+ totals.corpus_cer,
607
+ totals.corpus_wer,
608
+ totals.exact_match,
609
+ f"{totals.mean_loss:.4f}" if totals.mean_loss is not None else "n/a",
610
+ )
611
+ for reference, hypothesis in previews[:5]:
612
+ logger.info(" ref=%r hyp=%r", reference, hypothesis)
613
+ improved = best_cer is None or totals.corpus_cer < best_cer
614
+ current_best = totals.corpus_cer if improved else best_cer
615
+ payload = _checkpoint_payload(
616
+ export_model, train_model, optimizer, config, vocabulary, epoch, step,
617
+ maturity, current_best, history, planned,
618
+ )
619
+ save_checkpoint(checkpoint_dir / "last.pt", payload)
620
+ if improved:
621
+ save_checkpoint(checkpoint_dir / "best.pt", payload)
622
+ weights = dict(payload)
623
+ weights.pop("optimizer_state", None)
624
+ weights.pop("train_state", None)
625
+ save_checkpoint(checkpoint_dir / "best_weights.pt", weights)
626
+ logger.info("new best CER %.4f", totals.corpus_cer)
627
+ return current_best
628
+
629
+
630
+ def _write_metrics(checkpoint_dir: Path, best_cer: float | None, step: int, maturity: str, final_loss: float | None) -> None:
631
+ summary = {
632
+ "best_cer": best_cer,
633
+ "step": step,
634
+ "maturity": maturity,
635
+ "final_loss": final_loss,
636
+ }
637
+ (checkpoint_dir / "metrics.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
638
+
639
+
640
+ def _ensure_logging() -> None:
641
+ if not logging.getLogger().handlers:
642
+ logging.basicConfig(
643
+ level=logging.INFO,
644
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
645
+ )