File size: 7,538 Bytes
1c9c214 ae02605 | 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 | """Sudoku as a constraint-satisfaction benchmark for masked diffusion.
Uses the SATNet dataset (Wang et al., ICML 2019), which is the standard 9x9
Sudoku benchmark for neural solvers: 10,000 puzzles with the conventional
9,000 / 1,000 train/test split, scored by BOARD-level accuracy - every one of the
81 cells correct, nothing partial. That metric is unambiguous and it is what the
published numbers report, so a comparison means something.
Why this task. Sudoku is the cleanest possible case of the regime this project
is about: the answer is UNIQUE, so there is no information-theoretic obstacle at
all, and the dependence between cells is enormous, since each cell is
constrained by twenty others through its row, column and box. Our framework says
the entire difficulty is therefore mode (i) - and, more sharply, that a decoder
which commits cells in order of certainty is performing classical constraint
propagation. Filling the forced cells first, recomputing, and repeating IS the
textbook algorithm, and it is exactly what an entropy-budgeted commit rule does
without being told to.
The prediction, registered before any run: fixed-K parallel decoding should fail
badly on this task while entropy-budgeted decoding should succeed, and the gap
should be far larger than anything seen on text, where dependence is diffuse.
"""
import os
import numpy as np
GRID = 9
CELLS = GRID * GRID # 81
N_TRAIN, N_TEST = 9000, 1000 # the conventional SATNet split
class SudokuTokenizer:
"""Ten symbols: the digits 1-9 and the absorbing state.
There is no separate 'blank' symbol. A blank cell is simply one that starts
masked, which is what makes this a conditional masked-diffusion problem
rather than a classification one: clues are observed context, blanks are the
positions to fill.
"""
def __init__(self):
self.itos = [str(d) for d in range(1, 10)] + ["[MASK]"]
self.stoi = {s: i for i, s in enumerate(self.itos)}
self.mask = self.stoi["[MASK]"]
self.pad = self.mask
def __len__(self):
return len(self.itos)
def _onehot_to_int(t):
"""(N, 9, 9, 9) one-hot -> (N, 81) with 0 for an empty cell, 1-9 otherwise."""
filled = t.sum(-1) > 0
digits = t.argmax(-1) + 1
return np.where(filled, digits, 0).reshape(len(t), CELLS).astype(np.int64)
def is_valid_solution(grid):
"""Does a completed 81-cell grid satisfy every Sudoku constraint?
Checked rather than assumed: if the dataset's labels were not valid Sudoku
the entire benchmark would be meaningless, and the failure would be silent.
"""
g = np.asarray(grid).reshape(GRID, GRID)
target = set(range(1, 10))
for i in range(GRID):
if set(g[i]) != target or set(g[:, i]) != target:
return False
for bi in range(0, GRID, 3):
for bj in range(0, GRID, 3):
if set(g[bi:bi + 3, bj:bj + 3].ravel()) != target:
return False
return True
def consistent_with_clues(pred, puzzle):
"""Does a prediction keep every given clue? (It should: clues are observed.)"""
p, q = np.asarray(pred), np.asarray(puzzle)
given = q > 0
return bool((p[given] == q[given]).all())
def load_satnet(root="data/sudoku"):
"""Return (puzzles, solutions, tokenizer) as (N, 81) integer arrays.
puzzles: 0 where the cell is blank, 1-9 where a clue is given
solutions: 1-9 everywhere
"""
import torch
base = os.path.join(root, "sudoku")
if not os.path.isdir(base):
base = root
fx = os.path.join(base, "features.pt")
fy = os.path.join(base, "labels.pt")
if not (os.path.exists(fx) and os.path.exists(fy)):
raise FileNotFoundError(
f"SATNet Sudoku not found under {root}. Fetch once with:\n"
f" mkdir -p {root} && cd {root} && curl -sLO https://powei.tw/sudoku.zip "
f"&& unzip -q sudoku.zip")
X = _onehot_to_int(torch.load(fx, map_location="cpu").numpy())
Y = _onehot_to_int(torch.load(fy, map_location="cpu").numpy())
return X, Y, SudokuTokenizer()
def split(X, Y):
"""The conventional 9,000 / 1,000 split, in dataset order."""
return (X[:N_TRAIN], Y[:N_TRAIN]), (X[N_TRAIN:N_TRAIN + N_TEST],
Y[N_TRAIN:N_TRAIN + N_TEST])
def board_accuracy(pred, gold):
"""Fraction of boards with ALL 81 cells correct - the published metric."""
return float((np.asarray(pred) == np.asarray(gold)).all(axis=1).mean())
def cell_accuracy(pred, gold, blanks):
"""Per-cell accuracy over blanks only, reported alongside as context."""
p, g, b = np.asarray(pred), np.asarray(gold), np.asarray(blanks)
return float((p[b] == g[b]).mean())
if __name__ == "__main__":
root = os.environ.get("SUDOKUROOT", "data/sudoku")
X, Y, tok = load_satnet(root)
(Xtr, Ytr), (Xte, Yte) = split(X, Y)
print(f"puzzles {X.shape} solutions {Y.shape} vocab {len(tok)}")
print(f"train {Xtr.shape[0]} test {Xte.shape[0]} (conventional split)")
clues = (X > 0).sum(1)
print(f"clues per puzzle: min={clues.min()} max={clues.max()} "
f"mean={clues.mean():.1f}")
# every label must be a valid Sudoku solution, or the benchmark is meaningless
bad = sum(not is_valid_solution(Y[i]) for i in range(0, len(Y), 7))
print(f"labels that are NOT valid Sudoku (sampled): {bad} (must be 0)")
# every clue must agree with its solution
dis = sum(not consistent_with_clues(Y[i], X[i]) for i in range(0, len(X), 7))
print(f"puzzles whose clues disagree with the label (sampled): {dis} (must be 0)")
# train and test must not overlap
tr = {bytes(r) for r in Xtr[::3]}
ov = sum(bytes(r) in tr for r in Xte)
print(f"test puzzles also seen in the training sample: {ov} (must be 0)")
def _unit_list():
us = []
for r in range(GRID):
us.append([r * GRID + c for c in range(GRID)])
for c in range(GRID):
us.append([r * GRID + c for r in range(GRID)])
for br in range(0, GRID, 3):
for bc in range(0, GRID, 3):
us.append([(br + dr) * GRID + bc + dc for dr in range(3) for dc in range(3)])
return us
UNIT_LIST = _unit_list()
def violation_mask(x):
"""Positions involved in a row/column/box duplicate. Exact, not heuristic.
x is (B, 81) of token ids 0-8. A cell is flagged when its value appears more
than once in any unit it belongs to. A solved grid flags nothing, so this
doubles as the stopping condition for verifier-guided remasking.
"""
import torch
B = x.shape[0]
bad = torch.zeros_like(x, dtype=torch.bool)
for unit in UNIT_LIST:
idx = torch.tensor(unit, device=x.device)
vals = x[:, idx] # (B, 9)
same = vals[:, :, None] == vals[:, None, :] # (B, 9, 9)
dup = same.sum(-1) > 1 # appears more than once
bad[:, idx] |= dup
return bad
def expand_to_units(bad):
"""Widen a violation mask to every cell sharing a unit with a flagged cell.
Re-deciding a single conflicting cell in an otherwise unchanged context tends
to reproduce the same value. Reopening its row, column and box gives the
model a subproblem it can actually re-solve.
"""
import torch
out = torch.zeros_like(bad)
for unit in UNIT_LIST:
idx = torch.tensor(unit, device=bad.device)
hit = bad[:, idx].any(1, keepdim=True) # (B, 1)
out[:, idx] |= hit
return out
|