File size: 6,790 Bytes
0ba9d09 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | """Seeding, device selection, the training loop, checkpoints and the shared command line.
Every lecture script ends in the same four lines: parse the arguments, build the model, call
``train`` with a step function, and print a report. Only the step function differs between
frameworks, and each lecture is about that function.
"""
from __future__ import annotations
import argparse
import json
import random
import time
from pathlib import Path
from typing import Callable
import torch
# %% 01. Reproducibility and hardware
def set_seed(seed: int) -> None:
"""Seed Python, NumPy through torch, and torch itself, so a run repeats exactly."""
random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def pick_device(requested: str = "auto") -> torch.device:
"""Choose CUDA, then Apple MPS, then CPU, unless the reader names one."""
if requested != "auto":
return torch.device(requested)
if torch.cuda.is_available():
return torch.device("cuda")
if torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
# %% 02. The command line every script shares
#
# A reader who has learned the flags once knows them for all fifty scripts. A script adds its own
# flags on top of these with `parser.add_argument`.
def common_parser(description: str) -> argparse.ArgumentParser:
"""The flags shared by every script in the course."""
parser = argparse.ArgumentParser(
description=description, formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--steps", type=int, default=600, help="optimizer steps")
parser.add_argument("--batch-size", type=int, default=64, help="examples per step")
parser.add_argument("--lr", type=float, default=1e-3, help="learning rate")
parser.add_argument("--width", type=int, default=128, help="hidden width of the network")
parser.add_argument("--length", type=int, default=16, help="sequence length L")
parser.add_argument("--samples", type=int, default=16, help="sequences or points to generate")
parser.add_argument("--sample-steps", type=int, default=64, help="integration steps")
parser.add_argument("--seed", type=int, default=0, help="random seed")
parser.add_argument("--device", type=str, default="auto", help="auto, cpu, cuda or mps")
parser.add_argument("--out", type=str, default="", help="directory for the run's output")
parser.add_argument("--quiet", action="store_true", help="print only the final report")
return parser
# %% 03. The training loop
#
# The caller passes a function of the step index that returns a scalar loss with a graph attached;
# everything else, the optimizer, the logging and the timing, is the same in every lecture.
def train(
model: torch.nn.Module,
loss_fn: Callable[[int], torch.Tensor],
steps: int = 600,
lr: float = 1e-3,
log_every: int = 100,
quiet: bool = False,
grad_clip: float = 1.0,
extra_parameters: list[torch.nn.Parameter] | None = None,
) -> list[float]:
"""Run ``steps`` optimizer steps on ``loss_fn`` and return the loss at every step.
``loss_fn(step)`` draws its own batch, so a lecture with fresh noise at every step runs without
a DataLoader. ``extra_parameters`` covers the rare case of a second trained object, such as the
bias force of Chapter 7.
"""
parameters = list(model.parameters()) + list(extra_parameters or [])
optimizer = torch.optim.AdamW(parameters, lr=lr, weight_decay=0.0)
losses: list[float] = []
started = time.time()
model.train()
for step in range(steps):
loss = loss_fn(step) # The lecture's own objective.
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(parameters, grad_clip) # Keep a bad batch from derailing.
optimizer.step()
losses.append(float(loss.detach()))
if not quiet and log_every and (step + 1) % log_every == 0:
window = losses[-log_every:] # Average over the last window, a less jumpy number.
print(f" step {step + 1:5d}/{steps} loss {sum(window) / len(window):.4f}")
if not quiet:
print(f" trained {steps} steps in {time.time() - started:.1f}s")
model.eval()
return losses
# %% 04. Saving a run
#
# A run writes three files: the weights, the configuration that produced them, and a short report of
# what came out. No optimizer state, because none of these examples resumes training.
def save_run(
out: str | Path,
model: torch.nn.Module | None = None,
config: dict | None = None,
report: dict | None = None,
losses: list[float] | None = None,
samples: list[str] | None = None,
) -> Path | None:
"""Write the weights, the configuration, the report, the loss curve and any samples."""
if not out:
return None
directory = Path(out)
directory.mkdir(parents=True, exist_ok=True)
if model is not None:
torch.save({"model": model.state_dict(), "config": config or {}},
directory / "checkpoint.pt")
if config is not None:
(directory / "config.json").write_text(json.dumps(config, indent=2) + "\n")
if report is not None:
(directory / "report.json").write_text(json.dumps(report, indent=2) + "\n")
if losses is not None:
# Thin the curve to at most 200 points so the file stays small and readable.
stride = max(1, len(losses) // 200)
thinned = [round(v, 5) for v in losses[::stride]]
(directory / "losses.json").write_text(json.dumps(thinned) + "\n")
if samples is not None:
(directory / "samples.txt").write_text("\n".join(samples) + "\n")
return directory
def load_run(out: str | Path, model: torch.nn.Module) -> dict:
"""Read the weights back into ``model`` and return the configuration that trained them."""
payload = torch.load(Path(out) / "checkpoint.pt", map_location="cpu", weights_only=False)
model.load_state_dict(payload["model"])
model.eval()
return payload.get("config", {})
# %% 05. Printing a report
#
# Every script ends with the same two-column block, so we can compare two reports side by side.
def print_report(title: str, report: dict, samples: list[str] | None = None) -> None:
"""Print a titled block of name and value pairs, and a few samples."""
print(f"\n{title}")
print("-" * len(title))
for key, value in report.items():
if isinstance(value, float):
print(f" {key:<34s} {value:.4f}")
else:
print(f" {key:<34s} {value}")
if samples:
print("\n samples")
for sample in samples[:8]:
print(f" {sample}")
|