quran-asr-tiny / code /train.py
Abdou's picture
quran-asr-tiny: 16M Quran recitation ASR (step 245k)
38c1604 verified
Raw History Blame Contribute Delete
28.4 kB
"""
Step 3: train the model.
python train.py --config config/pilot.yaml
Progress goes to the console and to runs/<name>/: log.csv (metrics over time), status.json (live
state) and samples.json (what the model hears on a few validation ayahs at each evaluation).
`python dashboard.py` shows them as live charts. The best checkpoint (lowest letter error rate on
the validation clips) is runs/<name>/best.pt.
Train in as many sessions as you like: running the same command again continues exactly where
it stopped (same batch, optimizer, learning rate schedule and data order). To pause:
- Ctrl+C, the dashboard's Pause button, or a file named "stop" in runs/<name>/: saves and exits
within seconds;
- --stop-after 2 (hours) or --stop-at 07:30: pauses by itself, e.g. before you need the laptop;
- closing the window or a power cut: loses at most `checkpoint_minutes` (10) of training.
"""
import csv
import hashlib
import json
import math
import os
import shutil
import signal
import sys
import time
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from quran_asr import config as config_mod
from quran_asr import data, model as model_mod, text
LOG_COLUMNS = ["step", "epoch", "loss", "lr", "valid_cer", "valid_wer", "hours_seen", "elapsed_min",
"grad_norm", "gpu_peak_gb", "audio_h_per_h", "valid_skeleton", "valid_hafs_cer", "valid_warsh_cer"]
class EpochBatches:
"""
Wraps the duration sampler so every index carries the epoch (fresh augmentation per epoch) and
whether to augment: the first `augment_after` steps use clean audio, so the model first learns
which sounds are which letters. Augmenting from the very first step kept it from ever learning.
"""
def __init__(self, sampler, augment_after=0):
self.sampler = sampler
self.augment_after = augment_after
self.skip = 0
def __iter__(self):
epoch = self.sampler.epoch
for k, batch in enumerate(self.sampler):
if k >= self.skip:
on = epoch * len(self.sampler) + k >= self.augment_after
yield [(i, epoch, on) for i in batch]
self.skip = 0
def __len__(self):
return len(self.sampler) - self.skip
class EpochDataset(data.RecitationDataset):
def __getitem__(self, key):
i, epoch, on = key if isinstance(key, tuple) else (key, 0, True)
self.epoch, self.augment_on = epoch, on
return super().__getitem__(i)
def worker_init(_):
torch.set_num_threads(1)
# Ctrl+C (and Ctrl+Break) reach every process in the console window. Only the main process
# should react: it saves a checkpoint, then shuts the workers down itself. A worker dying first
# would turn the clean stop into a "worker exited unexpectedly" crash without a checkpoint.
signal.signal(signal.SIGINT, signal.SIG_IGN)
if hasattr(signal, "SIGBREAK"):
signal.signal(signal.SIGBREAK, signal.SIG_IGN)
class Pause(Exception):
"""Stop training now, keeping a checkpoint to resume from."""
def _interrupt(*_):
raise KeyboardInterrupt
def deadline_from(args):
"""--stop-after hours / --stop-at HH:MM as a time.time() value, or None."""
deadline = time.time() + args.stop_after * 3600 if args.stop_after else None
if args.stop_at:
hour, minute = (int(v) for v in args.stop_at.split(":"))
now = time.localtime()
at = time.mktime((now.tm_year, now.tm_mon, now.tm_mday, hour, minute, 0, 0, 0, -1))
if at <= time.time():
at += 24 * 3600 # that time tomorrow
deadline = min(deadline or at, at)
return deadline
def feature_stats(entries, count=400):
"""Global mean/std of the features over a sample of clips (stored inside the model)."""
ds = data.RecitationDataset(entries[:: max(1, len(entries) // count)][:count])
total, total_sq, n = 0, 0, 0
for k in range(len(ds)):
f, _, _ = ds[k]
total = total + f.sum(0)
total_sq = total_sq + (f ** 2).sum(0)
n += f.shape[0]
mean = total / n
std = (total_sq / n - mean ** 2).clamp(min=1e-4).sqrt()
return mean, std
def evaluate(model, loader, device, amp_dtype, sample_ids=()):
"""Error rates on the validation clips (overall and per riwaya) and the text heard on sample_ids."""
model.eval()
totals, heard, sample_ids = {}, {}, set(sample_ids)
with torch.no_grad():
for x, lens, _tokens, _tlens, idx in loader:
with torch.autocast(device.type, dtype=amp_dtype, enabled=amp_dtype is not None):
log_probs, out_lens = model(x.to(device), lens.to(device))
for ids, i in zip(model_mod.greedy_decode(log_probs, out_lens), idx):
entry = loader.dataset.entries[i]
hyp = text.decode(ids)
counts = text.error_counts(hyp, entry["text"])
for key in ("all", entry["riwaya"]):
acc = totals.setdefault(key, [0] * 6)
for j, c in enumerate(counts):
acc[j] += c
if i in sample_ids:
heard[i] = hyp
model.train()
def rate(key, j):
return totals[key][j] / max(1, totals[key][j + 1]) if key in totals else None
scores = {"cer": rate("all", 0), "wer": rate("all", 2), "skeleton": rate("all", 4),
"hafs_cer": rate("hafs", 0), "warsh_cer": rate("warsh", 0)}
return scores, heard
def pick_samples(entries, count=12):
"""A fixed handful of validation clips for samples.json: Warsh first, then one reciter at a time."""
by_reciter = {}
for i, e in enumerate(entries):
if 3 <= e["duration"] <= 20:
by_reciter.setdefault((e["riwaya"] != "warsh", e["reciter"]), []).append(i)
queues = [by_reciter[key] for key in sorted(by_reciter)]
picked = []
while len(picked) < count and any(queues):
for q in queues:
if q and len(picked) < count:
picked.append(q.pop(len(q) // 2))
return picked
def write_json(path, value):
"""Atomic write. Skipped (the next update retries) if a reader holds the file open on Windows."""
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(value, f, ensure_ascii=False)
os.replace(tmp, path)
except OSError:
pass
def lr_at(step, total, cfg):
if step < cfg.warmup_steps:
return cfg.lr * (step + 1) / cfg.warmup_steps
progress = min(1.0, (step - cfg.warmup_steps) / max(1, total - cfg.warmup_steps))
return cfg.lr * (cfg.min_lr_ratio + (1 - cfg.min_lr_ratio) * 0.5 * (1 + math.cos(math.pi * progress)))
def main():
parser = config_mod.from_args(__doc__)
parser.add_argument("--max-steps", type=int, default=0, help="stop early (for quick tests)")
parser.add_argument("--stop-after", type=float, default=0, help="pause after this many hours")
parser.add_argument("--stop-at", default="", help="pause at this time of day, e.g. 07:30")
args = parser.parse_args()
if os.name == "nt":
# Keep Windows from sleeping while training (it counts only keyboard/mouse as activity);
# the screen may still turn off. Ends by itself when train.py exits.
import ctypes
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000 | 0x00000001) # CONTINUOUS | SYSTEM_REQUIRED
if hasattr(signal, "SIGBREAK"):
signal.signal(signal.SIGBREAK, _interrupt) # Ctrl+Break saves too, like Ctrl+C
cfg = config_mod.load(args.config)
t = cfg.train
torch.manual_seed(t.seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if device.type == "cpu":
print("WARNING: no CUDA GPU found, training on the CPU will be very slow")
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
amp_dtype = {"bf16": torch.bfloat16, "fp16": torch.float16}.get(t.precision) if device.type == "cuda" else None
mdir = config_mod.manifest_dir(cfg)
train_entries = data.read_manifest(os.path.join(mdir, "train.jsonl"))
valid_entries = data.read_manifest(os.path.join(mdir, "valid.jsonl"))
print(f"train {len(train_entries)} clips ({data.hours(train_entries):.1f} h), "
f"valid {len(valid_entries)} clips ({data.hours(valid_entries):.1f} h)")
os.makedirs(cfg.paths.run_dir, exist_ok=True)
shutil.copy(cfg.config_path, os.path.join(cfg.paths.run_dir, "config.yaml"))
shutil.copy(os.path.join(mdir, "tokens.txt"), os.path.join(cfg.paths.run_dir, "tokens.txt"))
model = model_mod.build(cfg.model, len(text.VOCAB)).to(device)
params = model_mod.count_parameters(model)
print(f"model: {params / 1e6:.1f}M parameters (~{params / 1e6:.0f} MB as int8 ONNX)")
decay = [p for n, p in model.named_parameters() if p.ndim >= 2]
no_decay = [p for n, p in model.named_parameters() if p.ndim < 2]
opt = torch.optim.AdamW([{"params": decay, "weight_decay": t.weight_decay},
{"params": no_decay, "weight_decay": 0.0}], lr=t.lr, betas=(0.9, 0.98))
scaler = torch.amp.GradScaler(enabled=amp_dtype == torch.float16)
# Ordinary people (Iqra'Eval, tlog) are sampled more or less often so that they make up
# `ordinary_share` of each epoch's audio, the professional reciters the rest.
ordinary = ("iqraeval", "tlog")
source_weights = {}
share = getattr(cfg.data, "ordinary_share", None)
if share is None: # older configs: only Iqra'Eval
share, ordinary = getattr(cfg.data, "iqraeval_share", 0) or 0, ("iqraeval",)
hours = {s: sum(e["duration"] for e in train_entries if e.get("source") == s) / 3600 for s in ordinary}
if share and sum(hours.values()):
rest = sum(e["duration"] * (cfg.data.warsh_weight if e["riwaya"] == "warsh" else 1.0)
for e in train_entries if e.get("source") not in ordinary) / 3600
weight = share * rest / ((1 - share) * sum(hours.values()))
source_weights = {s: weight for s in ordinary}
print(f"ordinary people: {', '.join(f'{s} {h:.0f} h' for s, h in hours.items())}, sampled {weight:.2f}x "
f"per epoch = {share:.0%} of each epoch's audio; professional reciters {rest:.0f} h = {1 - share:.0%}")
sampler = data.DurationBatchSampler(train_entries, t.max_batch_seconds, cfg.data.warsh_weight, True, t.seed,
source_weights)
batches = EpochBatches(sampler, getattr(cfg.augment, "start_after_steps", 0) or 0)
train_ds = EpochDataset(train_entries, cfg.augment, cfg.spec_augment, seed=t.seed)
loader = DataLoader(train_ds, batch_sampler=batches, collate_fn=data.collate, num_workers=t.num_workers,
persistent_workers=t.num_workers > 0, pin_memory=device.type == "cuda",
worker_init_fn=worker_init, prefetch_factor=4 if t.num_workers > 0 else None)
valid_loader = DataLoader(data.RecitationDataset(valid_entries), batch_sampler=data.DurationBatchSampler(
valid_entries, t.max_batch_seconds, 1.0, False), collate_fn=data.collate,
num_workers=min(4, t.num_workers), worker_init_fn=worker_init)
total_steps = t.epochs * len(sampler)
# Identifies the training data, so a resumed run notices new data (e.g. Iqra'Eval added).
data_id = hashlib.md5("\n".join(e["path"] for e in train_entries).encode("utf-8")).hexdigest()
valid_id = hashlib.md5("\n".join(e["path"] for e in valid_entries).encode("utf-8")).hexdigest()
inter_layers = tuple(getattr(t, "inter_ctc_layers", None) or ())
inter_weight = getattr(t, "inter_ctc_weight", 0.3)
# Live state for the dashboard, rewritten at every log line.
status_path = os.path.join(cfg.paths.run_dir, "status.json")
status = dict(name=cfg.name, phase="starting", device=torch.cuda.get_device_name(0) if device.type == "cuda"
else "CPU", params_m=round(params / 1e6, 2), total_steps=total_steps, epochs=t.epochs,
steps_per_epoch=len(sampler), train_clips=len(train_entries), valid_clips=len(valid_entries),
train_hours=round(data.hours(train_entries), 2), valid_hours=round(data.hours(valid_entries), 2),
hours_by_reciter=data.summarize(train_entries), model=vars(cfg.model), train=vars(t),
session_started_at=time.time())
def publish(**changes):
status.update(changes, updated_at=time.time())
write_json(status_path, status)
step, epoch, best_cer, best_step = 0, 0, float("inf"), None
rewarm_from = None
seen_seconds, elapsed_before = 0.0, 0.0
last_path = os.path.join(cfg.paths.run_dir, "last.pt")
if os.path.exists(last_path):
ckpt = torch.load(last_path, map_location=device, weights_only=False)
model.load_state_dict(ckpt["model"])
opt.load_state_dict(ckpt["opt"])
scaler.load_state_dict(ckpt["scaler"])
step, epoch, best_cer = ckpt["step"], ckpt["epoch"], ckpt["best_cer"]
best_step = ckpt.get("best_step")
if ckpt.get("valid_id") != valid_id:
# Scores on a different validation set aren't comparable (e.g. ordinary readers added):
# the best checkpoint is chosen afresh from the next validation on.
best_cer, best_step = float("inf"), None
print("the validation set changed: best.pt will be replaced at the next validation")
seen_seconds, elapsed_before = ckpt.get("seen_seconds", 0.0), ckpt.get("elapsed", 0.0)
batches.skip = ckpt["batch_in_epoch"]
rewarm_from = ckpt.get("rewarm_from")
if "data_id" in ckpt:
changed = ckpt["data_id"] != data_id
else: # a checkpoint from before data_id: the data changed if prepare.py ran after it was saved
changed = os.path.getmtime(os.path.join(mdir, "train.jsonl")) > os.path.getmtime(last_path)
if changed:
# New training data: the batch position in the old epoch means nothing any more, so a
# new epoch starts, and the learning rate warms up again (Adam's statistics and the
# model meet different data).
if batches.skip:
epoch += 1
batches.skip = 0
rewarm_from = step
print(f"the training data changed: continuing from step {step} with the new data "
f"(epoch {epoch + 1}, learning rate warming up again)")
sampler.set_epoch(epoch)
# The remaining epochs at the current data's length (it changes when data or epochs do).
total_steps = step - batches.skip + (t.epochs - epoch) * len(sampler)
planned = ckpt.get("total_steps")
if not changed and planned and abs(total_steps - planned) > 0.05 * planned:
rewarm_from = step # more (or fewer) epochs: the schedule stretched, ease into it
print(f"the number of epochs changed ({planned} -> {total_steps} steps): learning rate warming up again")
status.update(total_steps=total_steps, steps_per_epoch=len(sampler))
print(f"resumed at step {step} (epoch {epoch + 1}/{t.epochs}, batch {batches.skip}); "
f"{total_steps} steps in total")
else:
publish(phase="preparing")
print("computing feature statistics...")
mean, std = feature_stats(train_entries)
model.feat_mean.copy_(mean.to(device))
model.feat_std.copy_(std.to(device))
session_start = time.time()
def elapsed():
"""Training time over all sessions, in seconds."""
return elapsed_before + time.time() - session_start
log_path = os.path.join(cfg.paths.run_dir, "log.csv")
columns = LOG_COLUMNS
if os.path.exists(log_path) and os.path.getsize(log_path) > 0:
with open(log_path, newline="", encoding="utf-8") as f:
columns = next(csv.reader(f)) # a log started by an older train.py keeps its columns
new_log = columns is LOG_COLUMNS
log_file = open(log_path, "a", newline="", encoding="utf-8")
log = csv.DictWriter(log_file, fieldnames=columns, extrasaction="ignore")
if new_log:
log.writeheader()
sample_ids = pick_samples(valid_entries)
samples_path = os.path.join(cfg.paths.run_dir, "samples.json")
clips = [dict({k: valid_entries[i][k] for k in ("reciter", "riwaya", "sura", "aya", "duration")},
ref=valid_entries[i]["text"]) for i in sample_ids]
samples = {"clips": clips, "evals": []}
if os.path.exists(samples_path):
try:
with open(samples_path, encoding="utf-8") as f:
old = json.load(f)
if old.get("clips") == clips:
samples["evals"] = [e for e in old["evals"] if e["step"] <= step]
except (OSError, ValueError, KeyError):
pass
# What the model hears on every validation clip, one file per validation, so the dashboard can
# show any of them ("Show other ayahs") with their whole history.
heard_dir = os.path.join(cfg.paths.run_dir, "heard")
os.makedirs(heard_dir, exist_ok=True)
write_json(os.path.join(heard_dir, "clips.json"), [
dict({k: e[k] for k in ("reciter", "riwaya", "sura", "aya", "duration")}, ref=e["text"]) for e in valid_entries])
for name in os.listdir(heard_dir):
if name.startswith("step_") and int(name[5:12]) > step:
os.remove(os.path.join(heard_dir, name)) # validations a resumed run will redo
publish(phase="training", step=step, epoch=min(epoch + 1, t.epochs), hours_seen=round(seen_seconds / 3600, 2),
elapsed_min=round(elapsed() / 60, 1), best_cer=None if best_step is None else best_cer,
best_step=best_step)
def save(batch_in_epoch, keep=True):
"""last.pt to resume from; keep=True also keeps a step_*.pt for export.py to average."""
state = dict(model=model.state_dict(), opt=opt.state_dict(), scaler=scaler.state_dict(), step=step,
epoch=epoch, batch_in_epoch=batch_in_epoch, best_cer=best_cer, best_step=best_step,
seen_seconds=seen_seconds, elapsed=elapsed(), model_config=vars(cfg.model), vocab=text.VOCAB,
data_id=data_id, valid_id=valid_id, rewarm_from=rewarm_from, total_steps=total_steps)
torch.save(state, last_path + ".tmp")
os.replace(last_path + ".tmp", last_path)
if not keep:
return
step_path = os.path.join(cfg.paths.run_dir, f"step_{step:07d}.pt")
torch.save({"model": state["model"], "model_config": state["model_config"], "vocab": text.VOCAB, "step": step},
step_path + ".tmp")
os.replace(step_path + ".tmp", step_path)
steps = sorted(f for f in os.listdir(cfg.paths.run_dir) if f.startswith("step_") and f.endswith(".pt"))
for old in steps[: -t.keep_checkpoints]:
os.remove(os.path.join(cfg.paths.run_dir, old))
def validate():
nonlocal best_cer, best_step
publish(phase="validating", step=step)
scores, heard = evaluate(model, valid_loader, device, amp_dtype, range(len(valid_entries)))
write_json(os.path.join(heard_dir, f"step_{step:07d}.json"),
{"step": step, "hyps": [heard.get(i, "") for i in range(len(valid_entries))]})
print(f" valid: letter error {scores['cer'] * 100:.2f}% word error {scores['wer'] * 100:.2f}%", flush=True)
log.writerow({"step": step, "epoch": min(epoch + 1, t.epochs), "hours_seen": round(seen_seconds / 3600, 2),
"elapsed_min": round(elapsed() / 60, 1),
**{f"valid_{k}": "" if v is None else round(v, 5) for k, v in scores.items()}})
log_file.flush()
samples["evals"].append({"step": step, "hyps": [heard.get(i, "") for i in sample_ids]})
write_json(samples_path, samples)
if scores["cer"] < best_cer:
best_cer, best_step = scores["cer"], step
torch.save({"model": model.state_dict(), "model_config": vars(cfg.model), "vocab": text.VOCAB,
"step": step, "valid_cer": scores["cer"]}, os.path.join(cfg.paths.run_dir, "best.pt"))
print(" new best checkpoint", flush=True)
publish(phase="training", valid=scores, best_cer=best_cer, best_step=best_step)
stop_path = os.path.join(cfg.paths.run_dir, "stop")
if os.path.exists(stop_path):
os.remove(stop_path) # left from the previous pause
deadline = deadline_from(args)
if deadline:
print(f"will pause at {time.strftime('%H:%M', time.localtime(deadline))} "
f"(in {(deadline - time.time()) / 3600:.1f} h)")
publish(stop_at=deadline)
every = getattr(t, "checkpoint_minutes", 10) * 60
last_checkpoint = time.time()
losses, norms, session_steps, skipped, overall_peak = [], [], 0, 0, 0.0
last_log_time, last_log_seen = time.time(), seen_seconds
done = batches.skip if step else 0 # batches of the current epoch already trained
model.train()
try:
while epoch < t.epochs:
for k, (x, lens, targets, target_lens, idx) in enumerate(loader, start=done):
for g in opt.param_groups:
g["lr"] = lr_at(step, total_steps, t)
if rewarm_from is not None: # after new data: warm up again from the resume point
g["lr"] *= min(1.0, (step - rewarm_from + 1) / t.warmup_steps)
x, lens = x.to(device, non_blocking=True), lens.to(device, non_blocking=True)
opt.zero_grad(set_to_none=True)
try:
with torch.autocast(device.type, dtype=amp_dtype, enabled=amp_dtype is not None):
log_probs, out_lens, *rest = model(x, lens, inter_layers)
inter = rest[0] if rest else []
tgt, tlen = targets.to(device), target_lens.to(device)
letters = target_lens.sum().clamp(min=1)
def ctc(lp): # per letter: a several-minute ayah weighs no more per letter than a short one
return F.ctc_loss(lp.transpose(0, 1), tgt, out_lens, tlen, blank=0, reduction="sum",
zero_infinity=True) / letters
loss = ctc(log_probs)
if inter:
# Intermediate CTC: the same head also reads a few middle layers, so the early
# layers get a short, direct learning signal. Without it the 16-layer model can
# stay stuck predicting the same letters for every clip.
total = (1 - inter_weight) * loss + inter_weight * sum(ctc(lp) for lp in inter) / len(inter)
else:
total = loss
scaler.scale(total).backward()
except torch.OutOfMemoryError:
# A rare oversized batch: skip it rather than stop the run.
del x, lens
log_probs = out_lens = loss = None
opt.zero_grad(set_to_none=True)
torch.cuda.empty_cache()
skipped += 1
print(f" skipped a batch that ran out of GPU memory ({len(idx)} clips); "
"lower max_batch_seconds if this happens often", flush=True)
continue
scaler.unscale_(opt)
norms.append(float(torch.nn.utils.clip_grad_norm_(model.parameters(), t.grad_clip)))
scaler.step(opt)
scaler.update()
step += 1
done = k + 1
session_steps += 1
seen_seconds += float(lens.sum()) / 100.0
losses.append(loss.item())
if step % t.log_every_steps == 0:
now = time.time()
rate = session_steps / max(1e-6, now - session_start) # steps per second in this session
eta_h = (total_steps - step) / max(1e-6, rate) / 3600
avg = sum(losses) / len(losses)
finite = [g for g in norms if math.isfinite(g)]
grad = sum(finite) / len(finite) if finite else float("nan")
speed = (seen_seconds - last_log_seen) / max(1e-6, now - last_log_time) # audio h per hour
losses, norms, last_log_time, last_log_seen = [], [], now, seen_seconds
peak = None
if device.type == "cuda":
peak = torch.cuda.max_memory_allocated() / 2**30 # since the previous log line
torch.cuda.reset_peak_memory_stats()
overall_peak = max(overall_peak, peak)
lr = float(f"{opt.param_groups[0]['lr']:.4g}")
gpu = f" GPU peak {overall_peak:.1f} GB" if peak is not None else ""
print(f"step {step}/{total_steps} epoch {epoch + 1}/{t.epochs} loss/letter {avg:.3f} "
f"lr {lr:.2e} {seen_seconds / 3600:.1f} h of audio ETA {eta_h:.1f} h{gpu}", flush=True)
row = {"step": step, "epoch": epoch + 1, "loss": round(avg, 4), "lr": lr,
"hours_seen": round(seen_seconds / 3600, 2), "elapsed_min": round(elapsed() / 60, 1),
"grad_norm": round(grad, 3), "audio_h_per_h": round(speed),
"gpu_peak_gb": "" if peak is None else round(peak, 2)}
log.writerow(row)
log_file.flush()
publish(phase="training", **{k: v for k, v in row.items() if k != "gpu_peak_gb"},
eta_h=round(eta_h, 3), gpu_peak_gb=round(overall_peak, 2), skipped_batches=skipped)
if step % t.eval_every_steps == 0:
validate()
save(done)
last_checkpoint = time.time()
elif time.time() - last_checkpoint >= every:
save(done, keep=False) # a crash or a closed window loses at most this much
last_checkpoint = time.time()
if deadline and time.time() >= deadline:
raise Pause(f"--stop-at {args.stop_at}" if args.stop_at else f"--stop-after {args.stop_after:g}")
if step % 10 == 0 and os.path.exists(stop_path):
raise Pause("pause requested")
if args.max_steps and step >= args.max_steps:
raise Pause("--max-steps")
epoch += 1
done = 0
sampler.set_epoch(epoch)
save(0)
last_checkpoint = time.time()
except (KeyboardInterrupt, Pause) as e:
reason = str(e) or "Ctrl+C"
print(f"pausing ({reason}): saving a checkpoint...", flush=True)
save(done, keep=False)
if os.path.exists(stop_path):
os.remove(stop_path)
log_file.close()
publish(phase="stopped", stop_reason=reason)
print(f"saved at step {step}. Run the same command to continue.")
return 0
except BaseException as e:
log_file.close()
try:
message = f"{type(e).__name__}: {e}"
except Exception: # some exceptions can't format themselves after crossing from a worker
message = type(e).__name__
publish(phase="crashed", error=message)
raise
try:
print("final validation...")
validate()
print(f"final valid: best letter error {best_cer * 100:.2f}% (step {best_step})")
finally:
log_file.close()
publish(phase="finished", eta_h=0)
if __name__ == "__main__":
sys.exit(main())