Algorithm Search⌘K

Real-valued FFT in Python

Computes the DFT of real input using a half-length complex FFT, exploiting conjugate symmetry.

File
real_valued_fft.py, 169 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
real_valued_fft.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/real-valued-fft/. The authors are listed in prov/C9SKTZ.jsonl.

"""DFT of real input of length n through one complex FFT of length n/2.

Over C the root is w = exp(-2*pi*i/n). The same code runs exactly over the
ring Z_p[i] = Z_p[t]/(t^2 + 1), p an odd prime, where conjugation is
a + b i -> a - b i and the "real" numbers are Z_p; there w must satisfy
w**n = 1, w**(n/2) = -1 and w * conj(w) = 1.
"""

from __future__ import annotations

import cmath
import math
from typing import Union


class GaussMod:
    """The element a + b i of Z_p[i]."""

    __slots__ = ("a", "b", "p")

    def __init__(self, a: int, b: int, p: int) -> None:
        self.a, self.b, self.p = a % p, b % p, p

    def __add__(self, o: "GaussMod") -> "GaussMod":
        return GaussMod(self.a + o.a, self.b + o.b, self.p)

    def __sub__(self, o: "GaussMod") -> "GaussMod":
        return GaussMod(self.a - o.a, self.b - o.b, self.p)

    def __mul__(self, o: "GaussMod | int") -> "GaussMod":
        if isinstance(o, int):
            return GaussMod(self.a * o, self.b * o, self.p)
        return GaussMod(self.a * o.a - self.b * o.b, self.a * o.b + self.b * o.a, self.p)

    __rmul__ = __mul__

    def __eq__(self, o: object) -> bool:
        return isinstance(o, GaussMod) and (self.a, self.b, self.p) == (o.a, o.b, o.p)

    def __hash__(self) -> int:
        return hash((self.a, self.b, self.p))

    def __repr__(self) -> str:
        return f"GaussMod({self.a}, {self.b}, {self.p})"

    def conjugate(self) -> "GaussMod":
        return GaussMod(self.a, -self.b, self.p)

    def __pow__(self, e: int) -> "GaussMod":
        r, b = GaussMod(1, 0, self.p), self
        if e < 0:
            n = (self.a * self.a + self.b * self.b) % self.p
            b, e = b.conjugate() * pow(n, -1, self.p), -e
        while e:
            if e & 1:
                r = r * b
            b, e = b * b, e >> 1
        return r


Num = Union[complex, GaussMod]


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 _check(n: int) -> None:
    if n < 1 or n & (n - 1):
        raise ValueError("the length must be a power of two")


def _powers(n: int, w: GaussMod | None, sign: int) -> list[Num]:
    """W[k] = w**(sign*k) for 0 <= k <= n/2 (w = exp(-2*pi*i/n) over C)."""
    if w is None:
        return [_unit(-sign * k, n) for k in range(n // 2 + 1)]
    if w ** n != GaussMod(1, 0, w.p) or (n > 1 and w ** (n // 2) != GaussMod(-1, 0, w.p)):
        raise ValueError("w must satisfy w**n = 1 and w**(n/2) = -1")
    if w * w.conjugate() != GaussMod(1, 0, w.p):
        raise ValueError("w must satisfy w * conj(w) = 1")
    base = w if sign > 0 else w ** -1
    out, cur = [], GaussMod(1, 0, w.p)
    for _ in range(n // 2 + 1):
        out.append(cur)
        cur = cur * base
    return out


def _fft(z: list[Num], table: list[Num]) -> None:
    """In-place radix-2 DIT FFT of length h; table[k] is the k-th power of a root of order h, k < h/2."""
    h = len(z)
    j = 0
    for i in range(1, h):
        bit = h >> 1
        while j & bit:
            j ^= bit
            bit >>= 1
        j |= bit
        if i < j:
            z[i], z[j] = z[j], z[i]
    size = 1
    while size < h:
        step = h // (2 * size)
        for s in range(0, h, 2 * size):
            for k in range(size):
                u, v = z[s + k], table[k * step] * z[s + k + size]
                z[s + k], z[s + k + size] = u + v, u - v
        size *= 2


def real_fft(x: list, w: GaussMod | None = None) -> list[Num]:
    """Return X_0, ..., X_{n/2} of the DFT of the real sequence x, n = len(x) a power of two."""
    n = len(x)
    _check(n)
    if w is None:
        one, i, half = 1 + 0j, 1j, 0.5
    else:
        p = w.p
        one, i, half = GaussMod(1, 0, p), GaussMod(0, 1, p), (p + 1) // 2
    if n == 1:
        return [one * x[0]]
    W = _powers(n, w, 1)  # W[k] = w**k
    h = n // 2  # @line L1
    z = [one * x[2 * j] + i * x[2 * j + 1] for j in range(h)]  # @line L2
    _fft(z, [W[2 * k] for k in range(h // 2)])  # @line L3
    z.append(z[0])  # @line L4  Z_h = Z_0
    minus_i_half = i * (-half if w is None else (w.p - half))
    out = []
    for k in range(h + 1):  # @line L5
        c = z[h - k].conjugate()
        e = (z[k] + c) * half  # @line L6
        o = (z[k] - c) * minus_i_half  # @line L7  divide by 2i
        out.append(e + W[k] * o)  # @line L8
    return out  # @line L9


def inverse_real_fft(X: list[Num], w: GaussMod | None = None) -> list:
    """Return the real sequence x of length n = 2 (len(X) - 1) with real_fft(x, w) = X."""
    n = 2 * (len(X) - 1) if len(X) > 1 else 1
    _check(n)
    if w is None:
        i, half = 1j, 0.5
    else:
        p = w.p
        i, half = GaussMod(0, 1, p), (p + 1) // 2
    if n == 1:
        return [X[0].real if w is None else X[0].a]
    h = n // 2
    Winv = _powers(n, w, -1)  # w**(-k)
    Z = []
    for k in range(h):
        c = X[h - k].conjugate()
        e = (X[k] + c) * half
        o = (X[k] - c) * half * Winv[k]
        Z.append(e + i * o)
    _fft(Z, [Winv[2 * k] for k in range(h // 2)])
    inv_h = 1 / h if w is None else pow(h, -1, w.p)
    out = []
    for v in Z:
        v = v * inv_h
        out.extend([v.real, v.imag] if w is None else [v.a, v.b])
    return out
test_real_valued_fft.py 135 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/real-valued-fft/. The authors are listed in prov/C9SKTZ.jsonl.

import cmath
import math
import random

import pytest

from real_valued_fft import GaussMod, inverse_real_fft, real_fft

U = 2.0**-53
# (prime, primitive root): 3 is a primitive root of 17 and 998244353, 5 of 97.
# All three are 1 mod 4; 127 is 3 mod 4, where Z_p[i] is the field of p^2 elements.
PRIMES = [(17, 3), (97, 5), (998244353, 3)]


def gauss_root(p, g, n):
    """An element of Z_p[i] of order n with w * conj(w) = 1, for p = 1 mod 4 and n | p - 1."""
    s = pow(g, (p - 1) // 4, p)  # a square root of -1
    a = pow(g, (p - 1) // n, p)
    u, v = a, pow(a, -1, p)  # the images of w under Z_p[i] -> Z_p x Z_p
    re = (u + v) * pow(2, -1, p) % p
    im = (u - v) * pow(2 * s, -1, p) % p
    return GaussMod(re, im, p)


def gauss_root_3mod4(p, n):
    """An element of order n in the norm-one subgroup of the field Z_p[i], n | p + 1."""
    for a in range(1, p):
        z = GaussMod(a, 1, p) ** (p - 1)
        w = z ** ((p + 1) // n)
        if n == 1 or w ** (n // 2) == GaussMod(-1, 0, p):
            return w
    raise AssertionError


def naive(x, w, p):
    n = len(x)
    one = GaussMod(1, 0, p)
    out = []
    for k in range(n // 2 + 1):
        acc = GaussMod(0, 0, p)
        for j, v in enumerate(x):
            acc = acc + (one * v) * (w ** (j * k % n))
        out.append(acc)
    return out


def naive_complex(x):
    n = len(x)
    return [sum(v * cmath.exp(-2j * math.pi * ((j * k) % n) / n) for j, v in enumerate(x)) for k in range(n // 2 + 1)]


def norm(v):
    return math.sqrt(sum(abs(t) ** 2 for t in v))


def tol(n, ref):
    return 64 * (n.bit_length() + 1) * U * max(norm(ref), 1.0)


def test_example():
    y = real_fft([1.0, 2.0, 3.0, 4.0])
    assert y == [10, -2 + 2j, -2]
    assert inverse_real_fft(y) == [1, 2, 3, 4]


def test_edges():
    assert real_fft([5.0]) == [5]
    assert real_fft([3.0, 5.0]) == [8, -2]
    for n in [1, 2, 4, 8, 16]:
        assert all(v == 0 for v in real_fft([0.0] * n))
        assert all(abs(v - 1) < 1e-12 for v in real_fft([1.0] + [0.0] * (n - 1)))
        y = real_fft([3.0] * n)
        assert abs(y[0] - 3 * n) < 1e-12 and all(abs(v) < 1e-12 * n for v in y[1:])
    with pytest.raises(ValueError):
        real_fft([1.0, 2.0, 3.0])
    with pytest.raises(ValueError):
        real_fft([])


def test_exact_mod_primes():
    rng = random.Random(20261107)
    for p, g in PRIMES:
        n = 1
        while (p - 1) % n == 0 and n <= 64:
            w = gauss_root(p, g, n)
            for _ in range(6):
                x = [rng.randrange(p) for _ in range(n)]
                y = real_fft(x, w)
                assert y == naive(x, w, p)
                assert inverse_real_fft(y, w) == x
            n *= 2


def test_exact_3_mod_4():
    rng = random.Random(4)
    p = 127
    for n in [1, 2, 4, 8, 16, 32, 64, 128]:
        w = gauss_root_3mod4(p, n)
        for _ in range(4):
            x = [rng.randrange(p) for _ in range(n)]
            y = real_fft(x, w)
            assert y == naive(x, w, p)
            assert inverse_real_fft(y, w) == x


def test_bad_root():
    with pytest.raises(ValueError):
        real_fft([1, 2, 3, 4], GaussMod(4, 0, 17))  # 4 has order 4 but 4 * conj(4) = 16


def test_random_complex():
    rng = random.Random(77)
    for t in range(0, 11):
        n = 2**t
        for _ in range(3):
            x = [rng.uniform(-1, 1) for _ in range(n)]
            ref = naive_complex(x)
            y = real_fft(x)
            assert norm([a - b for a, b in zip(y, ref)]) <= tol(n, ref)
            back = inverse_real_fft(y)
            assert norm([a - b for a, b in zip(back, x)]) <= tol(n, x)


def test_large():
    rng = random.Random(3)
    n = 2**14
    x = [rng.gauss(0, 1) for _ in range(n)]
    y = real_fft(x)
    back = inverse_real_fft(y)
    assert norm([a - b for a, b in zip(back, x)]) <= tol(n, x)
    full = y + [v.conjugate() for v in y[-2:0:-1]]
    assert abs(norm(full) ** 2 - n * norm(x) ** 2) <= 1e-9 * n * norm(x) ** 2

What the next level needs

  • the tests did not run in this build

Read the full page about Real-valued FFT