File size: 12,484 Bytes
534cc76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
"""Train the shared decision head over frozen native-rate signal embeddings."""

import argparse
import json
import os
from pathlib import Path
import random
from time import perf_counter

import torch
from torch.nn import functional as F
import yaml

from .checkpoint import save_checkpoint, load_checkpoint
from .data import ChoiceDataset, assert_disjoint, make_batch
from .evaluate import collect, metrics
from .features import open_feature_cache
from .model import PayaModel
from .tracking import open_run, run_identity, log_best
from .atomic import atomic_output


def choice_loss(logits, targets, mask, smoothing=0.0, reduction="mean"):
    if targets.dtype != torch.long or targets.shape != (len(logits),):
        raise ValueError("Targets must be integer candidate indices [B]")
    if ((targets < 0) | (targets >= logits.shape[1])).any():
        raise ValueError("Target out of range")
    if not mask.gather(1, targets[:, None]).all():
        raise ValueError("Cannot train on a padded candidate")
    if not 0 <= smoothing < 1 or reduction not in ("mean","none"):
        raise ValueError("Invalid candidate loss smoothing or reduction")
    if smoothing==0:
        return F.cross_entropy(logits, targets,reduction=reduction)
    logp=logits.log_softmax(-1)
    nll=-logp.gather(1,targets[:,None]).squeeze(1)
    uniform=-logp.masked_fill(~mask,0).sum(-1)/mask.sum(-1)
    values=(1-smoothing)*nll+smoothing*uniform
    return values.mean() if reduction=="mean" else values


def train(config_path, run_dir, device="cpu", resume=None):
    config = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
    destination = Path(run_dir)
    if destination.exists() and any(destination.iterdir()) and resume is None:
        raise FileExistsError("Use a fresh run directory or supply --resume")
    destination.mkdir(parents=True, exist_ok=True)
    seed = config.get("seed", 42)
    random.seed(seed); torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    torch.set_num_threads(config.get("threads", 4))
    datasets = {name: ChoiceDataset(path) for name, path in config["data"].items()
                if name in ("train", "selection", "calibration", "test") and path}
    if not {"train", "selection"} <= datasets.keys():
        raise ValueError("Training and independent selection datasets are required")
    assert_disjoint(*datasets.values())
    model = PayaModel.from_assets(config["assets"], config.get("preparation"), config.get("head"), device)
    cache = open_feature_cache(config)
    opt = config["optim"]
    epochs = opt["epochs"]
    if isinstance(epochs, bool) or not isinstance(epochs, int) or epochs < 1:
        raise ValueError("Need at least one training epoch")
    optimizer = torch.optim.AdamW(model.trainable_parameters(), lr=opt["lr"], weight_decay=opt.get("weight_decay", 0.01))
    interval = opt.get("checkpoint_every_steps", 100)
    if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1:
        raise ValueError("checkpoint_every_steps must be a positive integer")
    bindings = {name: data.sha256 for name, data in datasets.items()}
    forbidden = {"datasets": [{"path": str(datasets[name].path), "sha256": datasets[name].sha256}
                               for name in ("train", "selection")]}
    start, best, step, previous_run = 0, float("inf"), 0, None
    next_batch, epoch_total, epoch_count, restored_state = 0, 0.0, 0, None
    # Load lazy text assets before restoring RNG, so initialization cannot perturb resumed dropout.
    make_batch(model, [datasets["train"].rows[0]])
    if resume:
        payload = load_checkpoint(resume, model)
        state = payload["training"]
        if not state or ({k: v for k, v in state["config"].items() if k != "wandb"}
                         != {k: v for k, v in config.items() if k != "wandb"}):
            raise ValueError("Resume requires the same model, data and optimizer configuration")
        optimizer.load_state_dict(state["optimizer"])
        if state.get("dataset_sha256") != bindings:
            raise ValueError("Resume dataset content differs from checkpoint")
        start, best = state["epoch"], state["best_nll"]
        step, previous_run = state["global_step"], state["wandb"]
        next_batch = state.get("next_batch", 0)
        epoch_total, epoch_count = state.get("epoch_loss_sum", 0.0), state.get("epoch_count", 0)
        restored_state = state
    history = []
    completed_result = restored_state.get("completed_epoch_result") if restored_state else None
    last_batch_metrics = restored_state.get("last_batch_metrics") if restored_state else None
    history_path = destination/"history.jsonl"
    saved_history = []
    if history_path.exists():
        with history_path.open(encoding="utf-8") as source:
            saved_history = [json.loads(line) for line in source if line.strip()]
        saved_history = [row for row in saved_history if row["epoch"] <= start]
        with atomic_output(history_path,"w") as output:
            for row in saved_history:
                output.write(json.dumps(row)+"\n")
    steps_path = destination/"steps.jsonl"
    if restored_state and "metrics_bytes" in restored_state:
        committed_bytes = restored_state["metrics_bytes"]
        if not steps_path.exists() or steps_path.stat().st_size < committed_bytes:
            raise ValueError("Resume requires the committed steps.jsonl beside the checkpoint")
        with steps_path.open("r+b") as output:
            output.truncate(committed_bytes)
            output.flush()
            os.fsync(output.fileno())
    if start >= epochs:
        return model, history
    tracking = config.get("wandb", {})
    with open_run(config, destination, model, datasets, previous_run) as run, steps_path.open("a+b") as step_log:
        if restored_state:
            torch.set_rng_state(restored_state["torch_rng"])
            random.setstate(restored_state["python_rng"])
            if torch.cuda.is_available() and restored_state["cuda_rng"]:
                torch.cuda.set_rng_state_all(restored_state["cuda_rng"])
            if run:
                run.log({"recovery/checkpoint_step": step, "recovery/next_batch": next_batch})
                if restored_state.get("metrics_bytes"):
                    with steps_path.open(encoding="utf-8") as source:
                        for line in source:
                            record=json.loads(line)
                            if record["step"] % tracking.get("log_every",10)==0 or record["step"]==step:
                                run.log({**record,"recovery/replayed":True})
                elif last_batch_metrics:
                    run.log({**last_batch_metrics,"recovery/replayed":True})
                for completed in saved_history:
                    run.log({"step":completed["step"],"epoch":completed["epoch"],"recovery/replayed":True,
                        **{f"selection/{key}":value for key,value in completed["selection"].items() if key!='reliability'}})

        def progress(epoch, cursor, total, count):
            step_log.flush()
            os.fsync(step_log.fileno())
            return {"config": config, "epoch": epoch, "next_batch": cursor,
                    "metrics_bytes":step_log.tell(),
                    "epoch_loss_sum": total, "epoch_count": count,
                    "completed_epoch_result": completed_result,
                    "last_batch_metrics": last_batch_metrics,
                    "best_nll": best, "global_step": step, "dataset_sha256": bindings,
                    "wandb": run_identity(run, tracking.get("mode", "disabled")),
                    "optimizer": optimizer.state_dict(), "torch_rng": torch.get_rng_state(),
                    "python_rng": random.getstate(),
                    "cuda_rng": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else [],
                    "forbidden_calibration": forbidden}

        if not resume:
            save_checkpoint(destination/"last.pt", model, progress(0, 0, 0.0, 0))
        for epoch in range(start, epochs):
            started = perf_counter()
            for data in datasets.values():
                data.assert_unchanged()
            total, count = (epoch_total, epoch_count) if epoch == start else (0.0, 0)
            cursor = next_batch if epoch == start else 0
            for batch_index, rows in enumerate(datasets["train"].batches(
                    config.get("batch_size", 8), seed+epoch, start_batch=cursor), start=cursor):
                batch = make_batch(model, rows)
                targets = torch.tensor([row["target"] for row in rows], device=model.device)
                optimizer.zero_grad(set_to_none=True)
                model.train()
                result = model(batch, cache=cache)
                loss = choice_loss(result["logits"], targets, batch.criteria_mask,opt.get("label_smoothing",0.0))
                if not torch.isfinite(loss):
                    raise FloatingPointError("Nonfinite training loss")
                loss.backward()
                gradient_norm = torch.nn.utils.clip_grad_norm_(list(model.trainable_parameters()), 1.0)
                if not torch.isfinite(gradient_norm):
                    raise FloatingPointError("Nonfinite training gradients")
                optimizer.step()
                step += 1
                total += loss.item()*len(rows); count += len(rows)
                cursor = batch_index+1
                last_batch_metrics = {"step": step, "epoch": epoch+1, "train/loss": loss.item(),
                    "train/answer_loss": loss.item(), "train/gradient_norm": float(gradient_norm),
                    "train/learning_rate": optimizer.param_groups[0]["lr"]}
                step_log.write((json.dumps(last_batch_metrics,allow_nan=False)+"\n").encode("utf-8"))
                if run and step % tracking.get("log_every", 10) == 0:
                    run.log(last_batch_metrics)
                if step % interval == 0:
                    save_checkpoint(destination/"last.pt", model, progress(epoch, cursor, total, count))
            records = collect(model, datasets["selection"], config.get("batch_size", 8), cache,
                              output_path=destination/"selection-predictions.jsonl")
            selection = metrics(records)
            improved = selection["nll"] < best
            if improved:
                best = selection["nll"]
            row = {"epoch": epoch+1, "phase": "decision_head",
                   "step": step, "train_loss": total/max(1, count), "selection": selection}
            completed_result = row
            state = progress(epoch+1, 0, 0.0, 0)
            if improved:
                save_checkpoint(destination/"best.pt", model, state)
            saved_history = [previous for previous in saved_history if previous["epoch"] < epoch+1]+[row]
            with atomic_output(history_path,"w") as log:
                for previous in saved_history:
                    log.write(json.dumps(previous)+"\n")
            save_checkpoint(destination/"last.pt", model, state)
            history.append(row)
            if run:
                logged = {"epoch": epoch+1, "step": step, "phase": row["phase"],
                          "train/epoch_loss": row["train_loss"], "train/epoch_seconds": perf_counter()-started,
                          **{f"selection/{k}": selection[k] for k in ("accuracy", "nll", "brier", "ece_15", "count")}}
                run.log(logged)
                if improved:
                    run.summary.update({"best/epoch":epoch+1,"best/selection_nll":best})
                    if tracking.get("log_models",True):
                        log_best(run, model, destination, epoch+1, best)
            print(json.dumps(row), flush=True)
    return model, history


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--config", type=Path, default=Path("config/paya.yaml"))
    parser.add_argument("--run-dir", type=Path, required=True)
    parser.add_argument("--device", default="cpu")
    parser.add_argument("--resume", type=Path)
    args = parser.parse_args()
    train(args.config, args.run_dir, args.device, args.resume)