DFT and FFT
Overview
Section titled “Overview”| Module | M12.4 · build · Python · Pass 12 · 4 h |
| You build | python/tinyllm/sig/fft.py: dft_matrix, a mixed-radix fft (radices 4, 2, 3, 5) and ifft, rfft and irfft from one half-size complex FFT, fft_convolve (the convolution theorem), and dct2_ortho (the DCT-II via an FFT) |
| Contract | course/contracts/py/tinyllm/sig/fft.pyi |
| Tests | course/tests/M12.4/test_fft.py (what they check: section 4), golden values from course/oracle/M12.4/fft_numpy.py (numpy.fft, scipy.fft.dct) in course/fixtures/M12.4/fft_numpy.npz |
| Needs | no module code. Reading: M00.2 (Euler’s formula, roots of unity), M03.3 (orthogonal and unitary matrices), M09.3 (error bounds that grow with ) |
| Used by | M12.5 (stft calls rfft, istft calls irfft) · later data.10 (dct2_ortho in pHash, with B14’s data group) |
| Milestone | MS-P12 (the multimodal gate) |
| Optional depth | Cooley and Tukey, “An algorithm for the machine calculation of complex Fourier series” (1965); Van Loan, Computational Frameworks for the Fast Fourier Transform, ch. 1 to 2; Makhoul, “A fast cosine transform in one and two dimensions” (1980) |
Key Takeaways
Section titled “Key Takeaways”- The DFT is multiplication by ; is unitary, so the transform keeps energy (Parseval) and its inverse is the conjugate transform divided by (
test_dft_matrix_is_unitary,test_inverse_and_parseval). - An FFT splits into interleaved subsequences, transforms them, and recombines them with twiddle factors and one size- DFT; mixed radices cover (
test_fft_matches_numpy,test_fft_equals_the_dft_matrix). - A real signal’s spectrum is conjugate symmetric, so
rfftcomputes bins from one complex FFT of size (test_hand_example_dft,test_rfft_and_irfft_match_numpy). - The convolution theorem gives circular convolution; zero-padding to
len(a) + len(b) - 1makes it linear (test_fft_convolve_is_linear_convolution). - The orthonormal DCT-II is a reordered FFT (
test_dct_matches_scipy_and_is_orthogonal).
How to work this chapter
Section titled “How to work this chapter”ol start M12.4 # stubs python/tinyllm/sig/fft.py into your repool tests M12.4 # read the test catalog first: rung R0, you write no tests hereol check M12.4 # exit code is the verdictol diff M12.4 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Whisper’s frontend (L14.1) takes a Fourier transform of every 25 ms frame of audio: 3000 frames of 400 samples for each 30 s clip. Multiplying each frame by a DFT matrix costs 160000 complex multiplies per frame; an FFT does it in about 4000. More importantly, 400 is not a power of two, so the radix-2 FFT of most textbooks cannot transform it at all, and padding to 512 changes the frequencies of the bins and breaks parity with Whisper. This module builds the transform the STFT (M12.5) and the perceptual hash in data.10 both stand on, and checks it against numpy to within a frozen rounding bound.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| transform length | int | |
| primitive -th root of unity | complex | |
| , | DFT matrix | complex128[n, n] |
| spectrum of | complex128[n] | |
| , | radix and subsequence length | ints |
| DFT of the subsequence | complex128[m] | |
| half length for the real FFT | int | |
| float64 machine epsilon, | float |
2.1 The DFT and its matrix
Section titled “2.1 The DFT and its matrix”The DFT writes samples as coefficients of complex sinusoids,
The rows of are orthogonal: , a geometric series of roots of unity that is if and 0 otherwise. So satisfies : it is unitary (M03.3), it keeps lengths, and that is Parseval’s identity . The inverse is the conjugate transform: , which is how ifft reuses fft. dft_matrix reduces before computing the phase, so every entry is evaluated at an angle in .
2.2 The convolution theorem
Section titled “2.2 The convolution theorem”For circular convolution , the DFT turns convolution into a product: . Linear convolution, what numpy.convolve computes, has length ; padding both inputs with zeros to any makes the wrap-around terms vanish, so the circular result equals the linear one. fft_convolve picks the smallest whose prime factors are 2, 3, 5.
2.3 Cooley-Tukey, any radix
Section titled “2.3 Cooley-Tukey, any radix”Split into interleaved subsequences , , with DFTs of size . Writing with , :
So the size- DFT is size- DFTs, a multiply by the twiddle factors , and a size- DFT for each . Recursing until costs operations, for small radices. With radices 4, 2, 3, 5 every works: , Whisper’s frame. A size with another prime factor (7, 11, …) is a ValueError, not a silent fallback. Each level adds a few roundings, so the error grows like : the tests freeze the bound per entry (M09.3).
2.4 Real input: half the work
Section titled “2.4 Real input: half the work”If is real, , so the bins determine everything. Pack the even samples as real parts and the odd ones as imaginary parts, , and take one complex FFT of size . The DFTs of the even and odd samples untangle as
and for , with the Nyquist bin because . irfft runs the steps backwards; the imaginary parts of and must be zero for a real signal, so it drops them first, as numpy does.
2.5 The DCT-II via an FFT
Section titled “2.5 The DCT-II via an FFT”pHash (data.10) takes the orthonormal DCT-II, with and . Makhoul’s trick reorders the input into (even samples forward, odd samples backward); then with . The scales make the DCT matrix orthogonal, which the test checks by transforming the identity.
3. Worked example by hand
Section titled “3. Worked example by hand”, , :
| 0 | ||
| 1 | ||
| 2 | ||
| 3 |
, so rfft returns . Parseval: and . Through the packing of 2.4: , ; then , , so and . This is test_hand_example_dft.
4. The interface
Section titled “4. The interface”def dft_matrix(n, unitary=False) -> NDArray: ...def fft(x) -> NDArray: ... # last axis, n = 2^a 3^b 5^cdef ifft(X) -> NDArray: ...def rfft(x) -> NDArray: ... # n // 2 + 1 bins, one FFT of size n/2def irfft(X, n) -> NDArray: ...def fft_convolve(a, b) -> NDArray: ... # == numpy.convolve(a, b)def dct2_ortho(x, axis=-1) -> NDArray: ... # == scipy.fft.dct(x, 2, norm='ortho')What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_dft | unit, smoke | section 3’s spectrum, its rfft half, and Parseval | sign and packing conventions |
test_dft_matrix_is_unitary | property | for several ; one entry by formula; rejected | the transform keeps energy |
test_fft_matches_numpy | golden | 19 sizes up to 512, including 240 and 400, and a batch, within the frozen bound | M12.5 and L14.1 parity |
test_fft_equals_the_dft_matrix | differential | mixed-radix sizes against | the FFT is only fast multiplication |
test_rfft_and_irfft_match_numpy | golden | rfft and irfft for every even size; irfft drops imaginary DC and Nyquist parts | every STFT frame |
test_inverse_and_parseval | property | ifft(fft(x)) = x, Parseval, irfft(rfft(r)) = r | istft and energy bookkeeping |
test_fft_convolve_is_linear_convolution | differential | equals numpy.convolve, including lengths that need padding to 12 | the convolution theorem, done right |
test_dct_matches_scipy_and_is_orthogonal | golden | scipy’s orthonormal DCT-II on vectors and a 32 x 32 block; orthogonality | data.10’s pHash |
test_unsupported_sizes_raise | boundary | prime factors 7 and 11, empty input, odd rfft, complex rfft, wrong bin count; inputs untouched | misconfigured frames fail loudly |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| 1. twiddle factors with | every size above 4 wrong; spectra mirrored | test_fft_matches_numpy, test_fft_equals_the_dft_matrix (mutant s01) |
| 2. Nyquist bin as | the last rfft bin wrong | test_hand_example_dft, test_rfft_and_irfft_match_numpy (mutant s02) |
| 3. ifft without the | round trips scaled by | test_inverse_and_parseval (mutant s03) |
| 4. multiplying unpadded spectra | circular wrap-around; wrong length | test_fft_convolve_is_linear_convolution (mutant s04) |
| 5. scaling bin 0 like the others in the DCT | the DC coefficient too large; not orthogonal | test_dct_matches_scipy_and_is_orthogonal (mutant s05) |
| 6. keeping an imaginary DC part in irfft | a spurious alternating component | test_rfft_and_irfft_match_numpy (mutant s06) |
| 7. splitting into contiguous blocks instead of interleaved subsequences | a different transform that still has the right shape | test_fft_matches_numpy, test_fft_equals_the_dft_matrix (mutant s07) |
accepting in dft_matrix | an empty matrix instead of an error | test_dft_matrix_is_unitary (mutant m01) |
| accepting extra bins in irfft | the trailing bins silently ignored | test_unsupported_sizes_raise (mutant m02) |
| accepting complex input in rfft | the imaginary part discarded | test_unsupported_sizes_raise (mutant m03) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M00.2 | roots of unity from Euler’s formula (reading) |
| Back | M03.3 | unitary matrices keep lengths (reading) |
| Back | M09.3 | the error bound (reading) |
| Forward | M12.5 | stft runs rfft on every frame; istft runs irfft |
| Forward | data.10 | pHash’s 32 x 32 dct2_ortho (with B14’s data group) |
| Forward | S-M12 | DFT properties, the convolution theorem, FFT operation counts |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
fft | pocketfft (numpy, scipy) | radices up to 11 plus Bluestein for large primes, cache-friendly plans | numpy/fft/_pocketfft_umath.cpp |
fft | FFTW | plans tuned by measurement (“wisdom”), codelets generated by genfft | Frigo and Johnson, Proc. IEEE 2005 |
rfft in Rust | rustfft and realfft (the engine, L15.1) | SIMD butterflies; the same half-size packing | realfft crate docs |
fft in C | optional L14.6 | mixed-radix real FFT plans for the Whisper frontend | c/src/kernels/audio.c |