AlphaTensor in Python
Reinforcement-learning search that plays a game of subtracting rank-one tensors; it found a 4×4 algorithm using 47 multiplications over GF(2) and a 4×5 by 5×5 one with 76.
- File
alphatensor.py, 281 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/alphatensor/. The authors are listed in prov/1MB9PX.jsonl.
"""TensorGame, the single-player game that AlphaTensor plays, with exact checking.
The state is a residual tensor S, starting at the matrix multiplication tensor
<n, m, p>. A move picks factors u, v, w with entries in a small set F and
replaces S by S - u (x) v (x) w. The game ends when S = 0, after t moves,
which gives a bilinear algorithm with t multiplications, or when the move
limit is reached. AlphaTensor chooses moves by Monte Carlo tree search
guided by a neural network; here the policy is any function passed in. The
module also has the verification of a decomposition, the bilinear algorithm
it defines, and a flip graph random walk, which is a different search method
(Kauers and Moosbauer) used here to find a rank 7 scheme over GF(2) in
milliseconds.
Index conventions: u is an n x m matrix of coefficients of A, v an m x p
matrix of coefficients of B, and w an n x p matrix that says how much of the
product goes into each entry of C. The tensor entry at
((i, j), (j, k), (i, k)) is 1 and all others are 0.
"""
import random
from fractions import Fraction
from typing import Callable, Optional
Mat = list[list[int]]
Factor = tuple[Mat, Mat, Mat]
Tensor = list[list[list[int]]]
Policy = Callable[[Tensor, int], Factor]
DEFAULT_F = (-2, -1, 0, 1, 2)
def matmul_tensor(n: int, m: int, p: int) -> Tensor:
"""The tensor <n, m, p> as an (nm) x (mp) x (np) array of 0s and 1s."""
t = [[[0] * (n * p) for _ in range(m * p)] for _ in range(n * m)]
for i in range(n):
for j in range(m):
for k in range(p):
t[i * m + j][j * p + k][i * p + k] = 1
return t
def _flat(a: Mat) -> list[int]:
return [x for row in a for x in row]
def _norm(x: int, mod: Optional[int]) -> int:
return x % mod if mod is not None else x
def _is_zero(s: Tensor) -> bool:
return all(x == 0 for plane in s for row in plane for x in row)
def _slice_rank(mat: list[list[int]], mod: Optional[int]) -> int:
rows = [[Fraction(x) for x in r] for r in mat] if mod is None else [[x % mod for x in r] for r in mat]
rank, cols = 0, len(rows[0]) if rows else 0
for c in range(cols):
piv = next((r for r in range(rank, len(rows)) if rows[r][c] != 0), None)
if piv is None:
continue
rows[rank], rows[piv] = rows[piv], rows[rank]
inv = 1 / rows[rank][c] if mod is None else pow(rows[rank][c], -1, mod)
for r in range(len(rows)):
if r != rank and rows[r][c] != 0:
f = rows[r][c] * inv
rows[r] = [a - f * b for a, b in zip(rows[r], rows[rank])]
if mod is not None:
rows[r] = [a % mod for a in rows[r]]
rank += 1
return rank
def rank_upper_bound(s: Tensor, mod: Optional[int] = None) -> int:
"""gamma(S): the sum of the ranks of the slices S[a], an upper bound on the tensor rank of S."""
return sum(_slice_rank(plane, mod) for plane in s)
def alphatensor(
target: Tensor,
policy: Policy,
limit: int,
mod: Optional[int] = None,
coeffs: tuple[int, ...] = DEFAULT_F,
) -> tuple[Optional[list[Factor]], int]:
"""Play TensorGame on target with the given policy and at most limit moves.
Returns (factors, reward). If the residual reaches 0 after t moves,
factors is the list of t moves, a rank t decomposition of target, and the
reward is -t. Otherwise factors is None and the reward is
-limit - rank_upper_bound(residual). With mod set, arithmetic is in Z/mod.
Each factor entry must lie in coeffs. Cost: limit moves, each O(d) for a
tensor with d entries, plus the calls to policy.
"""
s = [[[_norm(x, mod) for x in row] for row in plane] for plane in target] # @line L1
t = 0
factors: list[Factor] = []
while not _is_zero(s) and t < limit: # @line L2
u, v, w = policy(s, t) # @line L3
fu, fv, fw = _flat(u), _flat(v), _flat(w)
if len(fu) != len(s) or len(fv) != len(s[0]) or len(fw) != len(s[0][0]):
raise ValueError("factor shapes do not match the tensor")
if any(x not in coeffs for x in fu + fv + fw):
raise ValueError("factor entry outside the coefficient set")
for a, x in enumerate(fu): # @line L4
if x:
for b, y in enumerate(fv):
if y:
row = s[a][b]
for c, z in enumerate(fw):
if z:
row[c] = _norm(row[c] - x * y * z, mod)
factors.append((u, v, w))
t += 1
if _is_zero(s): # @line L5
return factors, -t
return None, -t - rank_upper_bound(s, mod) # @line L6
def replay(factors: list[Factor]) -> Policy:
"""A policy that plays a fixed list of moves, as in a demonstration game."""
return lambda state, t: factors[t]
def verify(factors: list[Factor], n: int, m: int, p: int, mod: Optional[int] = None) -> bool:
"""True when sum of u (x) v (x) w equals <n, m, p>, over Z or over Z/mod."""
acc = [[[0] * (n * p) for _ in range(m * p)] for _ in range(n * m)]
for u, v, w in factors:
fu, fv, fw = _flat(u), _flat(v), _flat(w)
for a, x in enumerate(fu):
for b, y in enumerate(fv):
for c, z in enumerate(fw):
acc[a][b][c] += x * y * z
t = matmul_tensor(n, m, p)
return all(
_norm(acc[a][b][c] - t[a][b][c], mod) == 0
for a in range(n * m)
for b in range(m * p)
for c in range(n * p)
)
STRASSEN: list[Factor] = [
([[1, 0], [0, 1]], [[1, 0], [0, 1]], [[1, 0], [0, 1]]),
([[0, 0], [1, 1]], [[1, 0], [0, 0]], [[0, 0], [1, -1]]),
([[1, 0], [0, 0]], [[0, 1], [0, -1]], [[0, 1], [0, 1]]),
([[0, 0], [0, 1]], [[-1, 0], [1, 0]], [[1, 0], [1, 0]]),
([[1, 1], [0, 0]], [[0, 0], [0, 1]], [[-1, 1], [0, 0]]),
([[-1, 0], [1, 0]], [[1, 1], [0, 0]], [[0, 0], [0, 1]]),
([[0, 1], [0, -1]], [[0, 0], [1, 1]], [[1, 0], [0, 0]]),
]
def _schoolbook(a: Mat, b: Mat, rows: int, inner: int, cols: int, mod: Optional[int]) -> Mat:
c = [[0] * cols for _ in range(rows)]
for i in range(rows):
for k in range(inner):
x = a[i][k]
if x:
for j in range(cols):
c[i][j] += x * b[k][j]
return [[_norm(x, mod) for x in row] for row in c]
def multiply_with(
factors: list[Factor],
shape: tuple[int, int, int],
a: Mat,
b: Mat,
cols: int,
mod: Optional[int] = None,
cutoff: int = 1,
) -> Mat:
"""Multiply a (r x s) by b (s x cols) with the bilinear scheme for <n, m, p>, recursively.
The scheme must be valid over the ring used: a scheme found over GF(2)
is only valid with mod = 2 (or another power of 2 in special cases).
Sides are padded with zeros to multiples of the block shape at each
level. When a side is at most cutoff the schoolbook loop is used.
Recursion depth is O(log of the largest side).
"""
n, m, p = shape
r, s = len(a), len(b)
if min(r, s, cols) <= cutoff or max(n, m, p) == 1:
return _schoolbook(a, b, r, s, cols, mod)
rr, ss, cc = -(-r // n), -(-s // m), -(-cols // p) # block sizes; at least one side shrinks
def block(mat: Mat, bi: int, bj: int, h: int, wd: int) -> Mat:
return [
[mat[bi * h + x][bj * wd + y] if bi * h + x < len(mat) and bj * wd + y < len(mat[0]) else 0 for y in range(wd)]
for x in range(h)
]
ab = [[block(a, i, j, rr, ss) for j in range(m)] for i in range(n)]
bb = [[block(b, j, k, ss, cc) for k in range(p)] for j in range(m)]
cb = [[[[0] * cc for _ in range(rr)] for _ in range(p)] for _ in range(n)]
for u, v, w in factors:
left = [[sum(u[i][j] * ab[i][j][x][y] for i in range(n) for j in range(m)) for y in range(ss)] for x in range(rr)]
right = [[sum(v[j][k] * bb[j][k][x][y] for j in range(m) for k in range(p)) for y in range(cc)] for x in range(ss)]
prod = multiply_with(factors, shape, left, right, cc, mod, cutoff)
for i in range(n):
for k in range(p):
if w[i][k]:
blk = cb[i][k]
for x in range(rr):
for y in range(cc):
blk[x][y] += w[i][k] * prod[x][y]
out = [[0] * cols for _ in range(r)]
for i in range(n):
for k in range(p):
for x in range(rr):
for y in range(cc):
gi, gj = i * rr + x, k * cc + y
if gi < r and gj < cols:
out[gi][gj] = _norm(cb[i][k][x][y], mod)
return out
def _bits_to_mat(bits: int, rows: int, cols: int) -> Mat:
return [[(bits >> (x * cols + y)) & 1 for y in range(cols)] for x in range(rows)]
def flip_graph_search(n: int, m: int, p: int, target_rank: int, seed: int, max_steps: int = 100000) -> Optional[list[Factor]]:
"""Random walk on the flip graph over GF(2), from the standard nmp-term scheme.
A flip takes two terms that share a factor, a (x) b (x) c + a (x) b' (x) c',
and rewrites them as a (x) (b + b') (x) c + a (x) b' (x) (c + c'), which has
the same sum over GF(2). A reduction merges two terms that share two
factors. Returns a scheme valid over GF(2) with at most target_rank terms,
or None if max_steps flips do not reach it.
"""
rng = random.Random(seed)
terms = [(1 << (i * m + j), 1 << (j * p + k), 1 << (i * p + k)) for i in range(n) for j in range(m) for k in range(p)]
def reduce(ts: list[tuple[int, int, int]]) -> list[tuple[int, int, int]]:
ts = [t for t in ts if all(t)]
merged = True
while merged:
merged = False
for i in range(len(ts)):
for j in range(i + 1, len(ts)):
for pos in range(3):
o1, o2 = [q for q in range(3) if q != pos]
if ts[i][o1] == ts[j][o1] and ts[i][o2] == ts[j][o2]:
new = list(ts[i])
new[pos] ^= ts[j][pos]
ts = [ts[k] for k in range(len(ts)) if k not in (i, j)]
if new[pos]:
ts.append(tuple(new)) # type: ignore[arg-type]
merged = True
break
if merged:
break
if merged:
break
return ts
terms = reduce(terms)
for _ in range(max_steps):
if len(terms) <= target_rank:
break
pairs = []
for pos in range(3):
groups: dict[int, list[int]] = {}
for idx, t in enumerate(terms):
groups.setdefault(t[pos], []).append(idx)
pairs += [(pos, i, j) for g in groups.values() for i in g for j in g if i != j]
if not pairs:
return None
pos, i, j = rng.choice(pairs)
o1, o2 = [q for q in range(3) if q != pos]
s, t = list(terms[i]), list(terms[j])
s[o1] ^= t[o1]
t[o2] ^= s[o2]
terms[i], terms[j] = tuple(s), tuple(t) # type: ignore[assignment]
terms = reduce(terms)
if len(terms) > target_rank:
return None
return [(_bits_to_mat(a, n, m), _bits_to_mat(b, m, p), _bits_to_mat(c, n, p)) for a, b, c in terms]
test_alphatensor.py 169 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/alphatensor/. The authors are listed in prov/1MB9PX.jsonl.
import random
import pytest
from alphatensor import (
STRASSEN,
alphatensor,
flip_graph_search,
matmul_tensor,
multiply_with,
rank_upper_bound,
replay,
verify,
)
def schoolbook(a, b, cols, mod=None):
n, m = len(a), len(b)
c = [[0] * cols for _ in range(n)]
for i in range(n):
for j in range(cols):
s = 0
for k in range(m):
s += a[i][k] * b[k][j]
c[i][j] = s % mod if mod is not None else s
return c
def rand(rng, r, c, lo=-9, hi=9):
return [[rng.randint(lo, hi) for _ in range(c)] for _ in range(r)]
def standard(n, m, p):
out = []
for i in range(n):
for j in range(m):
for k in range(p):
u = [[int((x, y) == (i, j)) for y in range(m)] for x in range(n)]
v = [[int((x, y) == (j, k)) for y in range(p)] for x in range(m)]
w = [[int((x, y) == (i, k)) for y in range(p)] for x in range(n)]
out.append((u, v, w))
return out
def test_tensor_shape() -> None:
t = matmul_tensor(2, 3, 4)
assert len(t) == 6 and len(t[0]) == 12 and len(t[0][0]) == 8
assert sum(x for plane in t for row in plane for x in row) == 24
assert matmul_tensor(0, 3, 3) == []
def test_strassen_game_over_z() -> None:
factors, reward = alphatensor(matmul_tensor(2, 2, 2), replay(STRASSEN), limit=7)
assert reward == -7 and len(factors) == 7
assert verify(STRASSEN, 2, 2, 2)
for mod in (2, 3, 5, 7):
assert verify(STRASSEN, 2, 2, 2, mod)
assert alphatensor(matmul_tensor(2, 2, 2), replay(STRASSEN), 7, mod)[1] == -7
def test_standard_scheme_and_limit_penalty() -> None:
std = standard(2, 2, 2)
assert alphatensor(matmul_tensor(2, 2, 2), replay(std), 8)[1] == -8
# Stop after 5 of the 8 moves: 3 rank-one terms remain, and gamma bounds their rank.
factors, reward = alphatensor(matmul_tensor(2, 2, 2), replay(std), 5)
assert factors is None and reward == -5 - 3
# The empty game on <2,2,2>: gamma is the sum of slice ranks, 4 slices of rank 2.
assert alphatensor(matmul_tensor(2, 2, 2), replay([]), 0) == (None, -8)
assert rank_upper_bound(matmul_tensor(3, 3, 3)) == 27
def test_bad_moves() -> None:
bad = [([[3, 0], [0, 0]], [[1, 0], [0, 0]], [[1, 0], [0, 0]])]
with pytest.raises(ValueError):
alphatensor(matmul_tensor(2, 2, 2), replay(bad), 1)
with pytest.raises(ValueError):
alphatensor(matmul_tensor(2, 2, 2), replay([([[1]], [[1]], [[1]])]), 1)
def test_gf2_only_scheme() -> None:
"""Strassen with every sign dropped is valid over GF(2) but not over Z or Z/3."""
unsigned = [tuple([[abs(x) for x in row] for row in f] for f in t) for t in STRASSEN]
assert verify(unsigned, 2, 2, 2, 2)
assert not verify(unsigned, 2, 2, 2)
assert not verify(unsigned, 2, 2, 2, 3)
a, b = [[1, 2], [3, 4]], [[5, 6], [7, 8]]
assert multiply_with(unsigned, (2, 2, 2), a, b, 2, mod=2) == schoolbook(a, b, 2, 2)
assert multiply_with(unsigned, (2, 2, 2), a, b, 2) != schoolbook(a, b, 2)
def test_flip_graph_finds_rank_7() -> None:
for seed in range(4):
found = flip_graph_search(2, 2, 2, 7, seed)
assert found is not None and len(found) == 7
assert verify(found, 2, 2, 2, 2)
factors, reward = alphatensor(matmul_tensor(2, 2, 2), replay(found), 7, mod=2, coeffs=(0, 1))
assert reward == -7
found = flip_graph_search(2, 2, 3, 11, 1)
assert found is not None and verify(found, 2, 2, 3, 2)
assert flip_graph_search(2, 2, 2, 6, 0, max_steps=300) is None
def test_multiply_random_over_z() -> None:
rng = random.Random(1)
for _ in range(300):
r, s, c = rng.randint(0, 9), rng.randint(0, 9), rng.randint(0, 9)
a, b = rand(rng, r, s), rand(rng, s, c)
assert multiply_with(STRASSEN, (2, 2, 2), a, b, c) == schoolbook(a, b, c)
@pytest.mark.parametrize("mod", [2, 3, 5, 7])
def test_multiply_random_mod(mod: int) -> None:
rng = random.Random(100 + mod)
for _ in range(60):
r, s, c = rng.randint(1, 8), rng.randint(1, 8), rng.randint(1, 8)
a, b = rand(rng, r, s, 0, mod - 1), rand(rng, s, c, 0, mod - 1)
assert multiply_with(STRASSEN, (2, 2, 2), a, b, c, mod) == schoolbook(a, b, c, mod)
def test_multiply_gf2_found_scheme() -> None:
found = flip_graph_search(2, 2, 2, 7, 3)
rng = random.Random(9)
for _ in range(100):
r, s, c = rng.randint(0, 9), rng.randint(0, 9), rng.randint(0, 9)
a, b = rand(rng, r, s, 0, 1), rand(rng, s, c, 0, 1)
assert multiply_with(found, (2, 2, 2), a, b, c, 2) == schoolbook(a, b, c, 2)
def test_edge_matrices() -> None:
assert multiply_with(STRASSEN, (2, 2, 2), [], [], 0) == []
assert multiply_with(STRASSEN, (2, 2, 2), [[]], [], 3) == [[0, 0, 0]]
assert multiply_with(STRASSEN, (2, 2, 2), [[4]], [[-5]], 1) == [[-20]]
n = 7
eye = [[int(i == j) for j in range(n)] for i in range(n)]
zero = [[0] * n for _ in range(n)]
a = rand(random.Random(5), n, n)
assert multiply_with(STRASSEN, (2, 2, 2), a, eye, n) == a
assert multiply_with(STRASSEN, (2, 2, 2), eye, a, n) == a
assert multiply_with(STRASSEN, (2, 2, 2), a, zero, n) == zero
big = rand(random.Random(6), 17, 17)
assert multiply_with(STRASSEN, (2, 2, 2), big, big, 17, cutoff=4) == schoolbook(big, big, 17)
def test_rectangular_scheme() -> None:
std = standard(1, 2, 3)
assert verify(std, 1, 2, 3)
rng = random.Random(4)
for _ in range(40):
r, s, c = rng.randint(0, 6), rng.randint(0, 6), rng.randint(0, 6)
a, b = rand(rng, r, s), rand(rng, s, c)
assert multiply_with(std, (1, 2, 3), a, b, c) == schoolbook(a, b, c)
def test_example_scheme_seed_1() -> None:
"""The scheme printed in the example section."""
found = flip_graph_search(2, 2, 2, 7, 1)
assert found == [
([[0, 0], [1, 0]], [[1, 0], [0, 0]], [[0, 0], [1, 1]]),
([[0, 1], [0, 1]], [[1, 1], [1, 1]], [[1, 0], [0, 0]]),
([[1, 1], [0, 0]], [[0, 0], [0, 1]], [[0, 1], [0, 1]]),
([[0, 0], [0, 1]], [[0, 0], [1, 0]], [[1, 0], [1, 0]]),
([[1, 1], [0, 1]], [[1, 1], [0, 1]], [[1, 0], [0, 1]]),
([[1, 1], [1, 1]], [[1, 1], [0, 0]], [[0, 0], [0, 1]]),
([[1, 0], [0, 0]], [[0, 1], [0, 1]], [[1, 1], [0, 0]]),
]
assert verify(found, 2, 2, 2, 2) and not verify(found, 2, 2, 2)
What the next level needs
- the tests did not run in this build