∿ LEARN DSPFrom signal to HF transceiver
Lab / lesson

The DFT by hand and the FFT as its fast algorithm

Calculate a short DFT from the definition, understand the order of the bins and compare it with the fast FFT algorithm.

← Fourier as a bank of correlators

What we do in this lesson

There is no new physics today — the DFT formula is the same as in the previous lesson. The goal of the lesson is to make the formula less abstract and to answer the practical questions that come up when you actually calculate a spectrum in code:

  1. We go through the DFT formula by hand on a toy example of four numbers, step by step, with no steps skipped.
  2. We find out what the output index kk means and in which order np.fft.fft returns the results — this is the first thing that confuses people who work with NumPy.
  3. We see why nobody calculates the DFT directly from the formula for long records.
  4. We understand the main idea of the FFT — it is not new mathematics, but a method to reuse results that are already calculated instead of calculating from zero.

Back to the correlator formula

In the previous lesson, one correlator tested one frequency: it multiplied the signal samples by the reference rotation and added the results. The DFT is simply a bank of such correlators that work at the same time, one for each frequency of the grid:

X[k]=∑n=0N−1x[n]e−j2πkn/N.X[k]=\sum_{n=0}^{N-1}x[n]e^{-j2\pi kn/N}.

Here nn is the index of the input sample in time, and kk is the index of the output bin in frequency. Not one symbol of the formula changed — we only repeat it for each kk from 00 to N−1N-1.

We calculate the DFT by hand on four samples

We take the smallest example in which we can still see each term. Let N=4N=4, and let the input signal be a usual real cosine, with exactly one period in four samples:

x[n]=cos⁡(2πn/4),x=[1, 0, −1, 0].x[n]=\cos(2\pi n/4),\qquad x=[1,\ 0,\ -1,\ 0].

Note: the signal x[n]x[n] itself is only four usual numbers, not vectors on a plane. But the reference signals inside each correlator are still complex — the same as in the previous lesson.

To find X[k]X[k] for a specific kk, multiply each sample x[n]x[n] by the related value of the reference rotation e−j2πkn/4e^{-j2\pi kn/4} and add the four results. Here the reference rotation makes an integer number of turns in 4 steps, so its values are the four points on the circle that we already know: 1, −j, −1, j1,\,-j,\,-1,\,j. We write them for all pairs (k,n)(k,n) in a table — these are all the reference signals that we need:

k\nk \backslash n0123
011111111
111−j-j−1-1jj
211−1-111−1-1
311jj−1-1−j-j

The table row with index kk is the reference signal of bin kk. Now, for each kk, we multiply this row by the input x=[1,0,−1,0]x=[1,0,-1,0] element by element and add. Note: we have x[1]=x[3]=0x[1]=x[3]=0, so these two terms become zero on their own, and we only need to calculate the contribution from n=0n=0 and n=2n=2:

  • k=0k=0: 1⋅1+1⋅(−1)=01\cdot1 + 1\cdot(-1) = 0
  • k=1k=1: 1⋅1+(−1)⋅(−1)=1+1=21\cdot1 + (-1)\cdot(-1) = 1+1=2
  • k=2k=2: 1⋅1+1⋅(−1)=1−1=01\cdot1 + 1\cdot(-1) = 1-1=0
  • k=3k=3: 1⋅1+(−1)⋅(−1)=1+1=21\cdot1 + (-1)\cdot(-1) = 1+1=2

We get X=[0, 2, 0, 2]X=[0,\,2,\,0,\,2]. All four numbers are real, with no imaginary part — this is not a coincidence: the input signal was real. And 22 is exactly what the rule from the previous lesson predicts for a cosine with amplitude 11: each of its two components (the clockwise and the counterclockwise rotation) gives the contribution NA/2=4⋅1/2=2NA/2 = 4\cdot1/2=2.

How to read the bin index k as a frequency

The four numbers X[0..3]X[0..3] alone tell nothing about frequencies in hertz — the formula fk=kFs/Nf_k=kF_s/N gives the link to FsF_s. We take Fs=8F_s=8 kS/s, as before:

kk0123
fkf_k, Hz00+2000+2000+4000+4000+6000+6000

The last number looks wrong: at Fs=8F_s=8 kS/s, the Nyquist frequency is ±4000\pm4000 Hz, and +6000+6000 Hz is above it. Here the same aliasing effect operates that we studied in Part 1: on a grid of 4 samples, a rotation of +6000+6000 Hz is the same as a rotation of −2000-2000 Hz. Thus np.fft.fft does not show frequencies above Nyquist — instead, it gives the second half of the bins negative frequency labels:

kk0123
Frequency00+2000+2000−4000-4000−2000-2000

This is the order in which np.fft.fft actually returns the array: first zero, then the positive frequencies in increasing order, then the negative frequencies — also in increasing order, starting from the Nyquist frequency. The Nyquist frequency itself (k=2k=2) has the label −4000-4000 Hz by convention, but it could equally be +4000+4000 Hz — it is the same rotation.

This order is not convenient for a plot — we want the frequencies to increase from left to right. This is what np.fft.fftshift() does: it changes the order of the array and calculates nothing again:

Frequency−4000-4000−2000-200000+2000+2000
XX00220022

Now we see the same as in the previous lesson: a cosine gives two equal peaks — at +2000+2000 and at −2000-2000 Hz. A correlator cannot tell a positive rotation from a negative one while the signal is real, so the energy always divides equally between the mirror frequencies.

Why we need a faster algorithm

For each of the NN bins, the DFT formula must go through all NN samples — this is approximately N2N^2 multiplications and additions. For our example with N=4N=4, this is 1616 operations, a very small number. But imagine a receiver that continuously makes the spectrum of an audio stream at 48 00048\,000 samples per second and updates the picture many times per second — at this scale, N2N^2 quickly becomes too large. For a record of length N=1 048 576N=1\,048\,576 (2202^{20}, approximately 1 second on some SDRs), the number of operations is N2≈1.1×1012N^2\approx1.1\times10^{12} — more than a trillion for one single spectrum.

The FFT is a fast algorithm that calculates the same sum as the DFT, but not directly. It is not a different transform and not an approximation: the FFT returns the same numbers as the DFT formula, but it gets to them by a different path.

The idea of the FFT: we use what is already calculated

The key observation is simple: if you divide a signal of length NN into two halves — the even and the odd samples — then each half is itself a signal of length N/2N/2, and you can calculate its DFT separately. When it is calculated, both outputs of the full DFT — X[k]X[k] and its “pair” X[k+N/2]X[k+N/2] — come from the same two half-length results, with a different sign on one term. Thus, instead of going through all NN samples again for each of the NN bins, we do the work only once on each half of the data and use it two times.

We write this as a formula. We write the DFT of the even samples as E[k]E[k] and the DFT of the odd samples as O[k]O[k] (both have length N/2N/2):

E[k]=∑m=0N/2−1x[2m] e−j2πkm/(N/2),O[k]=∑m=0N/2−1x[2m+1] e−j2πkm/(N/2).E[k]=\sum_{m=0}^{N/2-1}x[2m]\,e^{-j2\pi km/(N/2)},\qquad O[k]=\sum_{m=0}^{N/2-1}x[2m+1]\,e^{-j2\pi km/(N/2)}.

Then you can make the full DFT of length NN from them like this:

X[k]=E[k]+e−j2πk/NO[k],X[k+N/2]=E[k]−e−j2πk/NO[k].X[k]=E[k]+e^{-j2\pi k/N}O[k],\qquad X[k+N/2]=E[k]-e^{-j2\pi k/N}O[k].

This pair of equations is called a “butterfly” — both outputs come from the same pair of numbers E[k]E[k], O[k]O[k], only with a plus and a minus.

We check this on our example x=[1,0,−1,0]x=[1,0,-1,0] to see that the numbers do not change. The even samples are [1,−1][1,-1], the odd samples are [0,0][0,0]. For two numbers, we calculate the DFT with the same logic as above, but with N/2=2N/2=2:

  • E[0]=1+(−1)=0E[0]=1+(-1)=0,  E[1]=1⋅1+(−1)⋅(−1)=2\ E[1]=1\cdot1+(-1)\cdot(-1)=2
  • O[0]=O[1]=0O[0]=O[1]=0 — the odd samples are zero, so their DFT is also zero

We make the full result with the butterfly formula:

  • X[0]=E[0]+O[0]=0X[0]=E[0]+O[0]=0, X[2]=E[0]−O[0]=0\quad X[2]=E[0]-O[0]=0
  • X[1]=E[1]+e−jπ/2O[1]=2X[1]=E[1]+e^{-j\pi/2}O[1]=2, X[3]=E[1]−e−jπ/2O[1]=2\quad X[3]=E[1]-e^{-j\pi/2}O[1]=2

Again X=[0,2,0,2]X=[0,2,0,2] — the same as the result of the direct calculation. But instead of 1616 multiplications, it was sufficient to calculate two DFTs of two samples each and combine them with a pair of additions.

On such a small example, you cannot see the saving, but the method scales recursively: you can divide each of the two halves of length N/2N/2 in half again in the same way, and so on, until only pieces of one sample remain (the DFT of one number is the number itself). There are log⁡2N\log_2 N levels of division, and at each level the butterflies do approximately NN additions and multiplications in total. The total cost is O(Nlog⁡N)O(N\log N) instead of O(N2)O(N^2). For N=1 048 576N=1\,048\,576, this is Nlog⁡2N≈2×107N\log_2 N\approx2\times10^7 operations — approximately 50 00050\,000 times fewer than N2N^2.

Lab: we compare the DFT from the definition and the FFT

The script first calculates the example of this lesson again and compares it with np.fft.fft. Then it measures the time of the same operation with two methods at different NN. Before you run it, it is useful to predict: by how many times must the time of the “manual” method increase when NN doubles, and by how many times for the FFT?

uv run labs/03/02-dft-manual-fft.py --show

The left plot repeats the table above after fftshift: two peaks of 22 at ±2000\pm2000 Hz. The right plot is the run time as a function of NN on logarithmic axes. The “manual” method is a direct multiplication by the N×NN\times N matrix of reference signals — the same DFT formula, with no division into even and odd samples. Its time increases as N2N^2 and makes a straight line with a clear slope on the plot. The time of np.fft.fft increases as Nlog⁡NN\log N — an almost horizontal line in the same range.

The DFT from the definition and the FFT: the same result, a different cost

At N=4096N=4096, the manual method takes several thousand times more time than np.fft.fft, but both return the same numbers within rounding error (∼10−16\sim10^{-16}). In the rest of the course, we will always calculate the spectrum with np.fft.fft, and use the DFT formula only for explanations and checks on small examples.

Self-check

Take x=[0, 1, 0, −1]x=[0,\,1,\,0,\,-1] — this is sin⁡(2πn/4)\sin(2\pi n/4): the same period as in the lesson example, but shifted by a quarter turn. Use the table of reference signals to find X[0],X[1],X[2],X[3]X[0],X[1],X[2],X[3].

Explanation

Only the columns n=1n=1 and n=3n=3 contribute. X[0]=1−1=0X[0]=1-1=0; X[1]=(−j)−j=−2jX[1]=(-j)-j=-2j; X[2]=1⋅(−1)+(−1)⋅(−1)=0X[2]=1\cdot(-1)+(-1)\cdot(-1)=0; X[3]=j+j=2jX[3]=j+j=2j. The magnitudes are the same as for the cosine (22 at ±2000\pm2000 Hz), but the angles are different: −90∘-90^\circ and +90∘+90^\circ. The quarter-turn shift of the signal shows in the phase of the result.

For the signal from the lesson example (x=[1,0,−1,0]x=[1,0,-1,0], Fs=8F_s=8 kS/s), np.fft.fft returns the bins in the order 0,+2000,−4000,−20000,+2000,-4000,-2000 Hz. In which order are the frequencies after np.fft.fftshift, and what is the value of XX at +2000+2000 Hz?

Explanation

fftshift calculates nothing again — it changes only the order: −4000,−2000,0,+2000-4000,-2000,0,+2000 Hz with the values 0,2,0,20,2,0,2. The peak of 22 at +2000+2000 Hz is still there.

NN is doubled. By approximately how many times does the time of the DFT from the definition (matrix method) and the time of the FFT increase?

Explanation

For the DFT from the definition: (2N)2=4N2(2N)^2=4N^2. For the FFT: 2Nlog⁡2(2N)=2N(log⁡2N+1)2N\log_2(2N)=2N(\log_2N+1) — a little more than 2Nlog⁡2N2N\log_2N. For example, from N=1024N=1024 to 20482048, the FFT becomes 2⋅11/10=2.22\cdot11/10=2.2 times more expensive. On the lab plot, you see this as the different slopes of the lines.

The butterfly formula X[k+N/2]=E[k]−e−j2πk/NO[k]X[k+N/2]=E[k]-e^{-j2\pi k/N}O[k] uses the same E[k]E[k] and O[k]O[k] as the formula for X[k]X[k]. Why do we not need to calculate a DFT of length N/2N/2 again for the second half of the bins?

Explanation

E[k]E[k] and O[k]O[k] are DFTs of length N/2N/2, so E[k+N/2]=E[k]E[k+N/2]=E[k] and O[k+N/2]=O[k]O[k+N/2]=O[k]: for the half-length record, the reference signals of bins kk and k+N/2k+N/2 differ by an integer number of turns. And the factor e−j2π(k+N/2)/N=e−j2πk/N⋅e−jπe^{-j2\pi(k+N/2)/N}=e^{-j2\pi k/N}\cdot e^{-j\pi} differs from the previous one by its sign. Thus one pair E[k]E[k], O[k]O[k] gives two outputs at the same time.