|
| 1 | +# The Discrete Fourier Transform (DFT) |
| 2 | + |
| 3 | +```elixir |
| 4 | +Mix.install([ |
| 5 | + {:nx_signal, "~> 0.2"}, |
| 6 | + {:vega_lite, "~> 0.1"}, |
| 7 | + {:kino_vega_lite, "~> 0.1"} |
| 8 | +]) |
| 9 | +``` |
| 10 | + |
| 11 | +## What is the Discrete Fourier Transform (DFT)? |
| 12 | + |
| 13 | +This livebook will show you how to use the Discrete Fourier Transform (DFT) to analyze a signal. |
| 14 | + |
| 15 | +Suppose we have a periodic signal which we want to analyze. |
| 16 | + |
| 17 | +We will run a Fast Fourrier Transform, which is a fast algorithm to compute the DFT. |
| 18 | +It transforms a time-domain function into the frequency domain. |
| 19 | + |
| 20 | +<https://en.wikipedia.org/wiki/Discrete_Fourier_transform> |
| 21 | + |
| 22 | +## Building the signal |
| 23 | + |
| 24 | +Let's build a known signal that we will decompose and analyze later on. |
| 25 | + |
| 26 | +The signal will be the sum of two sinusoidal signals, one at 5Hz, and one at 20Hz with the corresponding amplitudes (1, 0.5). |
| 27 | + |
| 28 | +$f(t) = \sin(2\pi*5*t) + \frac{1}{2} \sin(2\pi*20*t)$ |
| 29 | + |
| 30 | +Suppose we can sample at `fs=50Hz` (meaning 50 samples per second) and our aquisition time is `duration = 1s`. |
| 31 | + |
| 32 | +We build a time series of `t` equally spaced points with the given `duration` interval with `Nx.linspace`. |
| 33 | + |
| 34 | +For each value of this serie (the discrete time $t$), we will synthesize the signal $f(t)$ through the module below. |
| 35 | + |
| 36 | +```elixir |
| 37 | +defmodule Signal do |
| 38 | + import Nx.Defn |
| 39 | + import Nx.Constants, only: [pi: 0] |
| 40 | + |
| 41 | + defn source(t) do |
| 42 | + f1 = 5 |
| 43 | + f2 = 20 |
| 44 | + Nx.sin(2 * pi() * f1 * t) + 1/2 * Nx.sin(2 * pi() * f2 * t) |
| 45 | + end |
| 46 | + |
| 47 | + def sample(opts) do |
| 48 | + fs = opts[:fs] |
| 49 | + duration = opts[:duration] |
| 50 | + t = Nx.linspace(0, duration, n: trunc(duration * fs), endpoint: false, type: {:f, 32}) |
| 51 | + source(t) |
| 52 | + end |
| 53 | +end |
| 54 | +``` |
| 55 | + |
| 56 | +We sample our signal at fs=50Hz during 1s: |
| 57 | + |
| 58 | +```elixir |
| 59 | +fs = 50; duration= 1 |
| 60 | + |
| 61 | +sample = Signal.sample(fs: fs, duration: 1) |
| 62 | +``` |
| 63 | + |
| 64 | +## Analyzing the signal with the DFT |
| 65 | + |
| 66 | +Because our signal contains many periods of the underlying function, the DFT results will contain some noise. |
| 67 | +This noise can stem both from the fact that we're likely cutting of the signal in the middle of a period |
| 68 | +and from the fact that we have a specific frequency resolution which ends up grouping our individual components into frequency bins. |
| 69 | +The latter isn't really a problem as we have chosen `fs` to be [fast enough](https://en.wikipedia.org/wiki/Nyquist%E2%80%93Shannon_sampling_theorem). |
| 70 | + |
| 71 | +> The number at the index $i$ of the DFT results gives an approximation of the amplitude and phase of the sampled signal at the frequency $i$. |
| 72 | +
|
| 73 | +In other words, doing `Nx.fft(sample)` returns a list of numbers indexed by the frequency. |
| 74 | + |
| 75 | +```elixir |
| 76 | +dft = Nx.fft(sample) |
| 77 | +``` |
| 78 | + |
| 79 | +We will limit our study points to the first half of `dft` because it is symmetrical on the upper half. |
| 80 | +The phase doesn't really matter to us because we don't wish to reconstruct the signal, nor find possible discontinuities, |
| 81 | +so we'll use `Nx.abs` to obtain the absolute values at each point. |
| 82 | + |
| 83 | +```elixir |
| 84 | +n = Nx.size(dft) |
| 85 | + |
| 86 | +max_freq_index = div(n, 2) |
| 87 | + |
| 88 | +amplitudes = Nx.abs(dft)[0..max_freq_index] |
| 89 | + |
| 90 | +# the frequency bins, "n" of them spaced by fs/n=1 unit: |
| 91 | +frequencies = NxSignal.fft_frequencies(fs, fft_length: n)[0..max_freq_index] |
| 92 | + |
| 93 | +data1 = %{ |
| 94 | + frequencies: Nx.to_list(frequencies), |
| 95 | + amplitudes: Nx.to_list(amplitudes) |
| 96 | +} |
| 97 | + |
| 98 | +VegaLite.new(width: 700, height: 300) |
| 99 | +|> VegaLite.data_from_values(data1) |
| 100 | +|> VegaLite.mark(:bar, tooltip: true) |
| 101 | +|> VegaLite.encode_field(:x, "frequencies", |
| 102 | + type: :quantitative, |
| 103 | + title: "frequency (Hz)", |
| 104 | + scale: [domain: [0, 50]] |
| 105 | +) |
| 106 | +|> VegaLite.encode_field(:y, "amplitudes", |
| 107 | + type: :quantitative, |
| 108 | + title: "amplitutde", |
| 109 | + scale: [domain: [0, 30]] |
| 110 | +) |
| 111 | +``` |
| 112 | + |
| 113 | +We see the peaks at 5Hz, 20Hz with their amplitudes (the second is half the first). |
| 114 | + |
| 115 | +This is indeed our synthesized signal 🎉 |
| 116 | + |
| 117 | +We can confirm this visual inspection with a peek into our data. We use `Nx.top_k` function. |
| 118 | + |
| 119 | +```elixir |
| 120 | +{values, indices} = Nx.top_k(amplitudes, k: 5) |
| 121 | + |
| 122 | +{ |
| 123 | + values, |
| 124 | + frequencies[indices] |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +### Visualizing the original signal and the Inverse Discrete Fourier Transform |
| 129 | + |
| 130 | +Let's visualize our incoming signal over 400ms. This correspond to 2 periods of our 5Hz component and 8 periods of our 20Hz component. |
| 131 | + |
| 132 | +We compute 200 points to have a smooth curve, thus every (400/200=) 2ms. |
| 133 | + |
| 134 | +We also add the reconstructed signal via the **Inverse Discrete Fourier Transform** available as `Nx.ifft`. |
| 135 | + |
| 136 | +This gives us 50 values spaced by 1000ms / 50 = 20ms. |
| 137 | + |
| 138 | +Below, we display them as a bar chart under the line representing the ideal signal. |
| 139 | + |
| 140 | +```elixir |
| 141 | +#----------- REAL SIGNAL |
| 142 | +# compute 200 points of the "real" signal during 2/5=400ms (twice the main period) |
| 143 | + |
| 144 | +t = Nx.linspace(0, 0.4, n: trunc(0.4 * 500)) |
| 145 | +sample = Signal.source(t) |
| 146 | + |
| 147 | +#----------- RECONSTRUCTED IFFT |
| 148 | +yr = Nx.ifft(dft) |> Nx.real() |
| 149 | +fs = 50 |
| 150 | +tr = Nx.linspace(0, 1, n: 1 * fs, endpoint: false) |
| 151 | + |
| 152 | +idx = Nx.less_equal(tr, 0.4) |
| 153 | +xr = Nx.select(idx, tr, :nan) |
| 154 | +yr = Nx.select(idx, yr, :nan) |
| 155 | +#---------------- |
| 156 | + |
| 157 | + |
| 158 | +data = %{ |
| 159 | + x: Nx.to_list(t), |
| 160 | + y: Nx.to_list(sample) |
| 161 | +} |
| 162 | + |
| 163 | +data_r = %{ |
| 164 | + yr: Nx.to_list(yr), |
| 165 | + xr: Nx.to_list(xr) |
| 166 | +} |
| 167 | + |
| 168 | +VegaLite.new(width: 600, height: 300) |
| 169 | +|> VegaLite.layers([ |
| 170 | + VegaLite.new() |
| 171 | + |> VegaLite.data_from_values(data) |
| 172 | + |> VegaLite.mark(:line, tooltip: true) |
| 173 | + |> VegaLite.encode_field(:x, "x", type: :quantitative, title: "time (ms)", scale: [domain: [0, 0.4]]) |
| 174 | + |> VegaLite.encode_field(:y, "y", type: :quantitative, title: "signal") |
| 175 | + |> VegaLite.encode_field(:order, "x"), |
| 176 | + VegaLite.new() |
| 177 | + |> VegaLite.data_from_values(data_r) |
| 178 | + |> VegaLite.mark(:bar, tooltip: true) |
| 179 | + |> VegaLite.encode_field(:x, "xr", type: :quantitative, scale: [domain: [0, 0.4]]) |
| 180 | + |> VegaLite.encode_field(:y, "yr", type: :quantitative, title: "reconstructed") |
| 181 | + |> VegaLite.encode_field(:order, "xr") |
| 182 | +]) |
| 183 | +``` |
| 184 | + |
| 185 | +We see that during 400ms, we have 2 periods of a longer period signal, and 8 of a shorter and smaller perturbation period signal. |
0 commit comments