GOVINDFROM commited on
Commit
8c85090
·
verified ·
1 Parent(s): ae02605

Recurrent masked diffusion: results, code, card

Browse files
README.md CHANGED
@@ -35,6 +35,7 @@ digit relabelling. Metric: board accuracy, all 81 cells correct.
35
  | greedy MRV | – | – | 5.90% |
36
  | autoregressive prefix-LM | 2.4M | – | 0.30% |
37
  | direct recurrent classifier* | 212k | 10.5% | 31.00% |
 
38
  | feed-forward diffusion, MDLM | 37.9M | 4.40% | 88.15% |
39
  | feed-forward diffusion, schedule-matched | 37.9M | 40.10% | 88.90% |
40
  | 32 distinct layers, no weight sharing | 6.34M | 70.20% | 94.50% |
@@ -49,12 +50,23 @@ evidence against their method**, and it is not used as a comparison.
49
 
50
  ## Ablations
51
 
52
- | ingredient | gain |
53
- |---|---|
54
- | recurrence vs feed-forward | +10.80 |
55
- | weight sharing vs 32 distinct layers (matched depth) | +3.75 |
56
- | schedule matching | +3.15 |
57
- | deep supervision | +1.95 |
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  ## Recurrence extrapolates past its training depth
60
 
 
35
  | greedy MRV | – | – | 5.90% |
36
  | autoregressive prefix-LM | 2.4M | – | 0.30% |
37
  | direct recurrent classifier* | 212k | 10.5% | 31.00% |
38
+ | recurrent, no input injection | 212k | – | 94.10% |
39
  | feed-forward diffusion, MDLM | 37.9M | 4.40% | 88.15% |
40
  | feed-forward diffusion, schedule-matched | 37.9M | 40.10% | 88.90% |
41
  | 32 distinct layers, no weight sharing | 6.34M | 70.20% | 94.50% |
 
50
 
51
  ## Ablations
52
 
53
+ Built as a ladder, each step changing exactly one thing:
54
+
55
+ | step | board | gain |
56
+ |---|---|---|
57
+ | feed-forward, 12 layers, 37.9M | 88.90% | – |
58
+ | feed-forward, **32 layers**, 6.34M | 88.95% | **+0.05** |
59
+ | + weight sharing (injection **off**), 212k | 94.10% | **+5.15** |
60
+ | + input injection | **99.70%** | **+5.60** |
61
+
62
+ **Depth alone is worth nothing** — 12→32 layers gains 0.05 points. Sharing one
63
+ block across 32 applications gains 5.15 at 30× fewer parameters; re-supplying the
64
+ input each application gains a further 5.60. The weight-sharing comparison holds
65
+ injection **off on both sides**, so the effects are separated rather than
66
+ confounded.
67
+
68
+ Removing either from the full method: deep supervision **−1.95**, schedule
69
+ matching **−3.15**.
70
 
71
  ## Recurrence extrapolates past its training depth
72
 
results/inj-deep32L-s1.json ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "args": {
3
+ "mode": "full",
4
+ "all_cells": false,
5
+ "objective": "diffusion",
6
+ "K": 8,
7
+ "lam": 1.0,
8
+ "pe": "ape",
9
+ "recurrent": false,
10
+ "R": 32,
11
+ "no_deep_sup": false,
12
+ "R_infer": 0,
13
+ "root": "data/sudoku",
14
+ "hard": "/scratch/zt1/project/msml612/user/govind02/pcmd/data/sudoku_hard",
15
+ "augment": true,
16
+ "d": 128,
17
+ "layers": 32,
18
+ "heads": 4,
19
+ "bs": 64,
20
+ "steps": 110000,
21
+ "lr": 0.0003,
22
+ "warmup": 3000,
23
+ "seed": 1,
24
+ "eval_bs": 200,
25
+ "ckpt_every": 8000,
26
+ "out": "/scratch/zt1/project/msml612/user/govind02/pcmd/runs/inj-deep32L-s1"
27
+ },
28
+ "decode": {
29
+ "fixedK_1": {
30
+ "board": 0.062,
31
+ "cell": 0.7915740446702657,
32
+ "valid_sudoku": 0.062,
33
+ "clues_kept": 1.0,
34
+ "nfe": 1.0
35
+ },
36
+ "fixedK_2": {
37
+ "board": 0.436,
38
+ "cell": 0.858685296919657,
39
+ "valid_sudoku": 0.436,
40
+ "clues_kept": 1.0,
41
+ "nfe": 2.0
42
+ },
43
+ "fixedK_4": {
44
+ "board": 0.63,
45
+ "cell": 0.8979923079637274,
46
+ "valid_sudoku": 0.63,
47
+ "clues_kept": 1.0,
48
+ "nfe": 4.0
49
+ },
50
+ "fixedK_8": {
51
+ "board": 0.735,
52
+ "cell": 0.9136762993542924,
53
+ "valid_sudoku": 0.735,
54
+ "clues_kept": 1.0,
55
+ "nfe": 8.0
56
+ },
57
+ "fixedK_16": {
58
+ "board": 0.77,
59
+ "cell": 0.9197452454041848,
60
+ "valid_sudoku": 0.77,
61
+ "clues_kept": 1.0,
62
+ "nfe": 16.0
63
+ },
64
+ "fixedK_32": {
65
+ "board": 0.797,
66
+ "cell": 0.9209802053561977,
67
+ "valid_sudoku": 0.797,
68
+ "clues_kept": 1.0,
69
+ "nfe": 32.0
70
+ },
71
+ "fixedK_61": {
72
+ "board": 0.817,
73
+ "cell": 0.9257083377439046,
74
+ "valid_sudoku": 0.817,
75
+ "clues_kept": 1.0,
76
+ "nfe": 59.4
77
+ },
78
+ "entbudget_0.01": {
79
+ "board": 0.834,
80
+ "cell": 0.9320419180692283,
81
+ "valid_sudoku": 0.834,
82
+ "clues_kept": 1.0,
83
+ "nfe": 31.4
84
+ },
85
+ "entbudget_0.05": {
86
+ "board": 0.828,
87
+ "cell": 0.9335767968667302,
88
+ "valid_sudoku": 0.828,
89
+ "clues_kept": 1.0,
90
+ "nfe": 23.8
91
+ },
92
+ "entbudget_0.2": {
93
+ "board": 0.807,
94
+ "cell": 0.9329240323206661,
95
+ "valid_sudoku": 0.807,
96
+ "clues_kept": 1.0,
97
+ "nfe": 18.0
98
+ },
99
+ "entbudget_0.5": {
100
+ "board": 0.711,
101
+ "cell": 0.9230267104195335,
102
+ "valid_sudoku": 0.711,
103
+ "clues_kept": 1.0,
104
+ "nfe": 12.0
105
+ },
106
+ "entbudget_1.0": {
107
+ "board": 0.609,
108
+ "cell": 0.898168730814015,
109
+ "valid_sudoku": 0.609,
110
+ "clues_kept": 1.0,
111
+ "nfe": 8.0
112
+ },
113
+ "entbudget_2.0": {
114
+ "board": 0.41,
115
+ "cell": 0.872622702092375,
116
+ "valid_sudoku": 0.41,
117
+ "clues_kept": 1.0,
118
+ "nfe": 5.8
119
+ },
120
+ "conf_0.9": {
121
+ "board": 0.432,
122
+ "cell": 0.8763452242334427,
123
+ "valid_sudoku": 0.432,
124
+ "clues_kept": 1.0,
125
+ "nfe": 10.8
126
+ },
127
+ "conf_0.99": {
128
+ "board": 0.789,
129
+ "cell": 0.9322006986344872,
130
+ "valid_sudoku": 0.789,
131
+ "clues_kept": 1.0,
132
+ "nfe": 22.0
133
+ },
134
+ "conf_0.999": {
135
+ "board": 0.827,
136
+ "cell": 0.9297484210154899,
137
+ "valid_sudoku": 0.827,
138
+ "clues_kept": 1.0,
139
+ "nfe": 29.8
140
+ }
141
+ },
142
+ "best_board": 0.834
143
+ }
results/inj-noinject-s0.json ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "args": {
3
+ "mode": "full",
4
+ "all_cells": false,
5
+ "objective": "diffusion",
6
+ "K": 8,
7
+ "lam": 1.0,
8
+ "pe": "ape",
9
+ "recurrent": true,
10
+ "R": 32,
11
+ "no_inject": true,
12
+ "no_deep_sup": false,
13
+ "R_infer": 64,
14
+ "root": "data/sudoku",
15
+ "hard": "/scratch/zt1/project/msml612/user/govind02/pcmd/data/sudoku_hard",
16
+ "augment": true,
17
+ "d": 128,
18
+ "layers": 1,
19
+ "heads": 4,
20
+ "bs": 64,
21
+ "steps": 110000,
22
+ "lr": 0.0003,
23
+ "warmup": 3000,
24
+ "seed": 0,
25
+ "eval_bs": 200,
26
+ "ckpt_every": 8000,
27
+ "out": "/scratch/zt1/project/msml612/user/govind02/pcmd/runs/inj-noinject-s0"
28
+ },
29
+ "decode": {
30
+ "fixedK_1": {
31
+ "board": 0.82,
32
+ "cell": 0.9408101337285205,
33
+ "valid_sudoku": 0.82,
34
+ "clues_kept": 1.0,
35
+ "nfe": 1.0
36
+ },
37
+ "fixedK_2": {
38
+ "board": 0.879,
39
+ "cell": 0.9473377791891606,
40
+ "valid_sudoku": 0.879,
41
+ "clues_kept": 1.0,
42
+ "nfe": 2.0
43
+ },
44
+ "fixedK_4": {
45
+ "board": 0.9,
46
+ "cell": 0.9561236371334815,
47
+ "valid_sudoku": 0.9,
48
+ "clues_kept": 1.0,
49
+ "nfe": 4.0
50
+ },
51
+ "fixedK_8": {
52
+ "board": 0.922,
53
+ "cell": 0.9624572174588053,
54
+ "valid_sudoku": 0.922,
55
+ "clues_kept": 1.0,
56
+ "nfe": 8.0
57
+ },
58
+ "fixedK_16": {
59
+ "board": 0.94,
60
+ "cell": 0.9721428319395928,
61
+ "valid_sudoku": 0.94,
62
+ "clues_kept": 1.0,
63
+ "nfe": 16.0
64
+ },
65
+ "fixedK_32": {
66
+ "board": 0.936,
67
+ "cell": 0.9696023428954518,
68
+ "valid_sudoku": 0.936,
69
+ "clues_kept": 1.0,
70
+ "nfe": 32.0
71
+ },
72
+ "fixedK_61": {
73
+ "board": 0.944,
74
+ "cell": 0.9756007198052292,
75
+ "valid_sudoku": 0.944,
76
+ "clues_kept": 1.0,
77
+ "nfe": 59.4
78
+ },
79
+ "entbudget_0.01": {
80
+ "board": 0.949,
81
+ "cell": 0.9752125895345966,
82
+ "valid_sudoku": 0.949,
83
+ "clues_kept": 1.0,
84
+ "nfe": 45.8
85
+ },
86
+ "entbudget_0.05": {
87
+ "board": 0.952,
88
+ "cell": 0.9800289333474471,
89
+ "valid_sudoku": 0.952,
90
+ "clues_kept": 1.0,
91
+ "nfe": 40.0
92
+ },
93
+ "entbudget_0.2": {
94
+ "board": 0.944,
95
+ "cell": 0.9779118591439964,
96
+ "valid_sudoku": 0.944,
97
+ "clues_kept": 1.0,
98
+ "nfe": 26.6
99
+ },
100
+ "entbudget_0.5": {
101
+ "board": 0.927,
102
+ "cell": 0.9772590945979324,
103
+ "valid_sudoku": 0.927,
104
+ "clues_kept": 1.0,
105
+ "nfe": 23.6
106
+ },
107
+ "entbudget_1.0": {
108
+ "board": 0.888,
109
+ "cell": 0.9686320172188702,
110
+ "valid_sudoku": 0.888,
111
+ "clues_kept": 1.0,
112
+ "nfe": 18.2
113
+ },
114
+ "entbudget_2.0": {
115
+ "board": 0.858,
116
+ "cell": 0.9641332345365372,
117
+ "valid_sudoku": 0.858,
118
+ "clues_kept": 1.0,
119
+ "nfe": 10.0
120
+ },
121
+ "conf_0.9": {
122
+ "board": 0.886,
123
+ "cell": 0.9659856744645566,
124
+ "valid_sudoku": 0.886,
125
+ "clues_kept": 1.0,
126
+ "nfe": 22.6
127
+ },
128
+ "conf_0.99": {
129
+ "board": 0.934,
130
+ "cell": 0.9750714512543665,
131
+ "valid_sudoku": 0.934,
132
+ "clues_kept": 1.0,
133
+ "nfe": 35.4
134
+ },
135
+ "conf_0.999": {
136
+ "board": 0.946,
137
+ "cell": 0.9762358420662645,
138
+ "valid_sudoku": 0.946,
139
+ "clues_kept": 1.0,
140
+ "nfe": 39.4
141
+ }
142
+ },
143
+ "best_board": 0.952
144
+ }
results/inj-noinject-s1.json ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "args": {
3
+ "mode": "full",
4
+ "all_cells": false,
5
+ "objective": "diffusion",
6
+ "K": 8,
7
+ "lam": 1.0,
8
+ "pe": "ape",
9
+ "recurrent": true,
10
+ "R": 32,
11
+ "no_inject": true,
12
+ "no_deep_sup": false,
13
+ "R_infer": 64,
14
+ "root": "data/sudoku",
15
+ "hard": "/scratch/zt1/project/msml612/user/govind02/pcmd/data/sudoku_hard",
16
+ "augment": true,
17
+ "d": 128,
18
+ "layers": 1,
19
+ "heads": 4,
20
+ "bs": 64,
21
+ "steps": 110000,
22
+ "lr": 0.0003,
23
+ "warmup": 3000,
24
+ "seed": 1,
25
+ "eval_bs": 200,
26
+ "ckpt_every": 8000,
27
+ "out": "/scratch/zt1/project/msml612/user/govind02/pcmd/runs/inj-noinject-s1"
28
+ },
29
+ "decode": {
30
+ "fixedK_1": {
31
+ "board": 0.683,
32
+ "cell": 0.9332063088811263,
33
+ "valid_sudoku": 0.683,
34
+ "clues_kept": 1.0,
35
+ "nfe": 1.0
36
+ },
37
+ "fixedK_2": {
38
+ "board": 0.822,
39
+ "cell": 0.9432094844924315,
40
+ "valid_sudoku": 0.822,
41
+ "clues_kept": 1.0,
42
+ "nfe": 2.0
43
+ },
44
+ "fixedK_4": {
45
+ "board": 0.871,
46
+ "cell": 0.9562471331286828,
47
+ "valid_sudoku": 0.871,
48
+ "clues_kept": 1.0,
49
+ "nfe": 4.0
50
+ },
51
+ "fixedK_8": {
52
+ "board": 0.884,
53
+ "cell": 0.9612046152217635,
54
+ "valid_sudoku": 0.884,
55
+ "clues_kept": 1.0,
56
+ "nfe": 8.0
57
+ },
58
+ "fixedK_16": {
59
+ "board": 0.91,
60
+ "cell": 0.9646977876574574,
61
+ "valid_sudoku": 0.91,
62
+ "clues_kept": 1.0,
63
+ "nfe": 16.0
64
+ },
65
+ "fixedK_32": {
66
+ "board": 0.928,
67
+ "cell": 0.9728485233407431,
68
+ "valid_sudoku": 0.928,
69
+ "clues_kept": 1.0,
70
+ "nfe": 32.0
71
+ },
72
+ "fixedK_61": {
73
+ "board": 0.925,
74
+ "cell": 0.9684732366536114,
75
+ "valid_sudoku": 0.925,
76
+ "clues_kept": 1.0,
77
+ "nfe": 59.4
78
+ },
79
+ "entbudget_0.01": {
80
+ "board": 0.927,
81
+ "cell": 0.9681733178081225,
82
+ "valid_sudoku": 0.927,
83
+ "clues_kept": 1.0,
84
+ "nfe": 41.4
85
+ },
86
+ "entbudget_0.05": {
87
+ "board": 0.93,
88
+ "cell": 0.9708902297025511,
89
+ "valid_sudoku": 0.93,
90
+ "clues_kept": 1.0,
91
+ "nfe": 32.0
92
+ },
93
+ "entbudget_0.2": {
94
+ "board": 0.927,
95
+ "cell": 0.9727779542006281,
96
+ "valid_sudoku": 0.927,
97
+ "clues_kept": 1.0,
98
+ "nfe": 25.4
99
+ },
100
+ "entbudget_0.5": {
101
+ "board": 0.904,
102
+ "cell": 0.9671853498465122,
103
+ "valid_sudoku": 0.904,
104
+ "clues_kept": 1.0,
105
+ "nfe": 16.0
106
+ },
107
+ "entbudget_1.0": {
108
+ "board": 0.859,
109
+ "cell": 0.961028192371476,
110
+ "valid_sudoku": 0.859,
111
+ "clues_kept": 1.0,
112
+ "nfe": 11.6
113
+ },
114
+ "entbudget_2.0": {
115
+ "board": 0.816,
116
+ "cell": 0.9536537172294556,
117
+ "valid_sudoku": 0.816,
118
+ "clues_kept": 1.0,
119
+ "nfe": 6.8
120
+ },
121
+ "conf_0.9": {
122
+ "board": 0.755,
123
+ "cell": 0.9550827423167849,
124
+ "valid_sudoku": 0.755,
125
+ "clues_kept": 1.0,
126
+ "nfe": 11.8
127
+ },
128
+ "conf_0.99": {
129
+ "board": 0.905,
130
+ "cell": 0.9663914470202181,
131
+ "valid_sudoku": 0.905,
132
+ "clues_kept": 1.0,
133
+ "nfe": 28.0
134
+ },
135
+ "conf_0.999": {
136
+ "board": 0.925,
137
+ "cell": 0.9687378709290427,
138
+ "valid_sudoku": 0.925,
139
+ "clues_kept": 1.0,
140
+ "nfe": 37.8
141
+ }
142
+ },
143
+ "best_board": 0.93
144
+ }
slurm/genhard2.sh ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Build a HARD STRATUM test set to close the paper's main weakness.
3
+ #
4
+ # Published Sudoku numbers are on 17-clue puzzles; ours are 20-30 (mean 24.4),
5
+ # so the comparison has had to be hedged as "different distribution". Our test
6
+ # set contains only 46 puzzles at <=22 clues - too few to report.
7
+ #
8
+ # Generating a large pool and keeping the hardest tail gives a stratum close to
9
+ # the published difficulty, on which the EXISTING trained models can be evaluated
10
+ # with no retraining. CPU only, so it costs nothing against the GPU allocation.
11
+ #SBATCH -J genh2
12
+ #SBATCH -p standard
13
+ #SBATCH -c 64
14
+ #SBATCH --mem=64g
15
+ #SBATCH -t 4:00:00
16
+ #SBATCH -o logs/genh2-%j.out
17
+ export SCR=/scratch/zt1/project/msml612/user/$USER
18
+ source /etc/profile
19
+ SCR=/scratch/zt1/project/msml612/user/$USER
20
+ module load python/3.10.10
21
+ export PYTHONPATH=
22
+ cd $SCR/pcmd/src
23
+ $SCR/hf_env/bin/python -u - <<'PY'
24
+ import numpy as np, os, sys, time
25
+ from multiprocessing import Pool
26
+ sys.path.insert(0, '.')
27
+ from gen_hard_sudoku import make_one, canon
28
+ from sudoku import CELLS
29
+
30
+ OUT = os.path.join(os.environ['SCR'], 'pcmd/data/sudoku_stratum')
31
+ os.makedirs(OUT, exist_ok=True)
32
+ N = 30000
33
+ t0 = time.time()
34
+ with Pool(64) as p:
35
+ got = p.map(make_one, range(1_000_000, 1_000_000 + N), chunksize=32)
36
+ P = np.stack([g for g, _ in got]); S = np.stack([s for _, s in got])
37
+ clues = (P > 0).sum(1)
38
+ print(f"generated {N} minimal puzzles in {time.time()-t0:.0f}s", flush=True)
39
+ import collections
40
+ h = collections.Counter(clues.tolist())
41
+ for k in sorted(h): print(f" {k} clues: {h[k]}", flush=True)
42
+
43
+ for cap, name in ((22, 'le22'), (23, 'le23')):
44
+ m = clues <= cap
45
+ if m.sum() < 50: continue
46
+ np.save(os.path.join(OUT, f'{name}_puz.npy'), P[m])
47
+ np.save(os.path.join(OUT, f'{name}_sol.npy'), S[m])
48
+ print(f" saved {name}: {int(m.sum())} puzzles, mean {clues[m].mean():.1f} clues",
49
+ flush=True)
50
+
51
+ # these must not overlap the TRAINING solutions of the models we will evaluate
52
+ tr = np.load(os.path.join(os.environ['SCR'], 'pcmd/data/sudoku_hard/train_sol.npy'))
53
+ trc = {canon(r) for r in tr}
54
+ for cap, name in ((22, 'le22'), (23, 'le23')):
55
+ f = os.path.join(OUT, f'{name}_sol.npy')
56
+ if not os.path.exists(f): continue
57
+ Ssub = np.load(f)
58
+ leak = sum(canon(r) in trc for r in Ssub)
59
+ print(f" {name}: {leak}/{len(Ssub)} solutions also in training (must be 0)", flush=True)
60
+ assert leak == 0
61
+ PY
slurm/inj2.sh CHANGED
@@ -19,7 +19,7 @@
19
  #SBATCH --mem=16g
20
  #SBATCH -t 6:00:00
21
  #SBATCH -o logs/inj-%A_%a.out
22
- #SBATCH --array=0-2%3
23
  source /etc/profile
24
  SCR=/scratch/zt1/project/msml612/user/$USER
25
  module load python/3.10.10
 
19
  #SBATCH --mem=16g
20
  #SBATCH -t 6:00:00
21
  #SBATCH -o logs/inj-%A_%a.out
22
+ #SBATCH --array=0-1%2
23
  source /etc/profile
24
  SCR=/scratch/zt1/project/msml612/user/$USER
25
  module load python/3.10.10
slurm/inj3.sh ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Closes the one acknowledged gap in the ablation.
3
+ #
4
+ # The headline comparison - recurrent 99.70% against 32 distinct layers 94.50% -
5
+ # conflates TWO differences: weight sharing, and re-supplying the input at every
6
+ # application. --no_inject holds architecture, depth, parameter count and
7
+ # objective fixed and changes only the injection, which isolates them.
8
+ #
9
+ # no-injection stays near 98% -> weight sharing is the mechanism
10
+ # no-injection falls toward 94% -> injection is, and our explanation is wrong
11
+ #
12
+ # Runs on a 5GB MIG slice: at 212k parameters and 81-token sequences the
13
+ # optimiser state is a few MB, and that partition bills at 9/min against the
14
+ # H100's 144 - 16x more compute from the same remaining allocation.
15
+ #SBATCH -J inj
16
+ #SBATCH -p gpu-a100_1g.5gb
17
+ #SBATCH --gres=gpu:a100_1g.5gb:1
18
+ #SBATCH -c 4
19
+ #SBATCH --mem=16g
20
+ #SBATCH -t 2:00:00
21
+ #SBATCH -o logs/inj-%A_%a.out
22
+ #SBATCH --array=0-1%2
23
+ source /etc/profile
24
+ SCR=/scratch/zt1/project/msml612/user/$USER
25
+ module load python/3.10.10
26
+ export PYTHONPATH=
27
+ export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
28
+ cd $SCR/pcmd/src
29
+ I=$SLURM_ARRAY_TASK_ID
30
+ COMMON="--mode full --pe ape --hard $SCR/pcmd/data/sudoku_hard --augment \
31
+ --heads 4 --bs 64 --steps 110000 --lr 3e-4 --warmup 3000 --K 8 --lam 1.0 \
32
+ --eval_bs 200 --ckpt_every 8000"
33
+ case $I in
34
+ 0) NAME="inj-noinject-s0"; ARGS="--recurrent --R 32 --R_infer 64 --no_inject --d 128 --layers 1 --seed 0" ;;
35
+ 1) NAME="inj-noinject-s1"; ARGS="--recurrent --R 32 --R_infer 64 --no_inject --d 128 --layers 1 --seed 1" ;;
36
+ 2) NAME="inj-deep32L-s1"; ARGS="--d 128 --layers 32 --seed 1" ;;
37
+ 3) NAME="inj-rec-s2"; ARGS="--recurrent --R 32 --R_infer 64 --d 128 --layers 1 --seed 2" ;;
38
+ 4) NAME="inj-rec-s3"; ARGS="--recurrent --R 32 --R_infer 64 --d 128 --layers 1 --seed 3" ;;
39
+ esac
40
+ echo "=== $NAME ==="
41
+ $SCR/hf_env/bin/python -u train_sudoku.py $ARGS $COMMON --out $SCR/pcmd/runs/$NAME
slurm/plots.sh ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ #SBATCH -J plots
3
+ #SBATCH -p gpu-a100_1g.5gb
4
+ #SBATCH --gres=gpu:a100_1g.5gb:1
5
+ #SBATCH -c 4
6
+ #SBATCH --mem=16g
7
+ #SBATCH -t 0:40:00
8
+ #SBATCH -o logs/plots-%j.out
9
+ source /etc/profile
10
+ SCR=/scratch/zt1/project/msml612/user/$USER
11
+ module load python/3.10.10
12
+ export PYTHONPATH=
13
+ export RUNS=$SCR/pcmd/runs OUT=$SCR/pcmd/figures HARD=$SCR/pcmd/data/sudoku_hard
14
+ cd $SCR/pcmd/src
15
+ $SCR/hf_env/bin/python -u plot_sudoku.py
16
+ $SCR/hf_env/bin/python -u plot_sudoku_grids.py
src/model.py CHANGED
@@ -194,10 +194,15 @@ class RecurrentDenoiser(nn.Module):
194
  """
195
 
196
  def __init__(self, vocab, d=128, n_layers=1, n_heads=4, pe="ape",
197
- max_len=128, recurrences=32, hidden_mult=4):
198
  super().__init__()
199
  self.pe_kind, self.causal = pe, False
200
  self.recurrences = recurrences
 
 
 
 
 
201
  self.emb = nn.Embedding(vocab, d)
202
  if pe == "ape":
203
  self.pos = nn.Embedding(max_len, d)
@@ -225,7 +230,8 @@ class RecurrentDenoiser(nn.Module):
225
  h = inp
226
  outs = []
227
  for _ in range(R):
228
- h = h + inp # input injection: keep the clues alive
 
229
  for b in self.blocks:
230
  h = b(h, pad_mask, pos_ids)
231
  outs.append(self.head(self.norm(h)))
 
194
  """
195
 
196
  def __init__(self, vocab, d=128, n_layers=1, n_heads=4, pe="ape",
197
+ max_len=128, recurrences=32, hidden_mult=4, inject=True):
198
  super().__init__()
199
  self.pe_kind, self.causal = pe, False
200
  self.recurrences = recurrences
201
+ # `inject` exists to separate two things the headline comparison
202
+ # otherwise conflates: weight sharing, and re-supplying the input at
203
+ # every application. Turning it off keeps the architecture and the depth
204
+ # identical and changes only the injection, which is the clean isolation.
205
+ self.inject = inject
206
  self.emb = nn.Embedding(vocab, d)
207
  if pe == "ape":
208
  self.pos = nn.Embedding(max_len, d)
 
230
  h = inp
231
  outs = []
232
  for _ in range(R):
233
+ if self.inject:
234
+ h = h + inp # input injection: keep the clues alive
235
  for b in self.blocks:
236
  h = b(h, pad_mask, pos_ids)
237
  outs.append(self.head(self.norm(h)))
src/plot_sudoku.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Every figure for the Sudoku result, generated from the raw run files.
2
+
3
+ Nothing here is hand-entered: each panel reads result.json from the runs it
4
+ plots, so a figure cannot drift from the numbers it claims to show.
5
+ """
6
+ import glob, json, os
7
+ from collections import defaultdict
8
+
9
+ import numpy as np
10
+ import matplotlib
11
+ matplotlib.use("Agg")
12
+ import matplotlib.pyplot as plt
13
+
14
+ RUNS = os.environ.get("RUNS", "runs")
15
+ OUT = os.environ.get("OUT", "figures")
16
+ os.makedirs(OUT, exist_ok=True)
17
+
18
+ # classical baselines, measured on this exact test set by sudoku_compare.py
19
+ CLASSICAL = {"constraint propagation": 0.0300, "greedy MRV": 0.0590}
20
+
21
+
22
+ def cfg_label(a):
23
+ if a.get("objective") == "ar":
24
+ return "autoregressive prefix-LM"
25
+ if a.get("objective") == "direct":
26
+ return "direct recurrent classifier"
27
+ if not a.get("recurrent"):
28
+ return f"feed-forward {a['layers']}L ({a['mode']})"
29
+ tags = []
30
+ if a.get("no_inject"):
31
+ tags.append("no-inject")
32
+ if a.get("no_deep_sup"):
33
+ tags.append("no-deepsup")
34
+ return "recurrent " + a["mode"] + (" [" + ",".join(tags) + "]" if tags else "")
35
+
36
+
37
+ runs = defaultdict(list)
38
+ for f in sorted(glob.glob(f"{RUNS}/*/result.json")):
39
+ n = os.path.basename(os.path.dirname(f))
40
+ if not n.startswith(("rec1-", "abl-", "base-", "fix-", "inj-", "sudh-")):
41
+ continue
42
+ try:
43
+ r = json.load(open(f))
44
+ except Exception:
45
+ continue
46
+ if r.get("decode"):
47
+ runs[cfg_label(r["args"])].append(r)
48
+
49
+ # ---- Figure 1: board accuracy against decoding passes -----------------------
50
+ fig, ax = plt.subplots(figsize=(7.2, 4.6))
51
+ GRID = [1, 2, 4, 8, 16, 32, 61]
52
+ style = {"recurrent full": ("#1a5276", "o", 2.4),
53
+ "recurrent mdlm": ("#2980b9", "s", 1.6),
54
+ "feed-forward 12L (full)": ("#c0392b", "^", 1.6),
55
+ "feed-forward 12L (mdlm)": ("#e67e22", "v", 1.4),
56
+ "feed-forward 32L (full)": ("#8e44ad", "D", 1.4)}
57
+ for label, rs in sorted(runs.items()):
58
+ if label not in style:
59
+ continue
60
+ col, mk, lw = style[label]
61
+ xs, ys = [], []
62
+ for k in GRID:
63
+ v = [r["decode"][f"fixedK_{k}"]["board"] for r in rs
64
+ if f"fixedK_{k}" in r["decode"]]
65
+ if v:
66
+ xs.append(k); ys.append(np.mean(v) * 100)
67
+ if xs:
68
+ ax.plot(xs, ys, marker=mk, color=col, lw=lw, ms=5, label=label)
69
+ for name, v in CLASSICAL.items():
70
+ ax.axhline(v * 100, ls=":", lw=1.2, color="#555")
71
+ ax.text(1.05, v * 100 + 1.5, name, fontsize=7, color="#555")
72
+ ax.set_xscale("log", base=2)
73
+ ax.set_xlabel("denoising passes (NFE)")
74
+ ax.set_ylabel("board accuracy (%) — all 81 cells correct")
75
+ ax.set_title("Hard Sudoku: recurrence beats capacity")
76
+ ax.grid(alpha=.3); ax.legend(fontsize=7.5, loc="lower right"); ax.set_ylim(-3, 103)
77
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig1_passes.png", dpi=170)
78
+
79
+ # ---- Figure 2: parameters against accuracy ---------------------------------
80
+ fig, ax = plt.subplots(figsize=(6.6, 4.4))
81
+ pts = []
82
+ for label, rs in runs.items():
83
+ a = rs[0]["args"]
84
+ npar = 212e3 if a.get("recurrent") else (
85
+ 6.34e6 if a.get("layers") == 32 else (2.4e6 if a.get("objective") == "ar"
86
+ else 37.9e6))
87
+ best = np.mean([max(v["board"] for v in r["decode"].values()) for r in rs])
88
+ pts.append((npar, best * 100, label))
89
+ for npar, acc, label in pts:
90
+ hero = label == "recurrent full"
91
+ ax.scatter(npar, acc, s=170 if hero else 70,
92
+ color="#1a5276" if hero else "#888",
93
+ marker="*" if hero else "o", zorder=3 if hero else 2)
94
+ ax.annotate(label, (npar, acc), fontsize=6.6,
95
+ xytext=(6, 5 if acc < 95 else -11), textcoords="offset points")
96
+ ax.axhline(99.5, ls="--", color="#117a65", lw=1.2)
97
+ ax.text(2.3e5, 99.5 + 0.9, "Recurrent Transformer 99.5% @211k params "
98
+ "(different clue distribution)", fontsize=6.6, color="#117a65")
99
+ ax.set_xscale("log")
100
+ ax.set_xlabel("parameters"); ax.set_ylabel("board accuracy (%)")
101
+ ax.set_title("180× fewer parameters, 10 points better")
102
+ ax.grid(alpha=.3); ax.set_ylim(-5, 108)
103
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig2_params.png", dpi=170)
104
+
105
+ # ---- Figure 3: recurrence extrapolates past its training depth --------------
106
+ sw = os.path.join(OUT, "sudoku_rsweep.json")
107
+ if os.path.exists(sw):
108
+ data = json.load(open(sw))
109
+ fig, ax = plt.subplots(figsize=(6.6, 4.2))
110
+ for name, d in data.items():
111
+ Rs = sorted(int(k) for k in d)
112
+ ys = [d[str(R)] * 100 for R in Rs]
113
+ ax.plot(Rs, ys, marker="o", ms=4,
114
+ color="#1a5276" if "full" in name else "#c0392b",
115
+ alpha=.85, label=name)
116
+ ax.axvline(32, ls="--", color="k", lw=1)
117
+ ax.text(33, 12, "trained at R = 32", fontsize=7.5, rotation=90)
118
+ ax.set_xscale("log", base=2)
119
+ ax.set_xlabel("recurrences at inference"); ax.set_ylabel("board accuracy (%)")
120
+ ax.set_title("Deep supervision makes the recurrence iterable past training depth")
121
+ ax.grid(alpha=.3); ax.legend(fontsize=7); ax.set_ylim(-3, 103)
122
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig3_rsweep.png", dpi=170)
123
+
124
+ # ---- Figure 4: ablation ladder ---------------------------------------------
125
+ order = ["feed-forward 12L (mdlm)", "feed-forward 12L (full)",
126
+ "feed-forward 32L (full)", "recurrent mdlm",
127
+ "recurrent full [no-deepsup]", "recurrent full [no-inject]",
128
+ "recurrent full"]
129
+ have = [(o, np.mean([max(v["board"] for v in r["decode"].values())
130
+ for r in runs[o]]) * 100) for o in order if o in runs]
131
+ if have:
132
+ fig, ax = plt.subplots(figsize=(7.4, 4.0))
133
+ names = [h[0] for h in have]; vals = [h[1] for h in have]
134
+ cols = ["#1a5276" if n == "recurrent full" else "#9fb6c8" for n in names]
135
+ ax.barh(range(len(vals)), vals, color=cols)
136
+ for i, v in enumerate(vals):
137
+ ax.text(v + 0.8, i, f"{v:.2f}%", va="center", fontsize=8)
138
+ ax.set_yticks(range(len(names))); ax.set_yticklabels(names, fontsize=8)
139
+ ax.set_xlabel("board accuracy (%)"); ax.set_xlim(0, 108)
140
+ ax.set_title("Every ingredient is load-bearing")
141
+ ax.grid(alpha=.3, axis="x")
142
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig4_ablation.png", dpi=170)
143
+
144
+ print("configurations plotted:")
145
+ for k in sorted(runs):
146
+ v = [max(x["board"] for x in r["decode"].values()) for r in runs[k]]
147
+ print(f" {k:<42} n={len(v)} {np.mean(v)*100:6.2f}% {[round(x*100,1) for x in v]}")
148
+ print(f"\nwrote figures to {OUT}")
src/plot_sudoku_grids.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sudoku figures that show the model actually solving, plus difficulty curves.
2
+
3
+ Everything is drawn from real runs and real test puzzles - the grids below are
4
+ genuine model output on held-out instances, not illustrations.
5
+ """
6
+ import glob, json, os, sys
7
+ from collections import defaultdict
8
+
9
+ import numpy as np
10
+ import torch
11
+ import matplotlib
12
+ matplotlib.use("Agg")
13
+ import matplotlib.pyplot as plt
14
+ from matplotlib.patches import Rectangle
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+ from model import RecurrentDenoiser, Transformer
18
+ import diffusion as dfn
19
+ from sudoku import CELLS, GRID, SudokuTokenizer
20
+
21
+ RUNS = os.environ.get("RUNS", "runs")
22
+ OUT = os.environ.get("OUT", "figures")
23
+ HARD = os.environ.get("HARD", "data/sudoku_hard")
24
+ os.makedirs(OUT, exist_ok=True)
25
+ dev = "cuda" if torch.cuda.is_available() else "cpu"
26
+ tok = SudokuTokenizer()
27
+
28
+ P = np.load(os.path.join(HARD, "test_puz.npy"))
29
+ S = np.load(os.path.join(HARD, "test_sol.npy"))
30
+ clues = (P > 0).sum(1)
31
+
32
+
33
+ def draw_grid(ax, grid, given=None, wrong=None, title="", fs=9):
34
+ """Render one 9x9 board. Given clues bold, wrong cells shaded red."""
35
+ g = np.asarray(grid).reshape(GRID, GRID)
36
+ gv = np.asarray(given).reshape(GRID, GRID) if given is not None else None
37
+ wr = np.asarray(wrong).reshape(GRID, GRID) if wrong is not None else None
38
+ ax.set_xlim(0, 9); ax.set_ylim(0, 9); ax.invert_yaxis()
39
+ ax.set_xticks([]); ax.set_yticks([])
40
+ for i in range(GRID):
41
+ for j in range(GRID):
42
+ if wr is not None and wr[i, j]:
43
+ ax.add_patch(Rectangle((j, i), 1, 1, color="#f5b7b1", zorder=0))
44
+ elif gv is not None and gv[i, j]:
45
+ ax.add_patch(Rectangle((j, i), 1, 1, color="#eaeded", zorder=0))
46
+ v = int(g[i, j])
47
+ if v > 0:
48
+ bold = gv is not None and gv[i, j]
49
+ ax.text(j + .5, i + .55, str(v), ha="center", va="center",
50
+ fontsize=fs, fontweight="bold" if bold else "normal",
51
+ color="#000" if bold else ("#922b21" if (wr is not None and wr[i, j])
52
+ else "#1a5276"), zorder=2)
53
+ for k in range(10):
54
+ lw = 1.9 if k % 3 == 0 else 0.5
55
+ ax.plot([k, k], [0, 9], color="k", lw=lw)
56
+ ax.plot([0, 9], [k, k], color="k", lw=lw)
57
+ ax.set_title(title, fontsize=8.5)
58
+
59
+
60
+ def load_best():
61
+ """The strongest trained model available."""
62
+ best, bacc = None, -1
63
+ for d in sorted(glob.glob(f"{RUNS}/rec1-*")) + sorted(glob.glob(f"{RUNS}/inj-rec*")):
64
+ rf, mf = os.path.join(d, "result.json"), os.path.join(d, "model.pt")
65
+ if not (os.path.exists(rf) and os.path.exists(mf)):
66
+ continue
67
+ r = json.load(open(rf))
68
+ acc = max(v["board"] for v in r["decode"].values())
69
+ if acc > bacc:
70
+ bacc, best = acc, (d, r["args"])
71
+ if best is None:
72
+ return None, None
73
+ d, cfg = best
74
+ m = RecurrentDenoiser(len(tok), cfg["d"], cfg["layers"], cfg["heads"], cfg["pe"],
75
+ max_len=CELLS + 8,
76
+ recurrences=cfg.get("R_infer") or cfg["R"]).to(dev)
77
+ m.load_state_dict(torch.load(os.path.join(d, "model.pt"), map_location=dev))
78
+ m.eval()
79
+ return m, cfg
80
+
81
+
82
+ model, cfg = load_best()
83
+
84
+ # ---- Figure 5: the model solving, pass by pass ------------------------------
85
+ if model is not None:
86
+ hardest = np.argsort(clues)[:3] # fewest clues = hardest
87
+ budgets = [1, 4, 16, 61]
88
+ fig, axes = plt.subplots(len(hardest), len(budgets) + 2,
89
+ figsize=(2.0 * (len(budgets) + 2), 2.05 * len(hardest)))
90
+ for r, idx in enumerate(hardest):
91
+ puz, sol = P[idx], S[idx]
92
+ given = puz > 0
93
+ draw_grid(axes[r][0], puz, given,
94
+ title=f"puzzle ({given.sum()} clues)" if r == 0 else f"({given.sum()} clues)")
95
+ for c, K in enumerate(budgets):
96
+ with torch.no_grad():
97
+ gold = torch.from_numpy(sol - 1)[None].to(dev)
98
+ bl = torch.from_numpy(~given)[None].to(dev)
99
+ x = torch.where(bl, torch.full_like(gold, tok.mask), gold)
100
+ y, _ = dfn.fixed_k_decode(model, x, tok, dev, K, fillable=bl)
101
+ pred = y[0].cpu().numpy() + 1
102
+ wrong = (pred != sol) & (~given)
103
+ ok = not wrong.any()
104
+ draw_grid(axes[r][c + 1], pred, given, wrong,
105
+ title=(f"{K} pass{'es' if K > 1 else ''}" if r == 0 else "")
106
+ + (" ✓" if ok else f" {wrong.sum()} wrong"))
107
+ draw_grid(axes[r][-1], sol, given, title="solution" if r == 0 else "")
108
+ fig.suptitle("Recurrent masked diffusion solving held-out hard Sudoku "
109
+ "(grey = given clue, red = wrong cell)", fontsize=10)
110
+ fig.tight_layout(rect=[0, 0, 1, 0.96])
111
+ fig.savefig(f"{OUT}/sud_fig5_grids.png", dpi=165)
112
+ print("wrote sud_fig5_grids.png")
113
+
114
+ # ---- Figure 6: how the board fills across recurrences -----------------------
115
+ if model is not None:
116
+ idx = int(np.argsort(clues)[0])
117
+ puz, sol = P[idx], S[idx]
118
+ given = puz > 0
119
+ Rs = [1, 2, 4, 8, 16, 32, 64]
120
+ fig, axes = plt.subplots(1, len(Rs), figsize=(2.0 * len(Rs), 2.35))
121
+ for c, R in enumerate(Rs):
122
+ model.recurrences = R
123
+ with torch.no_grad():
124
+ gold = torch.from_numpy(sol - 1)[None].to(dev)
125
+ bl = torch.from_numpy(~given)[None].to(dev)
126
+ x = torch.where(bl, torch.full_like(gold, tok.mask), gold)
127
+ y, _ = dfn.fixed_k_decode(model, x, tok, dev, 16, fillable=bl)
128
+ pred = y[0].cpu().numpy() + 1
129
+ wrong = (pred != sol) & (~given)
130
+ draw_grid(axes[c], pred, given, wrong,
131
+ title=f"R={R} {(~wrong[~given]).mean()*100:.0f}% cells", fs=7.5)
132
+ model.recurrences = cfg.get("R_infer") or cfg["R"]
133
+ fig.suptitle("The same block applied more times: iterative refinement, "
134
+ "trained at R=32", fontsize=10)
135
+ fig.tight_layout(rect=[0, 0, 1, 0.93])
136
+ fig.savefig(f"{OUT}/sud_fig6_recurrence.png", dpi=165)
137
+ print("wrote sud_fig6_recurrence.png")
138
+
139
+ # ---- Figure 7: accuracy against puzzle difficulty ---------------------------
140
+ if model is not None:
141
+ model.recurrences = cfg.get("R_infer") or cfg["R"]
142
+ preds = []
143
+ with torch.no_grad():
144
+ for i in range(0, len(P), 200):
145
+ gold = torch.from_numpy(S[i:i + 200] - 1).to(dev)
146
+ bl = torch.from_numpy(P[i:i + 200] == 0).to(dev)
147
+ x = torch.where(bl, torch.full_like(gold, tok.mask), gold)
148
+ y, _ = dfn.fixed_k_decode(model, x, tok, dev, 61, fillable=bl)
149
+ preds.append(y.cpu().numpy() + 1)
150
+ pred = np.concatenate(preds)
151
+ solved = (pred == S).all(axis=1)
152
+ ks = sorted({int(c) for c in clues if (clues == c).sum() >= 15})
153
+ acc = [solved[clues == k].mean() * 100 for k in ks]
154
+ nn = [(clues == k).sum() for k in ks]
155
+ fig, ax = plt.subplots(figsize=(6.8, 4.2))
156
+ ax.plot(ks, acc, "o-", color="#1a5276", lw=2, ms=7, label="recurrent diffusion (ours)")
157
+ ax.axhline(3.0, ls=":", color="#c0392b", lw=1.4)
158
+ ax.text(ks[0], 5.5, "constraint propagation, whole set: 3.0%", fontsize=7.5,
159
+ color="#c0392b")
160
+ for k, a, n in zip(ks, acc, nn):
161
+ ax.annotate(f"n={n}", (k, a), fontsize=6.5, xytext=(0, -13),
162
+ textcoords="offset points", ha="center", color="#555")
163
+ ax.set_xlabel("clues given (fewer = harder)")
164
+ ax.set_ylabel("board accuracy (%)")
165
+ ax.set_title("Accuracy against puzzle difficulty, held-out test set")
166
+ ax.grid(alpha=.3); ax.legend(fontsize=8); ax.set_ylim(-3, 105)
167
+ ax.invert_xaxis()
168
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig7_difficulty.png", dpi=170)
169
+ print("wrote sud_fig7_difficulty.png")
170
+ json.dump({"by_clue": {int(k): float(a) for k, a in zip(ks, acc)},
171
+ "n_by_clue": {int(k): int(n) for k, n in zip(ks, nn)}},
172
+ open(os.path.join(OUT, "sud_by_clue.json"), "w"), indent=2)
173
+
174
+ # ---- Figure 8: benchmark difficulty, ours vs the saturated standard split ---
175
+ fig, ax = plt.subplots(figsize=(6.6, 4.0))
176
+ ax.bar(["SATNet split\n(~36 clues)", "ours\n(20–30 clues)"], [100.0, 3.0],
177
+ color=["#aab7b8", "#c0392b"], width=.55)
178
+ ax.set_ylabel("solved by constraint propagation alone (%)")
179
+ ax.set_title("Why we generated a new benchmark:\nthe standard split is saturated")
180
+ for i, v in enumerate([100.0, 3.0]):
181
+ ax.text(i, v + 2.5, f"{v:.1f}%", ha="center", fontweight="bold")
182
+ ax.set_ylim(0, 112); ax.grid(alpha=.3, axis="y")
183
+ fig.tight_layout(); fig.savefig(f"{OUT}/sud_fig8_saturation.png", dpi=170)
184
+ print("wrote sud_fig8_saturation.png")
src/train_sudoku.py CHANGED
@@ -50,6 +50,10 @@ def get_args():
50
  p.add_argument("--recurrent", action="store_true",
51
  help="weight-shared block applied R times with deep supervision")
52
  p.add_argument("--R", type=int, default=32, help="training recurrences")
 
 
 
 
53
  p.add_argument("--no_deep_sup", action="store_true",
54
  help="ablation: supervise only the final recurrence")
55
  p.add_argument("--R_infer", type=int, default=0,
@@ -106,7 +110,8 @@ blank_te = torch.from_numpy(Xte == 0)
106
 
107
  if a.recurrent:
108
  model = RecurrentDenoiser(len(tok), a.d, a.layers, a.heads, a.pe,
109
- max_len=CELLS + 8, recurrences=a.R).to(dev)
 
110
  print(f"params {model.n_params()/1e3:.0f}k RECURRENT R={a.R} "
111
  f"layers={a.layers} d={a.d} mode={a.mode} pe={a.pe}", flush=True)
112
  else:
 
50
  p.add_argument("--recurrent", action="store_true",
51
  help="weight-shared block applied R times with deep supervision")
52
  p.add_argument("--R", type=int, default=32, help="training recurrences")
53
+ p.add_argument("--no_inject", action="store_true",
54
+ help="ablation: drop input injection, keeping architecture and "
55
+ "depth identical. Isolates weight sharing from injection, "
56
+ "which the 32-layer comparison conflates")
57
  p.add_argument("--no_deep_sup", action="store_true",
58
  help="ablation: supervise only the final recurrence")
59
  p.add_argument("--R_infer", type=int, default=0,
 
110
 
111
  if a.recurrent:
112
  model = RecurrentDenoiser(len(tok), a.d, a.layers, a.heads, a.pe,
113
+ max_len=CELLS + 8, recurrences=a.R,
114
+ inject=not a.no_inject).to(dev)
115
  print(f"params {model.n_params()/1e3:.0f}k RECURRENT R={a.R} "
116
  f"layers={a.layers} d={a.d} mode={a.mode} pe={a.pe}", flush=True)
117
  else: