高速フーリエ変換#

高速フーリエ変換は離散フーリエ変換の高速なアルゴリズム

1の原始N乗根による表現#

1の原始N乗根

ωNを次のように定義する。

ωN:=ei2π/N

これはN次方程式zN1=0の解であり、次の関係を満たす。

ωNN=1,ωNk1,k=1,2,,N1

上の式を満たすωN1の原始N乗根 と呼ぶ。

ωNを用いると、離散フーリエ変換の式

fl=k=0N1Fkei2πkl/N,Fk=1Nl=0N1flei2πkl/N

は次のように書くことができる

fl=k=0N1FkωNkl,Fk=1Nl=0N1flωNkl

Tip

フーリエ変換は線形変換

上記だけだと「ei2π/NωNに置き換えただけじゃないの?」と思ってしまいがちだが、 1つの係数flFkとの間の変換ではなく、f0,,fN1F0,,FN1の間の 線形変換 になっていることが大切。(離散フーリエではなく連続のフーリエ変換も数学的には線形変換)

ベクトルと行列の形で書くと以下になる。

(f0f1f2fN1)=(11111ωNωN2ωNN11ωN2ωN4ωN2(N1)1ωNN1ωN2(N1)ωN(N1)(N1))(F0F1F2FN1)
(F0F1F2FN1)=1N(11111ωN1ωN2ωN(N1)1ωN2ωN4ωN2(N1)1ωN(N1)ωN2(N1)ωN(N1)(N1))(f0f1f2fN1)

離散フーリエ変換を計算するには、長さNの任意の数列a0,a1,a2,,aN1に対して

bk=l=0N1alωNkl,k=0,1,2,,N1

となる数列b0,b1,b2,,bN1が計算できればよい

{Fk} から {fl}を計算するには、bk=fl, ak=Fkとおけば

fl=k=0N1FkωNkl

が成り立つ。

{fl} から {Fk} を計算するには、ωN=ωN1 であるから

Fk=1Nl=0N1flωNkl

高速フーリエ変換#

Nが2の冪乗であれば、離散フーリエ変換の計算が効率化される(具体的にはO(N) から O(NlogN)になる)これを使うのが高速フーリエ変換。

単位円での表現#

1の原始N乗根ωNを次々と冪乗すると、複素平面の単位円周上を回転する

Hide code cell source
import cmath
import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))
ax.set_aspect('equal', 'box')

# 軸を描画
ax.arrow(-1.2, 0, 2.4, 0, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)
ax.arrow(0, -1.2, 0, 2.4, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)

ax.text(1.3, 0, "1", fontsize=14, color='black')
ax.text(0, 1.3, "i", fontsize=14, color='black')

ax.set_aspect('equal', adjustable='datalim')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.set(xticks=[], yticks=[])
ax.set(xlim=(-1.2, 1.2), ylim=(-1.2, 1.2))

# 単位円
unit_circle = plt.Circle((0, 0), 1, color='gray', fill=False)
ax.add_artist(unit_circle)

# 偶数のN
n = 8
omega_n = cmath.exp(2j * cmath.pi / n)

ax.plot([0, omega_n.real], [0, omega_n.imag], color="gray", linestyle='-', linewidth=0.8)
t = 2 * np.pi / n
theta = np.linspace(0, t, 100)
x = np.cos(theta) * 0.2
y = np.sin(theta) * 0.2
ax.plot(x, y, color="gray", linewidth=1)

for k in range(n):
    omega = omega_n**k
    ax.plot(omega.real, omega.imag, 'o', color="gray")
    ax.text(omega.real + 0.02, omega.imag + 0.02, rf'$\omega_N^{k}$', fontsize=10, color="dimgray", ha='left')

plt.show()
Ignoring fixed x limits to fulfill fixed data aspect with adjustable data limits.
../../_images/f5955815a4d112fa0c6ab92173e2812955793fbc3b74c6329f18f8d516d09416.png
定理

Nが偶数のとき、次の関係が成り立つ

ωNN/2=1,ωNN/2+1=ωN,ωNN/2+2=ωN2,,ωNN1=ωNN/21
証明

定義 ωN:=ei2π/N より、

ωNN/2=(ei2π/N)N/2=eiπ=1

である。ゆえに

ωNN/2+k=ωNN/2ωNk=ωNk

例えばN=8のときは、ωNN/2=ωN4=1=ωN0であり、ωNN/2+1=ωN5=ωN1=ωNである。以下同様。

単位円で描くとちょうど反対側に位置する。

そのため、 N/2個のωNについてのみ考えればよい ことがわかる。

Hide code cell source
import cmath
import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))
ax.set_aspect('equal', 'box')

# 軸を描画
ax.arrow(-1.2, 0, 2.4, 0, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)
ax.arrow(0, -1.2, 0, 2.4, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)

ax.text(1.3, 0, "1", fontsize=14, color='black')
ax.text(0, 1.3, "i", fontsize=14, color='black')

ax.set_aspect('equal', adjustable='datalim')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.set(xticks=[], yticks=[])
ax.set(xlim=(-1.2, 1.2), ylim=(-1.2, 1.2))

# 単位円
unit_circle = plt.Circle((0, 0), 1, color='gray', fill=False)
ax.add_artist(unit_circle)

# 偶数のN
n = 8
omega_n = cmath.exp(2j * cmath.pi / n)

ax.plot([0, omega_n.real], [0, omega_n.imag], color="gray", linestyle='-', linewidth=0.8)
t = 2 * np.pi / n
theta = np.linspace(0, t, 100)
x = np.cos(theta) * 0.2
y = np.sin(theta) * 0.2
ax.plot(x, y, color="gray", linewidth=1)

for k in range(n // 2):
    omega = omega_n**k
    ax.plot(omega.real, omega.imag, 'o', color="gray")
    ax.text(omega.real + 0.02, omega.imag + 0.02, rf'$\omega_N^{k}$', fontsize=10, color="dimgray", ha='left')

    omega = -1 * omega
    k2 = k + n // 2
    ax.plot(omega.real, omega.imag, 'o', color="steelblue")
    ax.text(omega.real + 0.02, omega.imag + 0.02, rf'$\omega_N^{k2} = -\omega_N^{k}$', fontsize=10, color="steelblue", ha='left')

plt.show()
Ignoring fixed x limits to fulfill fixed data aspect with adjustable data limits.
../../_images/f494e2caf2bbad8459683cdd04235e0fa40e6edd5f22e786607e46af7454fd3a.png
定理

Nが偶数のとき、ωN2は1の原始N/2乗根として表せる。

ωN2=ωN/2
証明

ωN2=(ei2π/N)2=ei4π/N=ei2π/(N/2)=ωN/2 となる

前出の定理より、ωN2k=ωN/2kとなっている。例えばωN4=ωN/22となっている

Hide code cell source
import cmath
import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))
ax.set_aspect('equal', 'box')

# 軸を描画
ax.arrow(-1.2, 0, 2.4, 0, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)
ax.arrow(0, -1.2, 0, 2.4, head_width=0.05, head_length=0.1, fc='black', ec='black', length_includes_head=True)

ax.text(1.3, 0, "1", fontsize=14, color='black')
ax.text(0, 1.3, "i", fontsize=14, color='black')

ax.set_aspect('equal', adjustable='datalim')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['bottom'].set_visible(False)
ax.set(xticks=[], yticks=[])
ax.set(xlim=(-1.2, 1.2), ylim=(-1.2, 1.2))

# 単位円
unit_circle = plt.Circle((0, 0), 1, color='gray', fill=False)
ax.add_artist(unit_circle)

# 偶数のN
n = 4
omega_n = cmath.exp(2j * cmath.pi / n)

ax.plot([0, omega_n.real], [0, omega_n.imag], color="gray", linestyle='-', linewidth=0.8)
t = 2 * np.pi / n
theta = np.linspace(0, t, 100)
x = np.cos(theta) * 0.2
y = np.sin(theta) * 0.2
ax.plot(x, y, color="gray", linewidth=1)

for k in range(n):
    omega = omega_n**k
    ax.plot(omega.real, omega.imag, 'o', color="gray")
    ax.text(omega.real + 0.02, omega.imag + 0.02, rf'$\omega_{{N/2}}^{k}$', fontsize=10, color="dimgray", ha='left')

plt.show()
Ignoring fixed x limits to fulfill fixed data aspect with adjustable data limits.
../../_images/055bbd7409dd693ee480756cb274167b98f998971fc862f28c66327ee8b1ee73.png

N1 次多項式

f(x)=a0+a1x+a2x2++aN1xN1=l=0N1alxl

を定義すると

bk=l=0N1alωNkl,k=0,1,2,,N1

bk=f(ωNk),k=0,1,2,,N1

と書くことができる。

つまり、 複素平面上の単位円周のN等分点でN1次多項式f(x)を計算すればb0,b1,,bNが得られる

この計算をFFTN[f(x)]と表す。

FFTN[f(x)]={f(1),f(ωN),f(ωN2),,f(ωNN1)}

間引き#

上記の多項式は次のように書き直せる

f(x)=a0+a1x+a2x2++aN1xN1=a0+a2x2+a4x4++aN2xN2+x(a1+a3x2+a5x4++aN1xN2)=p(x2)+xq(x2)

ただし、

{p(x)=a0+a2x+a4x2++aN2xN/21q(x)=a1+a3x+a5x2++aN1xN/21

とおいた。この係数の 間引き は高速フーリエ変換における重要な計算手順の一つ。

p(x2)xが複素平面上の単位円周のN等分点を順にたどって1周するとき x2はそれらを1つおきに進んで2周する 。そのため 前半のN/2個のみを計算すればよい から、

FFTN[p(x2)]={p(1),p(ωN2),p(ωN4),,p(ωNN2)}

となる。

また「Nが偶数のときωN2=ωN/2」という定理より以下のように書き直せる。

FFTN[p(x2)]={p(1),p(ωN/2),p(ωN/22),,p(ωN/2N/21)}

データ数N/2個の離散フーリエ変換 を計算すればいい。

q(x2)についても同様に得られる。

FFTN[q(x2)]={q(1),q(ωN/2),q(ωN/22),,q(ωN/2N/21)}

これらN/2個の離散フーリエ変換で得られるものたちを以下のようにFFTN/2と表記する

FFTN[p(x2)]=FFTN/2[p(x)],FFTN[q(x2)]=FFTN/2[q(x)]

バタフライ#

f(x)は「Nが偶数のとき、ωNN/2=1,ωNN/2+1=ωN,ωNN/2+2=ωN2,,ωNN1=ωNN/21」という定理と f(x)=p(x2)+xq(x2) により、以下のように計算できる。

{f(ωNk)=p(ωN/2k)+ωNkq(ωN/2k),k=0,1,,N/21f(ωNN/2+k)=p(ωN/2k)ωNkq(ωN/2k),k=0,1,,N/21

この計算を バタフライ と呼び、式中のωNk回転因子(ひねり因子) と呼ぶ。

考察:なぜ引数はωN/2kなのに回転因子はωNkなのか?

f(x)=p(x2)+xq(x2)なのと同様の形になっていると思われる。

考察:f(ωNN/2+k)=p(ωN/2k)ωNkq(ωN/2k)ωNkとマイナスがつくのはなぜ?

前述の定理より、ωNN/2+k=ωNk

高速フーリエ変換#

FFTNは係数の間引き(Decimation-In-Time, DIT)DNを行ってFFTN/2を実行し、バタフライBNを施す。

間引きによってFFTN/4,FFTN/8,FFTN/16,と分解していくと最終的にFFT1の計算になり、0次式(定数)の計算になる。

その後バタフライによって統合していく。

そのためFFTNは間引きの繰り返しとバタフライの繰り返しで分割統治法によって計算できる。

この方法を 高速フーリエ変換 (FFT)と呼ぶ。

Pythonの実装#

by chatGPT

import cmath

def fft_recursive(x: list[complex]) -> list[complex]:
    """
    Args: 入力信号(複素数リスト)
    Returns: FFTの結果(周波数成分Fのリスト)(複素数のリスト)
    """
    N = len(x)
    if N <= 1:  # 再帰の終了条件
        return x

    # 偶数番目と奇数番目に分割
    even = fft_recursive(x[0::2])
    odd = fft_recursive(x[1::2])

    # バタフライ操作
    T = [cmath.exp(-2j * cmath.pi * k / N) * odd[k] for k in range(N // 2)]
    return [even[k] + T[k] for k in range(N // 2)] + \
           [even[k] - T[k] for k in range(N // 2)]
Hide code cell source
# テスト用データ
input_signal = [0, 1, 2, 3, 4, 5, 6, 7]  # サンプルデータ

# 信号を複素数に変換
input_signal = [complex(x) for x in input_signal]

# FFTを計算
fft_result = fft_recursive(input_signal)

# 結果を表示
print("FFTの結果:")
for i, value in enumerate(fft_result):
    print(f"X[{i}] = {value:.3f}")

# NumPyによるFFT計算
import numpy as np
fft_result = np.fft.fft(input_signal)

# 結果を表示
print("NumPy FFT結果:")
for i, value in enumerate(fft_result):
    print(f"X[{i}] = {value:.3f}")
FFTの結果:
X[0] = 28.000+0.000j
X[1] = -4.000+9.657j
X[2] = -4.000+4.000j
X[3] = -4.000+1.657j
X[4] = -4.000+0.000j
X[5] = -4.000-1.657j
X[6] = -4.000-4.000j
X[7] = -4.000-9.657j
NumPy FFT結果:
X[0] = 28.000+0.000j
X[1] = -4.000+9.657j
X[2] = -4.000+4.000j
X[3] = -4.000+1.657j
X[4] = -4.000+0.000j
X[5] = -4.000-1.657j
X[6] = -4.000-4.000j
X[7] = -4.000-9.657j
Hide code cell source
import numpy as np
import matplotlib.pyplot as plt

# サンプル信号を作成(サンプリング周波数: 1000 Hz)
fs = 1000  # サンプリング周波数
t = np.linspace(0, 1, fs, endpoint=False)  # 時間軸
frequencies = [100, 200]
signal = np.sin(2 * np.pi * frequencies[0] * t) + 0.5 * np.sin(2 * np.pi * frequencies[1] * t)

# FFTを計算
N = len(signal)
fft_result = np.fft.fft(signal)
frequencies = np.fft.fftfreq(N, 1/fs)  # 周波数軸

# 振幅スペクトルを取得
amplitude = np.abs(fft_result) / N  # 振幅正規化

# 結果をプロット
plt.figure(figsize=(8, 4))
plt.plot(frequencies[:N//2], amplitude[:N//2])  # 正の周波数成分のみプロット
plt.title("Frequency Spectrum")
plt.xlabel("Frequency (Hz)")
plt.ylabel("Amplitude")
plt.grid()
plt.show()
../../_images/94e654589cff875537765955fe23a005febd835fc3bd277945a6bfac845f360f.png