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
# 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