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.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wormocr-1.0.0.dist-info/top_level.txt +1 -0
wormocr/training/loop.py
ADDED
|
@@ -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
|
+
)
|