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}")