File size: 9,982 Bytes
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fdae16
 
 
 
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261bc1b
 
 
 
7fdae16
 
 
 
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fdae16
6e5e0d0
 
 
 
 
 
 
7fdae16
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
7fdae16
 
 
 
 
 
 
 
 
 
 
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fdae16
 
261bc1b
7fdae16
261bc1b
 
 
6e5e0d0
261bc1b
 
6e5e0d0
 
 
 
7fdae16
 
 
 
 
 
 
 
 
 
 
 
 
 
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7fdae16
6e5e0d0
7fdae16
6e5e0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""Train one pointer-chasing model (fixed-depth or recurrent).

Example:
  python train.py --arch rec --depth 1 --iters 4 --seed 0 \
      --run-name rec1x4-seed0 --hub-repo eac123/ptr-recurrent-depth \
      --space-id <trackio space id>

Trains on fresh random mappings every batch (no fixed train set), k ~ U{1..8},
and logs to trackio. Saves a checkpoint + training history JSON locally and
uploads both to the Hub repo.
"""

import argparse
import json
import os
import time

import numpy as np
import torch
import torch.nn.functional as F

from ptr.common import sample_train_batch, sample_eval_batch
from ptr.flops import count_params, flops_budget_summary, step_flops
from ptr.models import PtrTransformer

PROBE_KS = (16, 32, 64)


def make_parser():
    p = argparse.ArgumentParser()
    p.add_argument("--arch", choices=["fixed", "rec"], required=True)
    p.add_argument("--depth", type=int, default=4)
    p.add_argument("--iters", default="1",
                   help="int, or 'dynamic' for R ~ U{2..8} per batch")
    p.add_argument("--d-model", type=int, default=128)
    p.add_argument("--stem-depth", type=int, default=0,
                   help="untied blocks applied once before the recurrent loop")
    p.add_argument("--dense-supervision", action="store_true",
                   help="supervise every unroll t toward f^{min(t,k)}(x)")
    p.add_argument("--S", type=int, default=64)
    p.add_argument("--k-max", type=int, default=8)
    p.add_argument("--steps", type=int, default=25000)
    p.add_argument("--batch", type=int, default=256)
    p.add_argument("--lr", type=float, default=3e-4)
    p.add_argument("--warmup", type=int, default=1000)
    p.add_argument("--seed", type=int, default=0)
    p.add_argument("--run-name", required=True)
    p.add_argument("--out-dir", default="out")
    p.add_argument("--hub-repo", default=None)
    p.add_argument("--space-id", default=None,
                   help="trackio space id for the metrics dashboard")
    p.add_argument("--project", default="ptr-recurrent-depth")
    p.add_argument("--eval-every", type=int, default=2500)
    p.add_argument("--eval-n", type=int, default=400)
    return p


def resolve_iters(args):
    if args.iters == "dynamic":
        return ("dynamic", 2, 8)
    if args.iters == "aligned":
        # one unroll per hop: R = k per example (all examples in a batch
        # share the same k so a single unroll count serves the batch)
        return ("aligned", 1, args.k_max)
    if args.iters == "aligned4":
        # aligned with a constant margin: R = k + 4, so the model needs
        # 'at least k unrolls', not exact halting at depth k
        return ("aligned_margin", 1, args.k_max, 4)
    return int(args.iters)


def sample_iters(spec, rng):
    if isinstance(spec, tuple):
        return int(rng.integers(spec[1], spec[2] + 1))
    return spec


def lr_at(step, args):
    if step < args.warmup:
        return args.lr * (step + 1) / args.warmup
    t = (step - args.warmup) / max(1, args.steps - args.warmup)
    return args.lr * 0.5 * (1 + np.cos(np.pi * t))


@torch.no_grad()
def probe(model, rng, device, S, ks, n, it_for):
    accs = {}
    for k in ks:
        b = sample_eval_batch(rng, n, S, k=k)
        f = torch.as_tensor(b["f"], device=device)
        x = torch.as_tensor(b["x"], device=device)
        kt = torch.as_tensor(b["k"], device=device)
        y = torch.as_tensor(b["answer"], device=device)
        logits = model(f, x, kt, n_iters=it_for(k))
        accs[k] = float((logits.argmax(-1) == y).float().mean())
    return accs


def main():
    args = make_parser().parse_args()
    os.makedirs(args.out_dir, exist_ok=True)
    torch.manual_seed(args.seed)
    np.random.seed(args.seed)
    rng = np.random.default_rng(args.seed)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    iters_spec = resolve_iters(args)

    model = PtrTransformer(args.S, args.d_model, 4, 4 * args.d_model,
                           depth=args.depth, stem_depth=args.stem_depth,
                           zero_init=True).to(device)

    # readout-unroll function: R as a function of the true hop count k
    if isinstance(iters_spec, tuple) and iters_spec[0] == "aligned":
        it_for = lambda k: k
    elif isinstance(iters_spec, tuple) and iters_spec[0] == "aligned_margin":
        it_for = (lambda k, m=iters_spec[3]: k + m)
    else:
        _base = iters_spec if isinstance(iters_spec, int) else 8
        it_for = lambda k, b=_base: b
    nparams = count_params(model)
    budget = flops_budget_summary(model, args.S, args.batch, iters_spec,
                                  args.steps)
    print(f"[budget] {json.dumps(budget)}")
    # cheap assertions up front
    assert budget["params_total"] > 0 and budget["train_flops_total"] > 0

    opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01)

    trackio = None
    if args.space_id:
        import trackio
        trackio.init(project=args.project, space_id=args.space_id,
                     config={**vars(args), **budget})

    history = {"loss": [], "id_acc": [], "ood": [], "iters": [],
               "flops_cum": 0.0}
    flops_cum = 0.0
    t0 = time.time()
    bytes_per_batch = 0
    for step in range(args.steps):
        lr = lr_at(step, args)
        for g in opt.param_groups:
            g["lr"] = lr
        if isinstance(iters_spec, tuple) and iters_spec[0] in (
                "aligned", "aligned_margin"):
            kb = int(rng.integers(iters_spec[1], iters_spec[2] + 1))
            n_iters = kb + iters_spec[3] if len(iters_spec) > 3 else kb
        else:
            n_iters = sample_iters(iters_spec, rng)
            kb = None
        b = sample_train_batch(rng, args.batch, args.S, k_max=args.k_max)
        if kb is not None:
            b["k"][:] = kb
        f = torch.as_tensor(b["f"], device=device)
        x = torch.as_tensor(b["x"], device=device)
        k = torch.as_tensor(b["k"], device=device)
        y = torch.as_tensor(b["answer"], device=device)
        if args.dense_supervision and kb is not None:
            # dense targets: unroll t -> f^{min(t, kb)}(x)  (hold after k)
            from ptr.common import iterate_mapping
            tg = iterate_mapping(b["f"], b["x"], n_iters)  # [n_iters, B]
            targets = np.stack([tg[min(t, kb) - 1]
                                for t in range(1, n_iters + 1)])  # [n_iters,B]
            logits, all_logits = model(f, x, k, n_iters=n_iters,
                                       return_all=True)
            loss = F.cross_entropy(
                all_logits.reshape(-1, args.S),
                torch.as_tensor(targets.T, device=device).reshape(-1))
        else:
            logits = model(f, x, k, n_iters=n_iters)
            loss = F.cross_entropy(logits, y)
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
        flops_cum += step_flops(model, args.S, args.batch, n_iters, True)

        if step % 100 == 0 or step == args.steps - 1:
            ips = 100 / max(1e-9, time.time() - t0) if step > 0 else 0.0
            t0 = time.time()
            rec = {"step": step, "loss": round(float(loss), 4), "lr": lr,
                   "iters": n_iters, "steps_per_s": round(ips, 2),
                   "flops_cum": flops_cum}
            history["loss"].append(rec)
            if device == "cuda" and step == 100:
                bytes_per_batch = torch.cuda.max_memory_allocated()
                print(f"[mem] peak GPU memory {bytes_per_batch/1e6:.1f} MB")
            if trackio:
                trackio.log({"train/loss": float(loss), "train/lr": lr,
                             "train/steps_per_s": ips,
                             "train/flops_cum": flops_cum,
                             "train/iters": n_iters}, step=step)
        if (step + 1) % args.eval_every == 0 or step == args.steps - 1:
            id_accs = probe(model, rng, device, args.S,
                            list(range(1, args.k_max + 1)), args.eval_n,
                            it_for)
            ood = probe(model, rng, device, args.S, PROBE_KS, args.eval_n,
                        it_for)
            id_mean = float(np.mean(list(id_accs.values())))
            print(f"[eval] step {step+1} id_acc(k=1..8) {id_mean:.4f} "
                  f"ood {ood}")
            history["id_acc"].append({"step": step + 1, "mean": id_mean,
                                      "per_k": id_accs})
            history["ood"].append({"step": step + 1, "acc": ood})
            if trackio:
                trackio.log({"eval/id_acc_mean": id_mean,
                             "eval/ood_k16": ood[16], "eval/ood_k32": ood[32],
                             "eval/ood_k64": ood[64]}, step=step + 1)
            history["flops_cum"] = flops_cum

    ckpt_path = os.path.join(args.out_dir, f"{args.run_name}.pt")
    torch.save({"state_dict": model.state_dict(),
                "config": vars(args), "budget": budget,
                "n_iters_spec": iters_spec}, ckpt_path)
    hist_path = os.path.join(args.out_dir, f"{args.run_name}_history.json")
    with open(hist_path, "w") as fh:
        json.dump(history, fh, indent=1)

    if args.hub_repo:
        from huggingface_hub import HfApi
        api = HfApi()
        api.upload_file(path_or_fileobj=ckpt_path,
                        path_in_repo=f"ckpts/{args.run_name}.pt",
                        repo_id=args.hub_repo, repo_type="model")
        api.upload_file(path_or_fileobj=hist_path,
                        path_in_repo=f"results/{args.run_name}_history.json",
                        repo_id=args.hub_repo, repo_type="model")
        print(f"[hub] uploaded ckpt + history to {args.hub_repo}")
    if trackio:
        trackio.finish()
    print(f"[done] {args.run_name}: final loss "
          f"{history['loss'][-1]['loss']}, "
          f"id_acc {history['id_acc'][-1]['mean']:.4f}")


if __name__ == "__main__":
    main()