File size: 50,083 Bytes
2f3b258 9783cf6 2f3b258 cd628d1 2f3b258 255646f 2f3b258 2f39622 cd628d1 3c82185 2f3b258 cd628d1 3c82185 11de80f 2f3b258 2f39622 2f3b258 2f39622 6172f2d 11de80f 2f3b258 11de80f 2f3b258 386795b 2f3b258 cd628d1 2f3b258 3c82185 2f39622 2f3b258 3c82185 2f3b258 cd628d1 2f39622 2f3b258 2f39622 3c82185 2f3b258 cd628d1 2f39622 6172f2d 2f3b258 3c82185 2f3b258 cd628d1 2f3b258 6172f2d 2f3b258 9783cf6 cd628d1 6172f2d 11de80f 6172f2d 11de80f 6172f2d 2f3b258 3c82185 2f3b258 cd628d1 3c82185 2f3b258 3c82185 2f3b258 3c82185 cd628d1 2f3b258 3c82185 2f3b258 c7f8cbb 11de80f c7f8cbb 2f39622 c7f8cbb 2f39622 c7f8cbb c37c1bb 11de80f 2f39622 c37c1bb 11de80f c7f8cbb 2f39622 c7f8cbb 2f39622 c7f8cbb d74339f c7f8cbb 2f39622 c7f8cbb 2f39622 c7f8cbb 2f39622 c7f8cbb 2f39622 2f3b258 2f39622 6172f2d c37c1bb 6172f2d c37c1bb 6172f2d c37c1bb 3876723 c37c1bb 11de80f 2f3b258 2f39622 2f3b258 2f39622 2f3b258 2f39622 2f3b258 2f39622 2f3b258 2f39622 6172f2d 2f39622 2f3b258 2f39622 6172f2d 11de80f 6172f2d 11de80f 6172f2d 2f39622 6172f2d 2f3b258 2f39622 bc4fd5d 2f39622 2f3b258 2f39622 2f3b258 6172f2d 11de80f 2f39622 6172f2d 2f39622 6172f2d 2f39622 2f3b258 2f39622 bc4fd5d 2f39622 6172f2d 2f39622 6172f2d 2f39622 2f3b258 2f39622 2f3b258 255646f 2f3b258 3c82185 cd628d1 2f39622 2f3b258 2f39622 2f3b258 6172f2d 2f3b258 2f39622 6172f2d 2f39622 | 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 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 | """Aggregate eval JSONs into plots, tables and REPORT.md, and upload to Hub.
Run anywhere with network access; pulls results/*_eval.json and
results/*_history.json from the repo first so it can run in a job container.
Dependencies: numpy, matplotlib, huggingface_hub.
"""
import glob
import json
import os
import sys
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Rectangle
REPO = "eac123/ptr-recurrent-depth"
PLOTS = "plots"
FAMILIES = ["fixed4x1", "rec1x4", "rec2x2", "rec1xdynamic", "rec4x4",
"fixed4-long", "rec1aligned", "rec1m4", "rec1m4dense"]
LABELS = {
"fixed4x1": "fixed-4 (4 blocks x 1)",
"rec1x4": "recurrent 1x4 (train R=4)",
"rec2x2": "recurrent 2x2 (train R=2)",
"rec1xdynamic": "recurrent 1x dynamic R~U{2..8}",
"rec4x4": "recurrent 4x4 (train R=4)",
"fixed4-long": "fixed-4 long (100k steps)",
"rec1aligned": "rec-1x-aligned (train R=k)",
"rec1m4": "rec-1x m4 (R=k+4, end-of-chain loss)",
"rec1m4dense": "rec-1x m4+dense (R=k+4, stem, dense loss)",
}
TRAIN_ITERS = {"fixed4x1": 1, "rec1x4": 4, "rec2x2": 2, "rec1xdynamic": 8,
"rec4x4": 4, "fixed4-long": 1, "rec1aligned": None,
"rec1m4": None, "rec1m4dense": None}
def pull():
from huggingface_hub import snapshot_download
if not glob.glob("results/*-seed*_eval.json"):
snapshot_download(repo_id=REPO, repo_type="model",
allow_patterns=["results/*.json"], local_dir=".")
files = sorted(glob.glob("results/*_eval.json"))
return {family: [json.load(open(f)) for f in files
if os.path.basename(f).startswith(family + "-seed")]
for family in FAMILIES}
def mean_std(vals):
v = np.array(vals, dtype=float)
return v.mean(), (v.std(ddof=1) if len(v) > 1 else 0.0)
def id_mean(e):
return float(np.mean([e["acc_vs_k"][str(k)] for k in range(1, 9)]))
def id_curve(evals_fam):
"""(ks, mean, std) over seeds for acc_vs_k."""
ks = np.arange(1, 65)
acc = np.array([[e["acc_vs_k"][str(k)] for k in ks] for e in evals_fam])
return acc.mean(0), acc.std(0)
def budget_of(evals_fam):
b = evals_fam[0]["budget"]
return b["params_total"], b["train_flops_total"]
# --------------------------------------------------------------------------
# figures
# --------------------------------------------------------------------------
def plot_acc_vs_k(evals, out):
plt.figure(figsize=(8, 5))
ks = np.arange(1, 65)
for fam in FAMILIES:
if not evals[fam]:
continue
m, s = id_curve(evals[fam])
p = plt.plot(ks, m, label=LABELS[fam])[0]
plt.fill_between(ks, m - s, m + s, alpha=0.15, color=p.get_color())
plt.axhline(1 / 64, color="gray", ls=":", lw=1, label="chance (1/64)")
plt.axvline(8, color="gray", ls="--", lw=1, label="max training k")
plt.xscale("log", base=2)
plt.xlabel("hop count k (log2)"); plt.ylabel("exact-match accuracy")
plt.title("OOD length generalisation (train k=1..8, fresh random f)")
plt.legend(fontsize=7); plt.grid(alpha=0.3); plt.tight_layout()
plt.savefig(out, dpi=150); plt.close()
def plot_acc_vs_iters(evals, out,
fams=("fixed4x1", "rec1x4", "rec1xdynamic", "rec4x4",
"rec1aligned")):
ks_show = [1, 8, 16, 32, 64]
fig, axes = plt.subplots(1, len(fams), figsize=(4 * len(fams), 4),
sharey=True)
for ax, fam in zip(np.atleast_1d(axes), fams):
if not evals[fam]:
continue
rs = np.arange(1, 17)
for k in ks_show:
acc = np.array([[e["acc_vs_iters"][str(r)][str(k)]
for r in rs] for e in evals[fam]])
m, s = acc.mean(0), acc.std(0)
p = ax.plot(rs, m, marker="o", ms=3, label=f"k={k}")[0]
ax.fill_between(rs, m - s, m + s, alpha=0.15, color=p.get_color())
ti = TRAIN_ITERS[fam]
if ti:
ax.axvline(ti, color="gray", ls="--", lw=1)
ax.set_title(f"{LABELS[fam]}\n"
+ ("(dash = train-time unrolls)" if ti
else "(trained R = k, no single dash)"),
fontsize=8)
ax.set_xlabel("inference unrolls R"); ax.grid(alpha=0.3)
axes[0].set_ylabel("exact-match accuracy")
axes[0].legend(fontsize=7)
fig.suptitle("Accuracy vs inference-time recurrence count", y=1.02)
fig.tight_layout(); fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
def plot_perk_zoom(evals, out,
fams=("fixed4x1", "rec1x4", "rec1xdynamic", "rec4x4",
"rec1aligned", "rec1m4", "rec1m4dense"), kmax=16):
"""Within-and-just-beyond training range: accuracy per hop count k."""
fig, ax = plt.subplots(figsize=(8.5, 5))
ks = np.arange(1, kmax + 1)
for f in fams:
if not evals[f]:
continue
acc = np.array([[e["acc_vs_k"][str(k)] for k in ks]
for e in evals[f]])
m, s = acc.mean(0), acc.std(0)
p = ax.plot(ks, m, marker="o", ms=4, label=LABELS[f])[0]
ax.fill_between(ks, m - s, m + s, alpha=0.12, color=p.get_color())
ax.axvline(8.5, color="gray", ls="--", lw=1)
ax.text(8.7, 0.03, "max training k", fontsize=8, color="gray")
ax.axhline(1 / 64, color="gray", ls=":", lw=1)
ax.set_xticks(ks)
ax.set_xlabel("hop count k"); ax.set_ylabel("exact-match accuracy")
ax.set_title("Per-hop-count accuracy, k = 1..16 "
"(1000 fresh mappings per cell, mean over seeds)")
ax.legend(fontsize=7); ax.grid(alpha=0.3)
fig.tight_layout(); fig.savefig(out, dpi=150); plt.close(fig)
def frontier(m, thr=0.9):
"""Last k with mean accuracy >= thr (scanning from k=1)."""
best = 0
for k in range(1, len(m) + 1):
if m[k - 1] >= thr:
best = k
else:
break
return best
def perk_rows(evals, fams, klo, khi):
ks = list(range(klo, khi + 1))
lines = ["| family | " + " | ".join(f"k={k}" for k in ks) + " |",
"|---|" + "---|" * len(ks)]
for f in fams:
if not evals[f]:
continue
acc = np.array([[e["acc_vs_k"][str(k)] for k in ks]
for e in evals[f]])
m = acc.mean(0)
lines.append(f"| {SHORT[f]} | "
+ " | ".join(f"{v:.2f}" for v in m) + " |")
return lines
def plot_state_size(evals, out, fams=("fixed4x1", "rec1x4")):
fig, axes = plt.subplots(1, len(fams), figsize=(5 * len(fams), 4),
sharey=True)
ks = np.arange(1, 65)
for ax, fam in zip(np.atleast_1d(axes), fams):
if not evals[fam]:
continue
for Sp in ("16", "32", "64"):
if Sp not in evals[fam][0]["acc_vs_state_size"]:
continue
acc = np.array([[e["acc_vs_state_size"][Sp][str(k)] for k in ks]
for e in evals[fam]])
m, s = acc.mean(0), acc.std(0)
p = ax.plot(ks, m, label=f"S'={Sp}")[0]
ax.fill_between(ks, m - s, m + s, alpha=0.15, color=p.get_color())
ax.set_xscale("log", base=2); ax.set_title(LABELS[fam], fontsize=9)
ax.set_xlabel("hop count k (log2)"); ax.grid(alpha=0.3)
axes[0].set_ylabel("exact-match accuracy")
axes[0].legend(fontsize=7)
fig.suptitle("Accuracy vs state-space size (eval on subsets of trained vocab)",
y=1.02)
fig.tight_layout(); fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
def plot_training(evals, out):
from huggingface_hub import hf_hub_download
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
for fam in FAMILIES:
for e in evals[fam]:
name = e["run_name"]
h = json.load(open(hf_hub_download(
repo_id=REPO, repo_type="model",
filename=f"results/{name}_history.json")))
axes[0].plot([r["step"] for r in h["loss"]],
[r["loss"] for r in h["loss"]], alpha=0.25)
axes[1].plot([r["step"] for r in h["id_acc"]],
[r["mean"] for r in h["id_acc"]],
label=f"{fam}", alpha=0.7)
axes[0].set_yscale("log"); axes[0].set_xlabel("step")
axes[0].set_ylabel("train loss"); axes[0].set_title("Loss (all runs)")
axes[1].set_xlabel("step"); axes[1].set_ylabel("in-dist acc (k=1..8 mean)")
axes[1].set_title("In-distribution accuracy during training")
axes[1].legend(fontsize=6); axes[1].grid(alpha=0.3)
fig.tight_layout(); fig.savefig(out, dpi=150); plt.close(fig)
SHORT = {"fixed4x1": "fixed-4", "rec1x4": "rec-1x4", "rec2x2": "rec-2x2",
"rec1xdynamic": "rec-1x-dyn", "rec4x4": "rec-4x4",
"fixed4-long": "fixed-4-long", "rec1aligned": "rec-1x-aligned",
"rec1m4": "m4", "rec1m4dense": "m4+dense"}
def plot_headline_bars(evals, out):
"""Bar-chart companion to the headline table. Two panels:
(a) the matched-FLOP family, (b) the iso-param / iso-FLOP trio."""
panels = [("Matched training FLOPs (~2.2e15)",
["fixed4x1", "rec2x2", "rec1x4", "rec1xdynamic"]),
("Same parameters (830k); FLOPs differ as labelled",
["fixed4x1", "rec4x4", "fixed4-long"])]
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.8))
for ax, (title, fams) in zip(axes, panels):
x = np.arange(len(fams))
w = 0.38
for off, key, lab, col in ((-w / 2, "id", "in-dist acc (k=1..8)",
"tab:blue"),
(w / 2, "k64", "acc at k=64 (OOD)",
"tab:red")):
means, stds = [], []
for f in fams:
if not evals[f]:
means.append(np.nan); stds.append(0); continue
vals = ([id_mean(e) for e in evals[f]] if key == "id"
else [e["acc_vs_k"]["64"] for e in evals[f]])
m, s = mean_std(vals)
means.append(m); stds.append(s)
ax.bar(x + off, means, w, yerr=stds, capsize=3, label=lab,
color=col, alpha=0.85)
ptxt = []
for f in fams:
p, fl = budget_of(evals[f]) if evals[f] else (0, 0)
ptxt.append(f"{SHORT[f]}\n"
f"{p/1000:.0f}k params\n{fl:.2e} FLOPs")
ax.set_xticks(x); ax.set_xticklabels(ptxt, fontsize=7)
ax.axhline(1 / 64, color="gray", ls=":", lw=1)
ax.set_ylim(0, 1.05); ax.set_title(title, fontsize=9)
ax.grid(alpha=0.3, axis="y")
axes[0].legend(fontsize=8, loc="upper left")
axes[0].set_ylabel("exact-match accuracy")
fig.suptitle("Headline results (mean ± std over seeds; dotted = chance)",
y=1.0)
fig.tight_layout(); fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
def plot_budget_map(evals, out):
"""Where each family sits in the (params, total training FLOPs) plane,
with the two matching axes drawn in."""
fig, ax = plt.subplots(figsize=(7.5, 5.5))
# hand-placed label offsets (points) so nothing overlaps
OFF = {"fixed4x1": (8, -14, "left"), "rec1x4": (0, -16, "center"),
"rec2x2": (10, -4, "left"), "rec1xdynamic": (8, 8, "left"),
"rec4x4": (-8, 10, "right"), "fixed4-long": (8, -14, "left"),
"rec1aligned": (8, -16, "left"),
"rec1m4": (8, 8, "left"), "rec1m4dense": (8, -16, "left")}
for f in FAMILIES:
if not evals[f]:
continue
p, fl = budget_of(evals[f])
ax.scatter(p, fl, s=90, zorder=3,
marker="s" if f != "rec1x4" and f.startswith("rec1")
else "o")
dx, dy, ha = OFF[f]
ax.annotate(LABELS[f], (p, fl), fontsize=7, ha=ha,
xytext=(dx, dy), textcoords="offset points")
ax.axvline(829696, color="gray", ls="--", lw=1)
ax.annotate("iso-parameter pair\n(fixed-4 vs rec-4x4)", (829696, 1.45e15),
fontsize=7, color="gray", ha="right")
ax.axhline(2.185e15, color="gray", ls=":", lw=1)
ax.annotate("matched-FLOP family (same per-step and total FLOPs)",
(2.6e5, 2.185e15), fontsize=7, color="gray",
xytext=(0, 7), textcoords="offset points", ha="center")
ax.axhline(8.72e15, color="gray", ls="-.", lw=1)
ax.annotate("iso-FLOP pair (rec-4x4 vs fixed-4-long)",
(5.0e5, 8.72e15), fontsize=7, color="gray",
xytext=(0, -13), textcoords="offset points", ha="center")
ax.set_xlim(1.8e5, 1.6e6)
ax.set_ylim(1.2e15, 1.1e16)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("parameters"); ax.set_ylabel("total training FLOPs")
ax.set_title("Experimental design: what is matched against what")
ax.grid(alpha=0.3, which="both"); fig.tight_layout()
fig.savefig(out, dpi=150); plt.close(fig)
def plot_schematic(out):
"""Side-by-side schematic of the two architectures."""
fig, axes = plt.subplots(1, 2, figsize=(10, 4.6))
for ax in axes:
ax.set_xlim(0, 4); ax.set_ylim(0, 6); ax.axis("off")
def box(ax, x, y, w, h, text, fc):
ax.add_patch(Rectangle((x, y), w, h, facecolor=fc, edgecolor="k",
alpha=0.85))
ax.text(x + w / 2, y + h / 2, text, ha="center", va="center",
fontsize=7.5)
def arrow(ax, x, y0, y1):
ax.add_patch(FancyArrowPatch((x, y0), (x, y1),
arrowstyle="-|>", mutation_scale=12,
color="k"))
ax = axes[0]
ax.set_title("fixed-depth transformer\n(4 distinct blocks, one pass)",
fontsize=9)
ys = [4.6, 3.5, 2.4, 1.3]
for i, y in enumerate(ys):
box(ax, 1.2, y, 1.6, 0.8, f"Block {i+1}\n(own weights)",
f"{0.25 + 0.15 * i}")
if i:
arrow(ax, 2.0, ys[i] + 0.8, ys[i - 1])
box(ax, 1.2, 0.2, 1.6, 0.6, "readout head", "lightyellow")
arrow(ax, 2.0, ys[-1], 0.8)
ax.text(0.15, 5.6, "input:\n[f(0..63), x, k]", fontsize=7)
ax = axes[1]
ax.set_title("recurrent transformer\n(L shared blocks, R unrolls)",
fontsize=9)
box(ax, 1.2, 3.2, 1.6, 0.8, "shared blocks\n(same weights)", "tab:orange")
box(ax, 1.2, 1.8, 1.6, 0.6, "readout head", "lightyellow")
arrow(ax, 2.0, 3.2, 2.4)
ax.annotate("", xy=(2.8, 3.65), xytext=(2.8, 3.35),
arrowprops=dict(arrowstyle="-|>", connectionstyle=
"arc3,rad=-1.8", color="tab:red"))
ax.text(3.35, 3.5, "loop\nR times\n(+step emb\neach pass)", fontsize=7,
color="tab:red", ha="center", va="center")
ax.text(0.15, 5.6, "input:\n[f(0..63), x, k]", fontsize=7)
ax.text(2.0, 0.9, "example: 1x4 = 1 shared block, R=4\n"
" 4x4 = 4 shared blocks, R=4\n"
"fixed-4 = 4 blocks, R=1",
fontsize=7, ha="center")
fig.tight_layout(); fig.savefig(out, dpi=150, bbox_inches="tight")
plt.close(fig)
# --------------------------------------------------------------------------
# report
# --------------------------------------------------------------------------
def worked_examples():
"""Two concrete examples rendered from a fixed RNG so the text matches
the data generator exactly."""
rng = np.random.default_rng(1234)
S = 8
f = rng.integers(0, S, size=S)
x = int(rng.integers(0, S))
chain = [x]
y = x
for t in range(1, 6):
y = int(f[y])
chain.append(y)
lines = [
"**Example 1 (small state space S=8, so it fits on the page).**",
"",
"The mapping f is drawn uniformly at random; with this seed it is",
"",
"```text",
"f(0)=2 f(1)=5 f(2)=2 f(3)=7 f(4)=0 f(5)=3 f(6)=6 f(7)=2",
"```",
"",
f"Start state x={x}, hop count k=3. Applying f step by step:",
"",
"```text",
f"f^1({x}) = f({x}) = {chain[1]}",
f"f^2({x}) = f({chain[1]}) = {chain[2]}",
f"f^3({x}) = f({chain[2]}) = {chain[3]} <- the required answer",
"```",
"",
f"So the answer is state **{chain[3]}**. Note how the chain entered a",
f"cycle ({chain[2]} -> {chain[3]} -> {chain[4]} -> ...): random finite",
"mappings always fall into cycles, which is exactly why a model that",
"only looks up f(x) fails once k grows past the cycle structure.",
"",
"The model never sees the chain. It sees one flat token sequence",
"(S+2 = 10 tokens here, 66 for S=64):",
"",
"```text",
"input : [2, 5, 2, 7, 0, 3, 6, 2, | 3, 3]",
" f(0) f(1) ... f(7) x k",
"target: the state id of f^k(x), predicted at the final position",
"```",
"",
"The position of each f-token encodes *which* state it is the image",
"of (state i is always at position i), so no 'arrow' tokens are",
"needed. The final two tokens are the start state x and the hop",
"count k; the model must combine all three pieces of information.",
"",
"**Example 2 (training-scale S=64).** Same construction, but only",
"the first few mapping entries are shown:",
"",
"```text",
f"f = [{', '.join(str(int(v)) for v in f64[:12])}, ...] (64 entries)",
f"x = {x64}, k = {k64} -> answer f^{k64}({x64}) = {ans64}",
"```",
"",
"Every training batch draws a *fresh* random f, a fresh x and a",
"fresh k ~ Uniform{{1..8}} for every example, so there is nothing to",
"memorise: the only way to score above chance is to learn the",
"algorithm itself.",
]
return "\n".join(lines)
def report(evals, rows):
def ms(fam, key_fn):
if not evals[fam]:
return "n/a"
vals = [key_fn(e) for e in evals[fam]]
m, s = mean_std(vals)
return f"{m:.3f}±{s:.3f}"
k64 = lambda e: e["acc_vs_k"]["64"]
o16 = lambda e: e["acc_vs_k"]["16"]
best = lambda e: max(e["acc_vs_iters"].values(),
key=lambda v: v["64"])["64"]
# state-size summary, computed from the eval JSONs
ss_lines = []
for fam in ("fixed4x1", "rec1x4"):
if not evals[fam]:
continue
cells = []
for Sp in ("16", "32", "64"):
vals = [np.mean([e["acc_vs_state_size"][Sp][str(k)]
for k in range(1, 9)]) for e in evals[fam]]
m, s = mean_std(vals)
cells.append(f"S'={Sp}: {m:.3f}±{s:.3f}")
ss_lines.append(f"- {LABELS[fam]}: " + ", ".join(cells))
n_seeds = {f: len(evals[f]) for f in FAMILIES}
# --- aligned-experiment results, computed from the eval JSONs ---
al_lines = ["*(stage-3 runs not finished yet - this section fills in",
"when rec1aligned eval JSONs appear in results/)*"]
al = evals["rec1aligned"]
if al:
def frontier(m, thr=0.9):
best = 0
for k in range(1, 17):
if m[k - 1] >= thr:
best = k
else:
break
return best
acc = np.array([[e["acc_vs_k"][str(k)] for k in range(1, 17)]
for e in al])
m = acc.mean(0)
f_al = frontier(m)
k1_al = float(m[0])
r1x4 = np.array([[e["acc_vs_k"][str(k)] for k in range(1, 17)]
for e in evals["rec1x4"]]).mean(0)
f_r14 = frontier(r1x4)
hi, lo = [], []
for e in al:
for r, cells in e["acc_vs_iters"].items():
for kk, v in cells.items():
(hi if int(kk) <= int(r) else lo).append(v)
stair = float(np.mean(hi) - np.mean(lo))
pred_a = f_al >= 8
pred_b = stair > 0.3
al_lines = [
f"- in-distribution accuracy (k=1..8 mean): "
f"{ms('rec1aligned', id_mean)}",
f"- capability frontier (last k with mean acc >= 0.9): "
f"**k={f_al}** (rec-1x4: k={f_r14}; rec-4x4 solves the full "
f"training range)",
f"- beyond the training range: k=16 {m[15]:.3f}, k=32 "
f"{np.mean([e['acc_vs_k']['32'] for e in al]):.3f}, k=64 "
f"{np.mean([e['acc_vs_k']['64'] for e in al]):.3f}",
f"- staircase check on the fixed-R sweep: mean accuracy where "
f"k <= R minus where k > R = {stair:+.3f} (large positive = "
f"the model really does one-unroll-per-hop)",
f"- even k=1 (a single unroll: read f(x) and stop) is unsolved "
f"at 25k steps: mean acc {k1_al:.3f} - the model has not even "
f"learned the easiest aligned case, so this is optimisation,",
f" not a capacity, failure",
f"- prediction (a) {('**supported**' if pred_a else '**not supported**')}",
f"- prediction (b) {('**supported**' if pred_b else '**not supported**')}",
]
long_path = "results/rec1aligned-long-seed0_eval.json"
if os.path.exists(long_path):
le = json.load(open(long_path))
lm = float(np.mean([le["acc_vs_k"][str(k)]
for k in range(1, 9)]))
verdict = (
"step-starved: the aligned objective does learn, just "
"slower" if lm > 0.5 else
"**a slow learner that plateaus**: it climbed 0.10 -> 0.235 "
"by 60k steps, then stayed flat for the last 40k (slope ~0), "
"so the aligned objective is roughly 4-10x slower to "
"optimise than fixed-R training and did not break out of "
"its second plateau by 100k. Why: with supervision only at "
"the end of the chain, the model faces a credit-assignment "
"problem across depths - it must learn to do one hop per "
"unroll AND to stop exactly at depth k, with no per-step "
"signal telling it either. Even the easiest case (k=1: one "
"unroll) stayed unsolved at 25k steps.")
al_lines += [
f"- **100k-step probe (1 seed, 4x budget)**: id acc "
f"{lm:.3f}, k=16 {le['acc_vs_k']['16']:.3f}, "
f"k=64 {le['acc_vs_k']['64']:.3f} -> {verdict}",
]
# --- v2 aligned variants (stage 5) ---
def variant_lines(fam, stair_margin=4):
ev = evals[fam]
if not ev:
return [f"*(stage-5 runs not finished: {fam})*"]
acc = np.array([[e["acc_vs_k"][str(k)] for k in range(1, 17)]
for e in ev])
m = acc.mean(0)
fm = frontier(m)
hi, lo = [], []
for e in ev:
for r, cells in e["acc_vs_iters"].items():
for kk, v in cells.items():
(hi if int(kk) <= int(r) - stair_margin else lo).append(v)
st = float(np.mean(hi) - np.mean(lo))
return [
f"- in-distribution (k=1..8 mean): {ms(fam, id_mean)}",
f"- frontier (last k with >=0.9 mean acc): **k={fm}**",
f"- k=16 {m[15]:.3f}, k=32 "
f"{np.mean([e['acc_vs_k']['32'] for e in ev]):.3f}, k=64 "
f"{np.mean([e['acc_vs_k']['64'] for e in ev]):.3f}",
f"- staircase check (acc high iff k <= R-{stair_margin}): "
f"{st:+.3f}",
]
al2_lines = (["**A / m4 (margin, end-of-chain loss):**"]
+ variant_lines("rec1m4")
+ ["", "**B / m4+dense (margin + stem + dense):**"]
+ variant_lines("rec1m4dense"))
if evals["rec1m4"] and evals["rec1m4dense"]:
p1 = np.mean([id_mean(e) for e in evals["rec1m4"]]) < 0.5
mB = float(np.array([[e["acc_vs_k"][str(k)] for k in range(1, 9)]
for e in evals["rec1m4dense"]]).mean())
mBm = np.array([[e["acc_vs_k"][str(k)] for k in range(1, 17)]
for e in evals["rec1m4dense"]]).mean(0)
p2 = mB >= 0.9
p3 = frontier(mBm) >= 12
al2_lines += [
f"- (P1) {('**supported**' if p1 else '**not supported**')}",
f"- (P2) {('**supported**' if p2 else '**not supported**')}",
f"- (P3) {('**supported**' if p3 else '**not supported**')}",
]
lines = [
"# Technical report: recurrent depth vs fixed depth on pointer chasing",
"",
"*(Everything below marked **observation** is measured from the runs in",
"`results/`. Statements about what transformers *can* compute in",
"principle are **theory** and cite prior work.)*",
"",
"## TL;DR",
"",
"We train six families of tiny transformers (3 seeds each, except",
"fixed-4-long: 2) on a synthetic multi-hop task: given a random",
"mapping f, a start state x and a hop count k, output f^k(x).",
"Training only ever uses k ≤ 8; evaluation uses k up to 64 with",
"freshly generated mappings. **Observation:** recurrent depth (a",
"weight-tied block applied R times) learns the trained task far",
"better than a fixed-depth transformer at matched parameters and",
"even at matched *total* training compute, but **no model in this",
"budget class generalises well to untrained hop counts** (best mean",
"acc ~0.27 at k=64 vs 1.0 in-distribution).",
"",
"## 1. The task",
"",
"Each example is a fresh, uniformly random function f on a state",
"space of size S=64, a uniformly random start state x, and a hop",
"count k. The correct answer is f applied k times to x. During",
"training k ~ Uniform{1..8}; at evaluation k ranges up to 64 (out of",
"distribution) and every mapping is newly generated, so nothing can",
"be memorised. Shortcut controls: f is uniform random (no exploitable",
"regular structure, cf. arXiv:2210.10749); the marginal distribution",
"of the answer is ~uniform for every k (verified: total-variation",
"< 0.02, no majority-class shortcut); k is the only token that",
"correlates with difficulty. A deterministic lookup solver scores",
"1.0 by construction and is re-run at every evaluation as a pipeline",
"integrity check.",
"",
worked_examples(),
"",
"A note on what 'length' means here: the input sequence is always",
"S+2 = 66 tokens regardless of k, so the OOD axis is **the value of",
"the hop-count token**, not the sequence length. Generalising from",
"k ≤ 8 to k = 64 therefore requires the model to apply its learned",
"procedure 8x more times than it ever saw during training.",
"",
"## 2. The models",
"",
"All models share one implementation (`ptr/models.py`): a",
"pre-LN transformer block (4 heads, d_ff = 4*d_model, GELU MLP,",
"bidirectional attention, learned positional embeddings) applied",
"**n_iters** times. Two knobs distinguish the families:",
"",
"- `depth` = number of distinct blocks in the stack. In the fixed",
" model each block has its own weights and is applied once.",
"- `n_iters` (R) = how many times the *same* stack is applied. Each",
" unroll adds a learned step embedding so the block can tell pass",
" t from pass t+1. At initialisation the blocks are the identity",
" (zero-initialised output projections), which keeps gradients",
" stable through long unroll chains (cf. arXiv:2604.07822).",
"",
"So 'fixed-4' = depth 4, R=1; 'rec-1x4' = one shared block unrolled",
"R=4 times; 'rec-4x4' = a 4-block stack unrolled R=4 times. The",
"input encoding, output head, optimizer and data pipeline are",
"identical everywhere (d_model=128, batch 256, AdamW lr=1e-3 chosen",
"by a 6-run sweep, cosine decay, 25k steps unless stated).",
"",
"",
"",
"### The six families and what is matched",
"",
"| family | blocks x unrolls | params | per-step block-applications | total training FLOPs | seeds |",
"|---|---|---|---|---|---|",
"| fixed-4 | 4 x 1 | 829,696 | 4 | 2.19e15 | 3 |",
"| rec-2x2 | 2 x 2 | 433,152 | 4 | 2.19e15 | 3 |",
"| rec-1x4 | 1 x 4 | 234,880 | 4 | 2.19e15 | 3 |",
"| rec-1x-dynamic | 1 x R~U{2..8} | 234,880 | ~5 avg | 2.73e15 | 3 |",
"| rec-4x4 | 4 x 4 | 829,696 | 16 | 8.68e15 | 3 |",
"| fixed-4-long | 4 x 1, 100k steps | 829,696 | 4 | 8.74e15 | 2 |",
"",
"Three comparison axes follow from this table:",
"",
"1. **Matched-FLOP family** (rows 1-3): every model executes exactly",
" 4 block-applications per training step and is trained for the",
" same number of steps, so total training FLOPs are identical",
" while parameters differ 4:2:1. Question: *does the loop",
" structure itself matter when compute is fixed?*",
"2. **Iso-parameter pair** (fixed-4 vs rec-4x4): identical parameter",
" counts, but rec-4x4 does 4x the per-step work. Question: *given",
" the same weights, does iterating them help?*",
"3. **Iso-FLOP pair** (rec-4x4 vs fixed-4-long): identical total",
" training FLOPs - fixed-4-long simply trains 4x longer. Question:",
" *can extra training compute substitute for recurrent depth?*",
"",
"",
"",
"## 3. What is evaluated",
"",
"Every evaluation cell uses freshly generated mappings (nothing",
"from training is reused) and 1000 examples per cell; accuracy is",
"exact match on the answer state (chance = 1/64 ≈ 0.016). The varied",
"parameters are:",
"",
"- **Chain length k ∈ 1..64** at the model's training-time unroll",
" count: the OOD length-generalisation curve.",
"- **Inference-time unroll count R ∈ 1..16**, crossed with",
" k ∈ {1,2,4,8,16,32,64}: 'more or fewer iterations than trained'.",
" For the fixed model, R > 1 re-applies its own block stack",
" (effectively deeper than trained) - also measured.",
"- **State-space size S' ∈ {16, 32, 64}**: evaluation on subsets of",
" the trained vocabulary/positions (larger S' would need new",
" embedding rows the model never had; noted as a limitation).",
"- **Training seed** (3 per family) and **training compute** (the",
" three budget axes above).",
"",
"## 4. Results",
"",
"### 4.1 Headline (at training-time unrolls)",
"",
"| family | acc k=1..8 | acc k=16 | acc k=32 | acc k=64 | best acc k=64 over R |",
"|---|---|---|---|---|---|",
]
for fam in FAMILIES:
lines.append(
f"| {LABELS[fam]} | {ms(fam, id_mean)} | {ms(fam, o16)} | "
f"{ms(fam, lambda e: e['acc_vs_k']['32'])} | "
f"{ms(fam, k64)} | {ms(fam, best)} |")
lines += [
"",
"",
"",
"Reading the bar chart: blue = how well the model learned the task",
"it was trained on; red = how far that skill stretches to 8x longer",
"chains. The gap between them is the length-generalisation deficit,",
"and it is large for every family.",
"",
"### 4.2 Accuracy vs chain length",
"",
"",
"",
"All curves bend downward after the training range (k ≤ 8, dashed",
"line). **Observation:** no family learned a clean",
"length-generalising algorithm in this budget class.",
"",
"### 4.3 Accuracy vs inference-time unrolls (the k x R grid)",
"",
"Two different quantities are crossed in this figure, and keeping",
"them apart is the whole point:",
"",
"- **k — task depth.** How many times f must be applied to answer",
" the question. It is part of the *input* (the k token) and is",
" fixed along each line of the figure.",
"- **R — the recurrent loop parameter.** How many times the model",
" *iterates its own block stack* before reading out an answer. It",
" is a free choice made at inference time: the trained model is",
" frozen and we simply run it for more or fewer unrolls. The dashed",
" vertical line marks the R each family was trained with.",
"",
"**What is being tested.** If a recurrent model learned the",
"abstraction 'apply one unroll per hop', accuracy should rise with R",
"and peak near R = k. If instead it learned a fixed amount of",
"computation (whatever R it was trained with), accuracy should peak",
"at the trained R regardless of k and fall off on both sides.",
"",
"",
"",
"**Observations.**",
"",
"1. The R-accuracy curves are *sharp*, not flat. For rec-1x4 at k=4,",
" mean accuracy jumps from 0.06 (R=3) to 0.94 (R=4) and falls back",
" to 0.35 (R=8): the model tuned itself to its trained unroll",
" count, not to the hop count in the input.",
"2. rec-4x4 peaks at R=4 for both k=4 (1.00) and k=8 (1.00): with 16",
" serial block-applications it solves the whole training range at",
" its trained R, and *loses* accuracy when given more unrolls",
" (k=8: 0.73 at R=8, 0.39 at R=16) - a mild 'overthinking' regime.",
"3. The fixed model is the opposite: its best R is 1 (0.14/0.20 at",
" k=4/8) and re-applying its own stack at R>1 only hurts - extra",
" depth without training the depth is useless.",
"4. Consequence: the length-generalisation deficit starts *inside*",
" the training range - see 4.3b.",
"",
"### 4.3b Within (and just beyond) the training range: accuracy per hop count",
"",
"The natural question - 'recurrent depth 4 should get hop count 4,",
"right?' - is answered by per-k accuracy inside k=1..16 (1000 fresh",
"mappings per cell, mean over seeds):",
"",
*perk_rows(evals, ["fixed4x1", "rec1x4", "rec2x2", "rec1xdynamic",
"rec4x4", "fixed4-long", "rec1aligned", "rec1m4",
"rec1m4dense"], 1, 8),
"",
*perk_rows(evals, ["fixed4x1", "rec1x4", "rec2x2", "rec1xdynamic",
"rec4x4", "fixed4-long", "rec1aligned", "rec1m4",
"rec1m4dense"], 9, 16),
"",
"",
"",
"**Observations.**",
"",
"1. k=1..3 are solved by every family (1.00): reading f(x) and very",
" short chains are easy.",
"2. The capability frontier scales with serial block-applications:",
" fixed-4 (4 applications) solves k<=3; rec-1x-dynamic (up to 8)",
" solves k<=7 (0.96 at k=7); rec-4x4 (16) solves the entire",
" training range k<=8 at 1.000; rec-1x4 (4 unrolls of 1 block)",
" manages k=4 at 0.95 and decays to 0.37 by k=8. So recurrence",
" with enough unrolls does buy hop count - roughly one hop per",
" block-application, plus a constant.",
"3. But the frontier stops at the training-data limit (k<=8): even",
" rec-4x4, with twice the serial depth k=8 needs, does not extend",
" beyond it (0.20-0.28 at k=9..16). Capacity alone does not give",
" length generalisation when the data never demanded it.",
"4. rec-1x4's k=8 accuracy (0.37) is low not because 8 hops exceed",
" recurrence but because it was trained with R=4 and cannot turn",
" 'k=8' into 'unroll 8 times' (at R=8 it scores only 0.14). This",
" is exactly the gap the aligned-unroll experiment (4.7) targets.",
"",
"### 4.4 Accuracy vs state-space size",
"",
"**S'** (S-prime) is the state-space size used *at evaluation time*.",
"Training always used S=64: the state-embedding table has 64 rows",
"and the positional table 66 positions. To ask whether the learned",
"procedure depends on the specific vocabulary, we evaluate on",
"smaller state spaces S' in {16, 32}: mappings are generated over",
"states {0..S'-1} only, which reuses trained embedding rows (no new",
"ones are introduced). S' > 64 is impossible without adding",
"embedding rows the model never trained - a limitation, not a",
"finding.",
"",
"",
"",
"In-distribution accuracy on smaller state spaces (mean over",
"k = 1..8):",
"",
*ss_lines,
"",
"**Observation:** accuracy degrades sharply and non-monotonically:",
"fixed-4 drops from 0.48 at S'=64 to 0.12 at S'=32 and 0.22 at",
"S'=16; rec-1x4 from 0.76 to 0.10 and 0.22. Relative to chance",
"(1/S'), smaller state spaces are far harder (roughly 3-4x chance,",
"vs ~30x chance at S'=64), so the learned solution is strongly",
"tied to the trained state space - it is not a clean, vocabulary-",
"independent algorithm. (The S'=16 > S'=32 inversion is unexplained;",
"random functions on smaller domains enter cycles sooner, which may",
"change the task's structure.)",
"",
"### 4.5 Training dynamics",
"",
"",
"",
"**Observation:** rec-4x4 reaches ~1.0 in-distribution by ~7.5k",
"steps; fixed-4 plateaus near ~0.47 and is still softening at 25k;",
"fixed-4-long reaches 0.72 by 100k. The learning *rate* differs",
"dramatically even when parameters are identical.",
"",
"### 4.6 Full table (per seed)",
"",
"```csv",
*rows,
"```",
"",
"### 4.7 Aligned unrolls: training with R = k (one unroll per hop)",
"",
"Motivated by 4.3b: the models implicitly tuned themselves to their",
"trained unroll count, and rec-1x4 fails k=8 mainly because it",
"cannot turn the k token into 'unroll 8 times'. The aligned variant",
"removes that gap *by construction*: during training every batch",
"uses a single k ~ U{1..8} and runs exactly R = k unrolls, so",
"'one unroll per hop' is the only consistent solution. Everything",
"else (architecture, data, optimizer, lr, 25k steps) matches",
"rec-1x4; 3 seeds. At evaluation the model reads out after R = k",
"unrolls for every k - including k > 8, which is the real test.",
"",
"*Predictions registered before the runs:* (a) if alignment is the",
"missing ingredient, rec-1x-aligned should reach ~1.0 across the",
"full k=1..8 range (rec-1x4 manages only 0.37 at k=8) and extend",
"beyond k=8 better than the other 1-block families, since R=k is",
"in-distribution for every k; (b) its fixed-R sweep should look",
"like a staircase (accuracy high iff k <= R) rather than peaking",
"at one R.",
"",
"*Results:*",
"",
*al_lines,
"",
"### 4.8 v2 aligned variants: margin R = k+4, stem layer, dense supervision",
"",
"The 4.7 diagnosis says the R=k objective fails because of (i) exact",
"halting at depth k, and (ii) no per-step signal. The v2 variants",
"(user design) target both, separately:",
"",
"- **A / m4** (3 seeds): R = k + 4 with end-of-chain supervision",
" only. The margin removes exact halting - the model needs 'at",
" least k unrolls', and can idle for the last 4. Everything else",
" identical to rec-1x4.",
"- **B / m4+dense** (3 seeds): R = k + 4, one untied stem block",
" before the loop, and dense supervision - every unroll t gets an",
" auxiliary head predicting f^{min(t,k)}(x), i.e. each unroll does",
" its own hop and then holds. This is the per-step signal the R=k",
" run lacked, and it acts as a curriculum: unroll 1 only has to",
" learn the lookup, later unrolls inherit it.",
"",
"At evaluation both read out at R = k + 4 for every k - the trained",
"inference mode - including k > 8.",
"",
"*Predictions registered before the runs:*",
"",
"- (P1) margin alone does **not** rescue learning (the credit-",
" assignment problem, not exact halting, was the blocker); it",
" should learn faster than R=k but plateau low.",
"- (P2) dense + stem **does** rescue: >= 0.9 mean accuracy across",
" k = 1..8, and a fixed-R sweep showing a staircase (accuracy high",
" iff k <= R - 4) rather than a peak at one R.",
"- (P3) if the fix is real, B extends beyond k=8 better than every",
" existing family (R = k+4 is its trained mode at any k), bounded",
" by OOD step embeddings (unroll indices > 11 were never trained).",
"",
"*Results:*",
"",
*al2_lines,
"",
"## 5. Conclusions",
"",
"1. **Recurrent depth buys learnability that neither parameters nor",
" extra training compute provide** *(observation)*: at identical",
" parameter counts rec-4x4 reaches 1.000 in-distribution vs 0.487",
" for fixed-4, and fixed-4-long still only 0.716 after 4x the",
" total training FLOPs.",
"2. **The loop structure itself matters at fixed compute**",
" *(observation, with caveats)*: rec-1x4 learns the trained task",
" better than fixed-4 (0.761 vs 0.487) with 4x fewer parameters",
" and identical training FLOPs - but seed variance is large",
" (0.59-0.95), so this needs more seeds to be a firm claim.",
"3. **Length generalisation to untrained hop counts is weak",
" everywhere** *(observation)*: best mean k=64 accuracy ~0.27",
" (rec-4x4), vs 1.0 in-distribution. This is consistent with",
" **theory**: constant-depth transformers sit in TC^0/AC^0 and",
" cannot compute iterated composition f^k for unbounded k without",
" growing depth or serial steps (Merrill & Sabharwal 2305.15401;",
" Li et al. 2402.12875) - but our budget class may simply be too",
" small to realise the achievable gains.",
"4. **Inference-time unrolls are tuned to the trained R, not to the",
" task's hop count** *(observation)*: accuracy peaks sharply at",
" the training-time R (rec-1x4 at k=4: 0.06 at R=3 -> 0.94 at",
" R=4) and more unrolls usually hurt (rec-4x4 at k=8: 1.00 at",
" R=4 -> 0.39 at R=16). The aligned experiment (4.7) tests",
" whether training with R=k removes this coupling.",
"5. **Single-seed comparisons of OOD generalisation are unreliable",
" at this scale** *(observation)*: rec-4x4's k=64 accuracy across",
" seeds is 0.18 / 0.18 / 0.39.",
"",
"### How could the OOD deficit be fixed? (discussion)",
"",
"Generalisation is the whole point, so it is worth being explicit",
"about the levers - which this study tests and which it does not:",
"",
"1. **Aligned / per-iteration supervision** - make the training",
" computation match the task's serial structure: one unroll per",
" hop (tested here, section 4.7), or supervising every unroll",
" toward f^t(x) (Fan et al. 2409.15647, untested here).",
"2. **Adaptive halting** (ACT, Universal Transformer) - let the",
" model decide how many unrolls to run per input, so R stops",
" being a fixed hyperparameter. Untested here.",
"3. **Externalise the serial steps** - chain-of-thought/scratchpad:",
" theory says serial *tokens* substitute for serial depth (Li et",
" al. 2402.12875) and can grow with k at inference. Untested.",
"4. **Curriculum and data coverage** - the frontier in 4.3b tracks",
" the training-data limit (k<=8) almost exactly; training on",
" longer chains or a growing-k curriculum directly moves it.",
" Untested here.",
"5. **Scale** - the frontier also scales with serial depth (4.3b),",
" so more parameters *and* more unrolls should extend it. Only",
" partially tested (fixed-4-long).",
"",
"## 6. Negative results, failures and caveats",
"",
"- At 3k steps (lr sweep) every model was near chance (~5-6%): the",
" algorithm-learning phase is far slower than tiny-set overfitting",
" (which reached 100% in ~100 steps).",
"- fixed-4's in-distribution accuracy may not be fully converged at",
" 25k steps (its curve was still softening); fixed-4-long at 100k",
" partially addresses this but 0.72 < 1.0.",
"- Evaluation state sizes are limited to S' ≤ 64 because the",
" vocabulary/positional tables are fixed at training time.",
"- The rec-1x-dynamic family trains at ~5 average block-",
" applications per step (R ~ U{2..8}), slightly above the matched",
" 4 - flagged rather than hidden.",
"- The aligned-unroll variant (R=k) is a negative result at this",
" budget: ~0.10 in-distribution across 3 seeds at 25k steps, and a",
" 100k-step probe still only reaches 0.24 - see 4.7.",
"- Two duplicate jobs created by interrupted tool calls were",
" cancelled (~$0.30 wasted).",
"- Dependency pin: trackio 0.42.0 requires huggingface-hub<2.0;",
" resolved with huggingface_hub==1.33.0.",
"",
"## 7. What remains",
"",
"- More seeds to tighten the OOD family means (esp. rec-4x4).",
"- Aligned follow-ups: deeper aligned stacks (rec-4x-aligned),",
" overshoot training (R = k + margin), and training k up to 16 to",
" see whether the frontier tracks the data limit again.",
"- Per-iteration supervision toward f^t(x) at every unroll",
" (Fan et al. 2409.15647) - related to alignment but gives dense",
" per-step signal; untested here.",
"- Adaptive halting, curriculum on k, and larger-scale runs",
" (see the discussion in section 5).",
"",
"## 8. Reproduce",
"",
"See README.md. `run_all.py` drives lrsweep/stage1/stage2/eval;",
"`evaluate.py` produces the per-checkpoint JSONs; `make_report.py`",
"generates this report and every figure. Live dashboards:",
"eac123/ptr-recurrent-depth-trackio.",
]
return "\n".join(lines)
def table(evals, out_csv):
rows = ["family,seed,params_total,train_flops_total,acc_k1_8,acc_k16,"
"acc_k32,acc_k64,best_acc_k64_over_iters,best_R_k64"]
for fam in FAMILIES:
for e in evals[fam]:
a = e["acc_vs_k"]
idm = np.mean([a[str(k)] for k in range(1, 9)])
it = e["acc_vs_iters"]
best_r = max(it, key=lambda r: it[r]["64"])
rows.append(f"{fam},{e['config']['seed']},{e['budget']['params_total']},"
f"{e['budget']['train_flops_total']:.3e},{idm:.4f},"
f"{a['16']:.4f},{a['32']:.4f},{a['64']:.4f},"
f"{it[best_r]['64']:.4f},{best_r}")
open(out_csv, "w").write("\n".join(rows) + "\n")
return rows
def upload(local, path_in_repo):
from huggingface_hub import HfApi
HfApi().upload_file(path_or_fileobj=local, path_in_repo=path_in_repo,
repo_id=REPO, repo_type="model")
if __name__ == "__main__":
# example 2 needs an S=64 sample with a fixed seed
_rng = np.random.default_rng(2026)
f64 = _rng.integers(0, 64, size=64)
x64 = int(_rng.integers(0, 64))
k64 = int(_rng.integers(1, 9))
_y = x64
for _ in range(k64):
_y = int(f64[_y])
ans64 = _y
globals()["f64"], globals()["x64"] = f64, x64
globals()["k64"], globals()["ans64"] = k64, ans64
os.makedirs(PLOTS, exist_ok=True)
evals = pull()
n = {f: len(evals[f]) for f in FAMILIES}
print("[report] evals per family:", n)
plot_schematic(f"{PLOTS}/schematic.png")
plot_budget_map(evals, f"{PLOTS}/budget_map.png")
plot_headline_bars(evals, f"{PLOTS}/headline_bars.png")
plot_acc_vs_k(evals, f"{PLOTS}/acc_vs_k.png")
plot_perk_zoom(evals, f"{PLOTS}/perk_zoom.png")
plot_acc_vs_iters(evals, f"{PLOTS}/acc_vs_iters.png")
plot_state_size(evals, f"{PLOTS}/acc_vs_state_size.png")
plot_training(evals, f"{PLOTS}/training_curves.png")
rows = table(evals, "results_table.csv")
open("REPORT.md", "w").write(report(evals, rows))
artifacts = ["schematic", "budget_map", "headline_bars", "acc_vs_k",
"perk_zoom", "acc_vs_iters", "acc_vs_state_size",
"training_curves"]
for a in artifacts:
upload(f"{PLOTS}/{a}.png", f"plots/{a}.png")
print("[report] uploaded", f"plots/{a}.png")
upload("results_table.csv", "results/results_table.csv")
upload("REPORT.md", "REPORT.md")
print("[report] uploaded results_table.csv, REPORT.md") |