Algorithm Search⌘K

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
sparse_fourier_transform.pyRawGitHub
# 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

Read the full page about Sparse Fourier transform