Sparse Fourier transform in Python
Recovers the k largest DFT coefficients of a length-n signal in time sublinear in n when the spectrum is sparse.
- File
sparse_fourier_transform.py, 185 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/sparse-fourier-transform/. The authors are listed in prov/E4WS9H.jsonl.
"""Sparse DFT by aliasing and peeling (an exact, verified form of FFAST).
The length n is a product of pairwise coprime factors f_1, ..., f_d. Stage i
reads x at the positions t*(n/f_i) + s for t < f_i and a few shifts s, and a
DFT of length f_i sorts the spectrum into f_i buckets by k mod f_i. Buckets
holding one frequency are decoded (the residues of k come from the shifts
n/f_j, the Chinese remainder theorem gives k) and subtracted everywhere.
X_k = sum_j x_j w**(j*k); over C, w = exp(-2*pi*i/n).
"""
from __future__ import annotations
import cmath
import math
from typing import Callable, Sequence
def _unit(r: int, n: int) -> complex:
"""Return exp(2*pi*i*r/n), exact when r/n is a multiple of 1/4."""
r %= n
if (4 * r) % n == 0:
return (1 + 0j, 1j, -1 + 0j, -1j)[4 * r // n]
return cmath.exp(2j * math.pi * r / n)
def crt(residues: Sequence[int], moduli: Sequence[int]) -> int:
"""The k in [0, prod moduli) with k = residues[i] mod moduli[i]."""
k, m = 0, 1
for r, f in zip(residues, moduli):
# solve k + m*u = r (mod f)
u = (r - k) * pow(m, -1, f) % f
k, m = k + m * u, m * f
return k
class _Field:
"""Arithmetic over C (p is None) or over Z/pZ, with the n-th root w."""
def __init__(self, n: int, w: int | None, p: int | None, tol: float) -> None:
self.n, self.p, self.tol = n, p, tol
if p is not None:
if w is None or pow(w, n, p) != 1 or any(pow(w, n // q, p) == 1 for q in _primes(n)):
raise ValueError("w must be a principal n-th root of unity modulo p")
self.w = w
def root(self, e: int) -> complex | int:
"""w**e."""
if self.p is None:
return _unit(-e, self.n)
return pow(self.w, e % self.n, self.p)
def norm(self, v: complex | int) -> complex | int:
return v % self.p if self.p is not None else v
def scale(self, num: int, den: int) -> complex | int:
"""num/den in the field."""
if self.p is None:
return num / den
return num * pow(den, -1, self.p) % self.p
def is_zero(self, v: complex | int) -> bool:
return abs(v) <= self.tol if self.p is None else v % self.p == 0
def _primes(n: int) -> list[int]:
out, q = [], 2
while q * q <= n:
if n % q == 0:
out.append(q)
while n % q == 0:
n //= q
q += 1
if n > 1:
out.append(n)
return out
def sparse_dft(
sample: Callable[[int], complex | int],
factors: Sequence[int],
K: int,
T: int | None = None,
w: int | None = None,
p: int | None = None,
tol: float = 1e-7,
) -> tuple[dict[int, complex | int], bool, int]:
"""Recover the spectrum of x (at most K nonzero X_k) from few samples.
sample(j) returns x_j for 0 <= j < n = prod(factors); the factors must be
pairwise coprime. T is the largest consecutive shift used to verify a
bucket; T >= K makes every reported coefficient provably correct (exact
arithmetic). Returns (coefficients, success, number of samples read).
"""
factors = list(factors)
n = math.prod(factors)
for a in range(len(factors)):
for b in range(a + 1, len(factors)):
if math.gcd(factors[a], factors[b]) != 1:
raise ValueError("the factors must be pairwise coprime")
if T is None:
T = K
F = _Field(n, w, p, tol)
d = len(factors)
shifts = sorted(set(range(T + 1)) | {n // f for f in factors}) # @line L1
# Z[i][s][b] = sum_t x_(t*n/f + s) * w_f**(t*b) = (f/n) sum_(k = b mod f) X_k w**(-s*k)
Z: list[dict[int, list]] = []
reads = 0
for f in factors: # @line L2
step = n // f
wf = [F.root(step * e) for e in range(f)]
per_shift = {}
for s in shifts:
z = [sample((t * step + s) % n) for t in range(f)]
reads += f
per_shift[s] = [F.norm(sum(z[t] * wf[(t * b) % f] for t in range(f))) for b in range(f)]
Z.append(per_shift)
if F.p is None: # tolerance relative to the largest bucket value
big = max((abs(v) for st in Z for row in st.values() for v in row), default=0.0)
F.tol = tol * max(big, 1.0)
# tables for reading k mod f_j from the ratio w_(f_j)**(-r)
tables = []
for f in factors:
tables.append([F.root(-(n // f) * r) for r in range(f)])
found: dict[int, complex | int] = {} # @line L3
budget = K # a K-sparse input needs at most K peels; this also bounds bad inputs
progress = True
while progress and budget > 0: # @line L4
progress = False
for i, f in enumerate(factors): # @line L5
for b in range(f):
z0 = Z[i][0][b]
if F.is_zero(z0):
continue
residues = []
for j, g in enumerate(factors): # @line L6
if j == i:
residues.append(b)
continue
zs = Z[i][n // g][b]
r = _match(F, tables[j], zs, z0)
if r is None:
break
residues.append(r)
if len(residues) < d:
continue
k = crt(residues, factors) # @line L7
v = F.norm(z0 * (n // f))
coef = F.scale(f, n)
ok = all( # @line L8
F.is_zero(Z[i][s][b] - coef * v * F.root(-s * k)) for s in shifts
)
if not ok:
continue
found[k] = F.norm(found.get(k, 0) + v) # @line L9
for l, g in enumerate(factors):
cg = F.scale(g, n)
for s in shifts:
row = Z[l][s]
row[k % g] = F.norm(row[k % g] - cg * v * F.root(-s * k))
progress = True
budget -= 1
if budget == 0:
break
if budget == 0:
break
success = all(F.is_zero(v) for st in Z for row in st.values() for v in row) # @line L10
if F.p is None:
found = {k: v for k, v in found.items() if abs(v) > F.tol}
else:
found = {k: v for k, v in found.items() if v % F.p}
return found, success, reads # @line L11
def _match(F: _Field, table: list, zs, z0) -> int | None:
"""The r with zs = table[r] * z0, or None."""
if F.p is not None:
for r, t in enumerate(table):
if (zs - t * z0) % F.p == 0:
return r
return None
best = min(range(len(table)), key=lambda r: abs(zs - table[r] * z0))
return best if abs(zs - table[best] * z0) <= F.tol else None
test_sparse_fourier_transform.py 173 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/sparse-fourier-transform/. The authors are listed in prov/E4WS9H.jsonl.
import cmath
import math
import random
import pytest
from sparse_fourier_transform import crt, sparse_dft
P = 998244353 # P - 1 = 2^23 * 7 * 17, generator 3
def root(p: int, g: int, n: int) -> int:
assert (p - 1) % n == 0
return pow(g, (p - 1) // n, p)
def dft_mod(x, w, p):
n = len(x)
return [sum(x[j] * pow(w, j * k, p) for j in range(n)) % p for k in range(n)]
def dft_c(x):
n = len(x)
return [sum(x[j] * cmath.exp(-2j * math.pi * j * k / n) for j in range(n)) for k in range(n)]
def signal_mod(X, n, w, p):
"""x with spectrum X (a dict), by the inverse DFT."""
wi, ni = pow(w, -1, p), pow(n, -1, p)
return [ni * sum(v * pow(wi, j * k, p) for k, v in X.items()) % p for j in range(n)]
def check_mod(factors, p, g, K, trials, seed, need=1.0):
n = math.prod(factors)
w = root(p, g, n)
rng = random.Random(seed)
wins = 0
for _ in range(trials):
k = rng.randint(0, K)
X = {f: rng.randrange(1, p) for f in rng.sample(range(n), k)}
x = signal_mod(X, n, w, p)
got, ok, reads = sparse_dft(lambda j: x[j], factors, K, w=w, p=p)
for f, v in got.items(): # every reported coefficient is right
assert X.get(f) == v
if ok:
assert got == X
wins += 1
assert wins >= need * trials
def test_crt():
for a in range(5):
for b in range(7):
for c in range(9):
k = crt([a, b, c], [5, 7, 9])
assert 0 <= k < 315 and (k % 5, k % 7, k % 9) == (a, b, c)
def test_mod_998244353_many():
check_mod((8, 7, 17), P, 3, 6, 120, 1, need=0.9)
check_mod((16, 7, 17), P, 3, 8, 60, 2, need=0.9)
def test_mod_97_and_17():
check_mod((32, 3), 97, 5, 4, 150, 3, need=0.6)
check_mod((16,), 17, 3, 3, 100, 4)
def test_single_factor_is_full_dft():
# with d = 1 every bucket holds one frequency, so any spectrum is recovered
rng = random.Random(5)
w = root(17, 3, 16)
for _ in range(30):
x = [rng.randrange(17) for _ in range(16)]
X = dft_mod(x, w, 17)
got, ok, _ = sparse_dft(lambda j: x[j], (16,), 16, w=w, p=17)
assert ok and got == {k: v for k, v in enumerate(X) if v}
def test_stopping_set_fails_safely():
# four frequencies forming a rectangle: every bucket of both stages holds two
n, w = 96, root(97, 5, 96)
sup = [crt([a, b], [32, 3]) for a in (3, 10) for b in (0, 2)]
X = {k: v for k, v in zip(sup, (1, 2, 3, 4))}
x = signal_mod(X, n, w, 97)
got, ok, _ = sparse_dft(lambda j: x[j], (32, 3), 4, w=w, p=97)
assert not ok and got == {}
# one more frequency in the same residue classes is still stuck; one outside is found
X[crt([20, 1], [32, 3])] = 5
x = signal_mod(X, n, w, 97)
got, ok, _ = sparse_dft(lambda j: x[j], (32, 3), 5, w=w, p=97)
assert not ok and got == {crt([20, 1], [32, 3]): 5}
def test_worked_example_mod_13():
x = [7, 1, 2, 8, 6, 9, 2, 3, 7, 9, 3, 8]
assert dft_mod(x, 2, 13) == [0, 1, 0, 0, 0, 3, 2, 0, 0, 0, 0, 0]
got, ok, reads = sparse_dft(lambda j: x[j], (4, 3), 3, w=2, p=13)
assert ok and got == {1: 1, 5: 3, 6: 2} and reads == 35
def test_complex_against_direct_dft():
rng = random.Random(6)
factors = (7, 8, 9)
n = 504
for _ in range(6):
X = {k: complex(rng.gauss(0, 1), rng.gauss(0, 1)) for k in rng.sample(range(n), 5)}
x = [sum(v * cmath.exp(2j * math.pi * j * k / n) for k, v in X.items()) / n for j in range(n)]
ref = dft_c(x)
got, ok, _ = sparse_dft(lambda j: x[j], factors, 5)
assert ok
for k in range(n):
assert abs(got.get(k, 0) - ref[k]) <= 1e-9 * n
def test_complex_large_oracle():
rng = random.Random(7)
factors = (49, 50, 51)
n = math.prod(factors)
wins = 0
for _ in range(4):
X = {k: complex(rng.gauss(0, 1), rng.gauss(0, 1)) for k in rng.sample(range(n), 12)}
oracle = lambda j: sum(v * cmath.exp(2j * math.pi * j * k / n) for k, v in X.items()) / n
got, ok, reads = sparse_dft(oracle, factors, 12)
assert reads == 150 * len(set(range(13)) | {n // 49, n // 50, n // 51})
if ok:
wins += 1
assert set(got) == set(X)
assert max(abs(got[k] - X[k]) for k in X) <= 1e-9
assert wins >= 3
def test_inverse_recovers_input():
rng = random.Random(8)
n, w = 952, root(P, 3, 952)
for _ in range(10):
X = {k: rng.randrange(1, P) for k in rng.sample(range(n), 4)}
x = signal_mod(X, n, w, P)
got, ok, _ = sparse_dft(lambda j: x[j], (8, 7, 17), 4, w=w, p=P)
assert ok and signal_mod(got, n, w, P) == x
def test_edge_cases():
# zero signal
got, ok, _ = sparse_dft(lambda j: 0, (8, 7, 17), 3, w=root(P, 3, 952), p=P)
assert ok and got == {}
# n = 1 and n = 2
got, ok, _ = sparse_dft(lambda j: 5, (1,), 1, w=1, p=17)
assert ok and got == {0: 5}
got, ok, _ = sparse_dft(lambda j: (3, 1)[j], (2,), 2, w=16, p=17)
assert ok and got == {0: 4, 1: 2}
# constant signal: one frequency, X_0 = n c
got, ok, _ = sparse_dft(lambda j: 2, (8, 7, 17), 2, w=root(P, 3, 952), p=P)
assert ok and got == {0: 2 * 952}
# impulse: flat spectrum, not sparse; nothing wrong is reported
x = [1] + [0] * 951
got, ok, _ = sparse_dft(lambda j: x[j], (8, 7, 17), 3, w=root(P, 3, 952), p=P)
assert not ok and all(v == 1 for v in got.values())
# complex zero and a single tone
got, ok, _ = sparse_dft(lambda j: 0j, (7, 8, 9), 2)
assert ok and got == {}
got, ok, _ = sparse_dft(lambda j: cmath.exp(2j * math.pi * 100 * j / 504), (7, 8, 9), 1)
assert ok and set(got) == {100} and abs(got[100] - 504) < 1e-9
def test_bad_parameters():
with pytest.raises(ValueError):
sparse_dft(lambda j: 0, (4, 6), 1, w=root(P, 3, 8), p=P)
with pytest.raises(ValueError):
sparse_dft(lambda j: 0, (4, 3), 1, w=pow(2, 2, 13), p=13) # order 6, not 12
What the next level needs
- the tests did not run in this build