離散フーリエ変換(DFT)の理論と実装を解説

離散フーリエ変換(DFT: Discrete Fourier Transform)は、有限個のデータ点に対してフーリエ変換を適用する手法です。コンピュータで信号処理を行う際には、連続信号をサンプリングして離散データとして扱う必要があるため、DFT は実用上不可欠な道具です。

イメージとしては、連続フーリエ変換の「デジタル版」です。連続的な積分を有限個の離散点の和で近似することで、コンピュータ上で計算可能になります。さらに、DFT を高速に計算するアルゴリズムが FFT(高速フーリエ変換)であり、$O(N^2)$ の計算量を $O(N \log N)$ に削減することで、現代のデジタル信号処理を実現可能にしました。本記事では、DFT の理論的背景からスクラッチ実装まで一気通貫で解説します。

本記事の内容

  • 連続フーリエ変換から離散フーリエ変換への離散化
  • DFTの数学的定義とDFT行列
  • 逆DFT
  • FFTアルゴリズムの原理(Cooley-Tukey)
  • 計算量の解析: $O(N^2)$ から $O(N \log N)$ へ
  • Pythonでのスクラッチ実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

DFTとは

DFT(離散フーリエ変換)とは、$N$ 個の離散データ列 $\{x_0, x_1, \dots, x_{N-1}\}$ を、$N$ 個の周波数成分 $\{X_0, X_1, \dots, X_{N-1}\}$ に変換する操作です。

大雑把に言うと、「時系列データのデジタル信号がどのような周波数成分を持っているかを、コンピュータで計算できる形にしたもの」です。MP3やJPEGの圧縮、スペクトルアナライザ、音声認識など、現代のデジタル技術の至るところで使われています。

連続から離散への離散化

サンプリングによる離散化

連続関数 $f(t)$ をサンプリング間隔 $\Delta t$ で $N$ 点サンプリングして得られるデータ列を考えます。

$$ x_n = f(n \Delta t), \quad n = 0, 1, \dots, N-1 $$

連続フーリエ変換の積分を台形公式的に離散和で近似すると、

$$ \begin{align} F(\nu) &= \int_{-\infty}^{\infty} f(t) e^{-i2\pi\nu t} \, dt \\ &\approx \sum_{n=0}^{N-1} f(n\Delta t) \, e^{-i2\pi\nu \cdot n\Delta t} \, \Delta t \\ &= \Delta t \sum_{n=0}^{N-1} x_n \, e^{-i2\pi\nu n\Delta t} \end{align} $$

周波数も離散化して $\nu_k = k / (N\Delta t) = k \Delta \nu$($k = 0, 1, \dots, N-1$)とすると、

$$ F(\nu_k) \approx \Delta t \sum_{n=0}^{N-1} x_n \, e^{-i2\pi k n / N} $$

$\Delta t$ のスケーリング因子を除いた部分がDFTの定義となります。

数学的定義

DFT(離散フーリエ変換)

$N$ 点のデータ列 $\{x_0, x_1, \dots, x_{N-1}\}$ のDFTは、以下で定義されます。

$$ \begin{equation} X_k = \sum_{n=0}^{N-1} x_n \, e^{-i 2\pi kn / N}, \quad k = 0, 1, \dots, N-1 \end{equation} $$

逆DFT(IDFT)

$$ \begin{equation} x_n = \frac{1}{N} \sum_{k=0}^{N-1} X_k \, e^{i 2\pi kn / N}, \quad n = 0, 1, \dots, N-1 \end{equation} $$

回転因子

$W_N = e^{-i2\pi/N}$ と定義すると、DFTは簡潔に書けます。

$$ X_k = \sum_{n=0}^{N-1} x_n \, W_N^{kn} $$

$W_N$ は 回転因子(twiddle factor)と呼ばれ、複素平面上の単位円を $N$ 等分する点に対応します。

DFT行列

DFTは行列とベクトルの積として表現できます。

$$ \begin{equation} \bm{X} = \bm{W}_N \bm{x} \end{equation} $$

ここで、$\bm{x} = (x_0, x_1, \dots, x_{N-1})^T$、$\bm{X} = (X_0, X_1, \dots, X_{N-1})^T$ であり、DFT行列 $\bm{W}_N$ は、

$$ \bm{W}_N = \begin{pmatrix} 1 & 1 & 1 & \cdots & 1 \\ 1 & W_N & W_N^2 & \cdots & W_N^{N-1} \\ 1 & W_N^2 & W_N^4 & \cdots & W_N^{2(N-1)} \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ 1 & W_N^{N-1} & W_N^{2(N-1)} & \cdots & W_N^{(N-1)^2} \end{pmatrix} $$

DFT行列の $(k, n)$ 成分は $(\bm{W}_N)_{kn} = W_N^{kn} = e^{-i2\pi kn/N}$ です。

DFT行列の性質

DFT行列は以下の性質を持ちます。

  1. ユニタリ性: $\bm{W}_N^{-1} = \frac{1}{N} \bm{W}_N^*$($\bm{W}_N^*$ は共役転置)
  2. 対称性: $\bm{W}_N = \bm{W}_N^T$(転置と等しい)
  3. 周期性: $W_N^{k+N} = W_N^k$

ユニタリ性から、逆DFTが $\bm{x} = \frac{1}{N} \bm{W}_N^* \bm{X}$ であることがわかります。

具体例: $N = 4$ のDFT行列

$W_4 = e^{-i2\pi/4} = e^{-i\pi/2} = -i$ なので、

$$ \bm{W}_4 = \begin{pmatrix} 1 & 1 & 1 & 1 \\ 1 & -i & -1 & i \\ 1 & -1 & 1 & -1 \\ 1 & i & -1 & -i \end{pmatrix} $$

DFTの性質

DFTも連続フーリエ変換と同様の性質を持ちます。

周期性

$$ X_{k+N} = X_k, \quad x_{n+N} = x_n $$

DFTは本質的に周期 $N$ の周期列を扱います。

パーセバルの定理(離散版)

$$ \sum_{n=0}^{N-1} |x_n|^2 = \frac{1}{N} \sum_{k=0}^{N-1} |X_k|^2 $$

時間領域のエネルギーと周波数領域のエネルギーが保存されます。

畳み込み定理(離散版)

$$ \text{DFT}[x * y] = X_k \cdot Y_k $$

ここで $*$ は 巡回畳み込み(circular convolution)を表します。

$$ (x * y)_n = \sum_{m=0}^{N-1} x_m \, y_{(n-m) \bmod N} $$

FFTアルゴリズム

素朴なDFTの計算量

DFTの定義式 $X_k = \sum_{n=0}^{N-1} x_n W_N^{kn}$ を愚直に計算すると、各 $X_k$ に $N$ 回の乗算と加算が必要で、$k = 0, \dots, N-1$ の $N$ 個を計算するため、全体で $O(N^2)$ の計算量が必要です。

Cooley-Tukey FFTの原理

FFT(高速フーリエ変換)は、DFTの対称性と周期性を利用して計算量を $O(N \log N)$ に削減するアルゴリズムです。最も一般的な Cooley-Tukey アルゴリズム(基数2)では、$N$ が2の冪乗であるときに適用可能です。

$N$ 点のDFTを、偶数番目と奇数番目のデータに分割して考えます。

$$ \begin{align} X_k &= \sum_{n=0}^{N-1} x_n W_N^{kn} \\ &= \sum_{m=0}^{N/2-1} x_{2m} W_N^{k \cdot 2m} + \sum_{m=0}^{N/2-1} x_{2m+1} W_N^{k(2m+1)} \\ &= \sum_{m=0}^{N/2-1} x_{2m} W_{N/2}^{km} + W_N^k \sum_{m=0}^{N/2-1} x_{2m+1} W_{N/2}^{km} \end{align} $$

ここで $W_N^{2m} = W_{N/2}^m$ という性質を使いました。偶数番目のデータのDFTを $E_k$、奇数番目のデータのDFTを $O_k$ とおくと、

$$ \begin{equation} X_k = E_k + W_N^k O_k \end{equation} $$

さらに、$W_N^{k+N/2} = -W_N^k$ という性質(半回転で符号反転)から、

$$ \begin{equation} X_{k+N/2} = E_k – W_N^k O_k \end{equation} $$

これが バタフライ演算 と呼ばれる計算パターンです。$N$ 点のDFTが $N/2$ 点のDFT 2つに分割され、これを再帰的に適用することで、計算量が $O(N \log_2 N)$ になります。

計算量の比較

アルゴリズム 計算量 $N = 1024$ での演算回数
素朴なDFT $O(N^2)$ $\sim 10^6$
FFT $O(N \log N)$ $\sim 10^4$

$N = 1024$ の場合、FFT は素朴な DFT に比べて約100倍高速です。$N$ が大きくなるほど差は拡大します。

Pythonでの実装

DFTのスクラッチ実装

import numpy as np
import time

def dft_naive(x):
    """素朴なDFTの実装 O(N^2)"""
    N = len(x)
    X = np.zeros(N, dtype=complex)
    for k in range(N):
        for n in range(N):
            X[k] += x[n] * np.exp(-2j * np.pi * k * n / N)
    return X

def idft_naive(X):
    """素朴な逆DFTの実装"""
    N = len(X)
    x = np.zeros(N, dtype=complex)
    for n in range(N):
        for k in range(N):
            x[n] += X[k] * np.exp(2j * np.pi * k * n / N)
    return x / N

# テスト
x = np.array([1, 2, 3, 4], dtype=complex)
X_naive = dft_naive(x)
X_numpy = np.fft.fft(x)

print("Input:", x)
print("DFT (naive):", X_naive)
print("DFT (numpy):", X_numpy)
print("Max error:", np.max(np.abs(X_naive - X_numpy)))

# 逆変換の確認
x_recovered = idft_naive(X_naive)
print("Recovered:", x_recovered)
print("Recovery error:", np.max(np.abs(x - x_recovered)))

DFT行列による実装

import numpy as np

def dft_matrix(N):
    """N次のDFT行列を生成する"""
    n = np.arange(N)
    k = n.reshape(-1, 1)
    W = np.exp(-2j * np.pi * k * n / N)
    return W

def dft_by_matrix(x):
    """DFT行列を用いたDFTの実装"""
    N = len(x)
    W = dft_matrix(N)
    return W @ x

# テスト: N = 4 の場合
N = 4
W4 = dft_matrix(N)
print("DFT Matrix (N=4):")
print(np.round(W4, 3))

x = np.array([1, 2, 3, 4], dtype=complex)
X = dft_by_matrix(x)
print("\nDFT result:", X)
print("NumPy FFT:", np.fft.fft(x))

# ユニタリ性の確認: W * W^H = N * I
WH = np.conj(W4.T)
product = W4 @ WH
print("\nW * W^H / N:")
print(np.round(product / N, 3))

FFTのスクラッチ実装(Cooley-Tukey)

import numpy as np
import time

def fft_recursive(x):
    """再帰的FFTの実装(Cooley-Tukey, 基数2)"""
    N = len(x)

    # ベースケース
    if N == 1:
        return x.copy()

    # Nが2の冪でない場合はエラー
    if N % 2 != 0:
        raise ValueError("N must be a power of 2")

    # 偶数番目と奇数番目に分割
    E = fft_recursive(x[::2])   # 偶数インデックス
    O = fft_recursive(x[1::2])  # 奇数インデックス

    # 回転因子
    W = np.exp(-2j * np.pi * np.arange(N // 2) / N)

    # バタフライ演算
    X = np.zeros(N, dtype=complex)
    X[:N//2] = E + W * O     # 前半
    X[N//2:] = E - W * O     # 後半

    return X

# テスト
N = 1024
x = np.random.randn(N) + 1j * np.random.randn(N)

# 正しさの検証
X_fft = fft_recursive(x)
X_numpy = np.fft.fft(x)
print(f"Max error (FFT vs NumPy): {np.max(np.abs(X_fft - X_numpy)):.2e}")

# 速度比較
sizes = [64, 128, 256, 512, 1024]
times_naive = []
times_fft = []
times_numpy = []

for N in sizes:
    x = np.random.randn(N) + 1j * np.random.randn(N)

    # 素朴なDFT
    start = time.time()
    _ = dft_naive(x)
    times_naive.append(time.time() - start)

    # FFT (自作)
    start = time.time()
    _ = fft_recursive(x)
    times_fft.append(time.time() - start)

    # NumPy FFT
    start = time.time()
    _ = np.fft.fft(x)
    times_numpy.append(time.time() - start)

print("\nComputation time comparison:")
print(f"{'N':>6s} | {'Naive DFT':>12s} | {'FFT (ours)':>12s} | {'NumPy FFT':>12s}")
print("-" * 52)
for i, N in enumerate(sizes):
    print(f"{N:>6d} | {times_naive[i]:>12.6f} | {times_fft[i]:>12.6f} | {times_numpy[i]:>12.6f}")

計算量の可視化

import numpy as np
import matplotlib.pyplot as plt

N_values = np.arange(1, 1025)
O_N2 = N_values ** 2
O_NlogN = N_values * np.log2(np.maximum(N_values, 1))

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(N_values, O_N2, 'r-', linewidth=2, label='$O(N^2)$ (Naive DFT)')
ax.plot(N_values, O_NlogN, 'b-', linewidth=2, label='$O(N \\log N)$ (FFT)')
ax.set_xlabel('N (number of data points)')
ax.set_ylabel('Number of operations')
ax.set_title('Computational Complexity: DFT vs FFT')
ax.legend(fontsize=12)
ax.grid(True, alpha=0.3)
ax.set_xlim(0, 1024)
ax.set_ylim(0, 200000)
plt.savefig("dft_vs_fft_complexity.png", dpi=150, bbox_inches="tight")
plt.show()

実信号のスペクトル解析

import numpy as np
import matplotlib.pyplot as plt

# サンプリング設定
fs = 1000        # サンプリング周波数 [Hz]
T = 1.0          # 信号の長さ [s]
N = int(fs * T)  # データ点数
t = np.arange(N) / fs

# 信号の生成: 3つの正弦波 + ノイズ
np.random.seed(42)
f1, f2, f3 = 50, 120, 300  # 周波数 [Hz]
signal = 1.0 * np.sin(2 * np.pi * f1 * t) \
       + 0.5 * np.sin(2 * np.pi * f2 * t) \
       + 0.3 * np.sin(2 * np.pi * f3 * t) \
       + 0.5 * np.random.randn(N)

# FFTの計算
X = np.fft.fft(signal)
freq = np.fft.fftfreq(N, 1 / fs)

# 片側スペクトル(正の周波数のみ)
mask = freq >= 0
freq_positive = freq[mask]
amplitude = 2 * np.abs(X[mask]) / N  # 振幅の正規化

fig, axes = plt.subplots(2, 1, figsize=(12, 8))

# 時間波形
axes[0].plot(t[:200], signal[:200], 'b-', linewidth=0.8)
axes[0].set_xlabel('Time [s]')
axes[0].set_ylabel('Amplitude')
axes[0].set_title('Time Domain Signal')
axes[0].grid(True, alpha=0.3)

# 振幅スペクトル
axes[1].plot(freq_positive, amplitude, 'r-', linewidth=0.8)
axes[1].set_xlabel('Frequency [Hz]')
axes[1].set_ylabel('Amplitude')
axes[1].set_title('Amplitude Spectrum (FFT)')
axes[1].set_xlim(0, 500)
axes[1].grid(True, alpha=0.3)

# ピーク位置のアノテーション
for f_peak in [f1, f2, f3]:
    idx = np.argmin(np.abs(freq_positive - f_peak))
    axes[1].annotate(f'{f_peak} Hz', xy=(freq_positive[idx], amplitude[idx]),
                     xytext=(freq_positive[idx] + 20, amplitude[idx] + 0.05),
                     arrowprops=dict(arrowstyle='->', color='black'),
                     fontsize=11)

plt.tight_layout()
plt.savefig("fft_spectrum_analysis.png", dpi=150, bbox_inches="tight")
plt.show()

FFTにより、ノイズに埋もれた信号から50 Hz、120 Hz、300 Hzの3つの周波数成分を明確に検出できます。各ピークの高さは元の信号の振幅に比例しています。

まとめ

本記事では、離散フーリエ変換(DFT)の理論と実装について解説しました。

  • DFTは、連続フーリエ変換をサンプリングされた離散データに適用するための定式化です
  • DFTは行列とベクトルの積 $\bm{X} = \bm{W}_N \bm{x}$ として表現でき、DFT行列はユニタリ行列です
  • 素朴なDFTの計算量は $O(N^2)$ ですが、FFT(Cooley-Tukey)により $O(N \log N)$ に削減されます
  • FFTの核心は、偶数・奇数インデックスへの分割と回転因子の周期性を利用したバタフライ演算です
  • パーセバルの定理と巡回畳み込み定理はDFTでも成立します

次のステップとして、以下の記事も参考にしてください。