Pythonでフーリエ変換

はじめに

何かデータをフーリエ変換したくなることがある。例えば先生から「そのデータ、フーリエ変換してみたら?」と言われた時とか。なんとなくフーリエ変換がどういうものかは知っていて、PythonとかのライブラリにFFTがあるからデータを食わせればすぐ変換できるということも知っているが、なんとなく定義に自信が無い、そんな時もあるだろう。

そういう場合は、厳密にフーリエ変換がわかるような単純な系について実際にデータを食わせてみて、理論値と一致することを確認するのが望ましい。しかし、実際にやってみると「アレ?」と思うことが結構ある。以下ではPythonでFFTをする時の注意点等を紹介する1

ガウス分布

ガウス分布のフーリエ変換

まずはフーリエ変換の定義から確認しておこう。ある関数f(x)のフーリエ変換f^(k)

f^(k)=f(x)eikxdx

で与えられる。逆変換と対称にするために2πで割る流儀もあるが、工学で使うフーリエ変換では、フーリエ変換はそのまま、逆フーリエ変換に2πをつける流儀が多いと思われる。

簡単にフーリエ変換できて、かつそれなりに非自明な例としてガウス分布が挙げられる。こんな分布を考えよう。

f(x)=12πσ2exp(x22σ2)

これは平均0、標準偏差σであるようなガウス分布である。これをフーリエ変換しよう。定義につっこんで計算するだけだ。

f^(k)=f(x)eikx =12πσ2exp(x22σ2)eikxdx

この計算にはちょっとした工夫が必要だ。まず指数関数の中身を平方完成する。

x22σ2ikx=12σ2(x+iσk)2σ2k22

上式の右辺第一項を指数の肩に乗せたものはガウス積分であり、1/2πσ2とキャンセルする。したがって最終的に

f^(k)=exp(σ2k22)

つまり、ガウス分布をフーリエ変換したものは、規格化されておらず、かつ標準偏差が逆数になったようなガウス分布になる。つまり、実空間で「幅の広い」ガウス分布は、波数空間では「幅の狭い」ガウス分布になる(逆もまた然り)。

上記をPythonで確認してみよう。

ガウス分布のFFT

Pythonでデータをフーリエ変換するのは簡単で、配列をnumpy.fft.fttに渡すだけで良い。まずはガウス分布を作ってみよう。

import numpy as np
from math import sqrt, pi, exp
import matplotlib.pyplot as plt

N = 4096            # サンプル数
s = N/256           # 標準偏差

y = []
for i in range(N):
  x = i - N/2
  v = exp(-x**2/(2.0*s**2))/(sqrt(2*pi)*s)
  y.append(v)

plt.plot(y)
plt.xlim([N/2-100,N/2+100])
plt.show()

これは、データ点をN=4096とし、N/2を中心に、N/256=16を標準偏差とするガウス分布で、プロットするとこんな感じになる。

gauss1.png

さて、配列yにデータが入っているので、フーリエ変換をするにはそれをnumpy.fft.fftに突っ込めば良い。突っ込んだものをfkとしてsその絶対値プロットしてみよう。

fk = np.fft.fft(y)
plt.plot(fk)

するとこうなる。

gauss2.png

なんか変なデータになっていますね。これがどうなっているかを理解して、ちゃんと理論値と比較できるようになりましょう、というのが本稿の目的だ。

まず、numpy.fft.fftが行うのは離散フーリエ変換(DFT)である。離散フーリエ変換では、配列のどのインデックスがどの座標に対応しているかを気を付けなければならない。

fftに一次元配列を渡すと、デフォルト(オプション無し)では、インデックスiと、座標xが等しいものとして計算される。したがってx=iである。

さて、N個のデータのDFTのデータは、やはりN個になるため、結果は要素N個の一次元配列となる。その順番とkの対応はnumpy.fft.fftfreqにデータ点数を与えることで得られる。取得してプロットしてみよう。

freq = np.fft.fftfreq(N)
plt.plot(freq)

すると、こんな感じになる。

k.png

波数kの取り得る値は-1/2から1/2まで1/N刻みであり、それが

0,1/N,2/N,,1/2,1/2,1/2+1/N,,1/N

といった順番で並んでいることがわかる。一般にフーリエ変換はk=0付近にピークがあり、そこから離れると急激に振幅が小さくなる場合が多く、またkの正負で対称なので、最初の半分しかデータが必要がない。なので、フーリエ変換したデータの最初の半分だけを見ればよいことになる。

さて、フーリエ変換した配列のインデックスとkの関係がわかったので、それをx軸、y軸としてプロットすれば、ガウス分布になるはずだ。やってみよう。

plt.xlim([-0.05,0.05])
plt.scatter(freq,fk,s=1)

gauss3.png

ガウス分布っぽいものは出てきたが、まだおかしい。実は、計算の都合上、フーリエ変換f^(k)と、離散フーリエ変換の係数Akの間には

f^(k)=(1)kAk

と、(1)kがかかってしまう。今回のケースでは、絶対値をとってしまうのが楽だ。

plt.xlim([-0.05,0.05])
plt.scatter(freq,np.abs(fk),s=1)

gauss4.png

期待されるガウス分布になった。では、これが理論曲線に一致するか確認してみよう。理論曲線は以下の通りだった。

f^(k)=exp(σ2k22)

これを重ねて表示してみよう。

theory = [exp(-k**2*s**2/2.0) for k in freq]    # 理論曲線
fig, ax = plt.subplots()
ax.scatter(freq, np.abs(fk), s = 1, c = "red")  # DFTの結果
ax.scatter(freq,theory, s = 1, c="blue")        # 理論曲線
plt.xlim([-0.1,0.1])
plt.show()

結果はこうなる。

comparison1.png

赤がフーリエ変換の結果、青が理論曲線だ。高さとピーク位置はあってそうだが、分散がおかしいことがわかる。

こういう場合は変にがんばって検索するより、公式ドキュメントにあたった方が早い。NumPy公式ドキュメントのFFTの説明を読むと、DFTの定義は以下のようになっている(少しnotationを変えている)。

Ak=m=0N1amexp(2πimkN)

これと連続版におけるフーリエ変換の定義を見比べよう。

f^(k)=f(x)exp(ikx)dx

すると、連続版のxがDFTのmに、連続版のkは、DFTにおける2πk/Nに対応することがわかる。先ほどの例では、単にk/Nとしてしまっていたため、波数空間の縮尺が2πだけずれている。これを修正してみよう。

theory = [exp(-(2*pi*k)**2*s**2/2.0) for k in freq] # kに2piをかける
fig, ax = plt.subplots()
ax.scatter(freq, np.abs(fk), s = 1, c = "red")
ax.scatter(freq,theory, s = 1, c="blue")
plt.xlim([-0.1,0.1])
plt.show()

comparison2.png

めでたく一致した。

時系列(指数減衰)

次に、時系列の解析をしてみよう。以下のような力学系を考える。

v˙=γv

速度に比例する抵抗をうけて運動する物体の速度の時間発展を表すものだ。オイラー法で時間発展させて、時系列を作るにはこんな感じのコードになるだろう。

import numpy as np
from math import sqrt, pi,
import matplotlib.pyplot as plt

N = 4096            # サンプル数
h = 0.1          # 時間刻み
gamma = 0.2
v0 = 2.0

v = v0
f = []
for _ in range(N):
  v = v -gamma * v * h
  f.append(v)

できた時系列をプロットしてみよう。

plt.plot(f)
plt.show()

exp1.png

さて、今回は刻み幅が1ではなくhなので、周波数空間が変わる。それを見ておこう。

freq = np.fft.fftfreq(N, d=h)
plt.plot(freq)

exp_freq.png

先ほどと異なり、1/(2h)から1/(2h)になっていることに注意。時間刻みを実空間でh倍したら、周波数空間では1/h倍になる。

フーリエ変換して、絶対値を表示してみよう。

fw = np.fft.fft(f)
plt.scatter(freq, np.abs(fw),s=1)

exp2.png

この理論曲線をもとめてみよう。まずは、離散フーリエ変換の定義式に戻る。

Ak=m=0N1amexp(2πimkN)

Akが係数、amが入力の時系列だ。時間刻みをhとしているので、時系列amと入力信号f(t)の関係は、

am=f(mh)

である。また、時系列の時間は0からNhまでなので、T=NhとしてNTを用いて書き直そう。

Ak=m=0N1f(mh)exp(2πimhkT)

これを積分で近似すると、

Ak=1h0Tf(t)exp(2πitkT)dt

さて、今回のケースでは

f(t)=v0eγt

なので、突っ込んで計算し、Tが大きいと思って「おつり」を無視すると、

Ak=1hv0γ+2πik/T

さて、これをnumpy.fft.fftfreqで得たx座標とともにプロットするのだが、それが1/(Nh)刻みなので、w=kh/Tとすると2

A(w)=1hv0γ+2πiw

となる。こいつの絶対値をとったものが、

plt.scatter(freq, np.abs(fw),s=1)

でプロットされるものに一致するはずだ。見てみよう。

fig, ax = plt.subplots()
theory = [v0/sqrt((w*2*pi)**2 + gamma**2)/h for w in freq] # 理論値
ax.set_xscale("log")
ax.set_yscale("log")
plt.xlim([1,0.5/h])
plt.scatter(freq, np.abs(fw), s = 1)
plt.scatter(freq, theory, s=1)

exp_theory.png

概ね一致した。なお、グラフの右の方でずれてくるのはDFTの周期境界条件のためだ。

時系列(ランジュバン方程式)

最後にランジュバン方程式を考えてみよう。こんな運動方程式を考える。

v˙=γv+R

これは揺動力を受けながら運動する物体の速度の時間発展を表している。ここで、R(t)は、以下を満たす白色雑音である。

R(t)=0

R(t)R(t)=δ(tt)

時系列を作るには、白色雑音を離散化しなくてはならない。確率微分方程式はいろいろ面倒くさいのだが、ここでは一番簡単なEuler-Maruyama方を採用しよう。時間刻みをhとした時、R(t)を時間hだけ積分したら、平均0、分散hとなるようなガウス分布に従う乱数になるとする。これにより、以下のようなコードに落とすことができる。

import numpy as np
from math import sqrt, pi, cos
import matplotlib.pyplot as plt

N = 4096         # サンプル数
h = 0.1          # 時間刻み
gamma = 0.2      # 減衰係数
v0 = 0.0         # 初速

v = v0
f = []
for _ in range(N):
  v = v -gamma * v * h + np.random.normal(0, sqrt(h))
  f.append(v)

後の便利のために初速を0にしてある。また、numpy.random.normalは、分散ではなく標準偏差を与える方式のため、hを与えている。

さて、まずは時系列をプロットしてみよう。

plt.plot(f)
plt.show()

langevin1.png

ちゃんとランダムウォークになっているっぽいですね。

フーリエ変換して係数の絶対値をプロットしてみよう。

fw = np.fft.fft(f)
freq = np.fft.fftfreq(N, d=h)
plt.scatter(freq, np.abs(fw),s=1)

langevin2.png

このグラフを係数も含めて求めましょう、というのが目的だ。

まず、先ほどの運動方程式をラプラス変換で解いてしまおう。

sv~=γv~+R~

より、

v~=R~s+γ

となる(ここでゴミがでないように初速を0にしておいた)。ここから、

v(t)=0teγtR(t)dt

となる。これは畳み込み積分であるから、フーリエ変換すれば、それぞれのフーリエ変換の積に分解できる。指数関数のフーリエ変換は先ほど求めた通りなので、問題は揺動力R(t)の変換だ。

定義に突っ込んで計算してみよう。R(t)を時間刻みhで離散化したものをrmとしよう。ただしrm=R(mh)である。こいつのDFT係数は

Ak=m=0N1rmexp(2πimkN)

である。さて、ここでパワースペクトル

|Ak|2=AkAk

を考える。ただしAkAkの複素共役である。式を素直に書き下すと、

AkAk=m=0N1n=0N1rmrnexp(2πi(mn)kN)

となる。ここで、両辺を乱数についてアンサンブル平均をとる。

AkAk=m=0N1n=0N1rmrnexp(2πi(mn)kN)

すると、異なる時刻のrmは相関を持たず、平均はゼロなので、m=nの項しか残らない。したがって和が一つ消え、さらに後ろの指数関数が消えるので、

AkAk=m=0N1rm2

rmは平均0、分散hのガウス分布に従う確率変数であったから、

rm2=h

以上から、

AkAk=Nh

となる。以上から、v(t)のフーリエ変換v(ω)の絶対値は

|v^(ω)|=v^(ω)v^(ω)=1h|1γ+2πiw|Nh=1hNhγ2+(2πw)2

となる。プロットしてみよう。

fig, ax = plt.subplots()
theory = [sqrt(N*h)/sqrt((2*pi*w)**2 + gamma**2)/h for w in freq] # 理論値
ax.set_xscale("log")
ax.set_yscale("log")
plt.xlim([1,0.5/h])
plt.scatter(freq, np.abs(fw), s = 1)
plt.scatter(freq, theory, s=1)

langevin3.png

なんか正しそうな雰囲気ですね。

まとめ

Pythonでフーリエ変換をしてみた。Python(に限らず多くのライブラリ)で実装されているのは離散フーリエ変換であり、しかも規格化定数その他に気を付けないと値が理論値と一致しなくて結構困ることになる。

離散フーリエ変換と連続フーリエ変換の関係は、ゆっくり考えるとちゃんとわかるのだが、いろいろ面倒だ。例えばDFT版の白色雑音のパワースペクトルがNhになるのもよく考えるとわかるのだが、最初、連続版と対応がつかずに困った。本稿がPythonでフーリエ変換してみようとしている人の役に立てば幸いである。


  1. 計算にあまり自信がないので、ご利用は自己責任で。計算ミスの指摘を歓迎します。 

  2. このあたりのnotationが苦しい。本当はωとか使いたいのですが、2πのからみでおかしな感じになってしまうので・・・ 

kaityo256
記事中に明示されていない場合、私の記事はCC-BY 4.0で、記事に含まれるソースコードはMITライセンスで公開します。
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした