?\
とたたけば分かります。なので、自分でQR分解しても意味がないこともあります。ノイズを含むデータの微分 - Savitzky-Golay filter
実験データを解析していると、傾きや変曲点を求めたくなる時があります。 典型的なのが、時系列データの解析で急激に変化し始めるポイントを特定したいときです。 実際のデータの微分を求めようとすると、ノイズが入っているので難儀します。
そんなときに役に立つのが、Savitzky-Golay法です。 学生の頃は、scipyで実装されたsavitzky-golayフィルターをよく使っていたのですが、 juliaの公式パッケージで使えるものはなさそうです。[1]
scipyをimportしても良いのですが、Vincent Picaudさんがblogで公開している説明がとても分かりやすいので、 それに参考にして日本語で原理をまとめたり、juliaでのテスト結果をまとめたいと思います。
[1] | VincentさんのDirectConvolution.jlで実行可能のはずなのですが、メンテされていなくて、現在は使用不可となっています。幸いにもProject.tomlを付け加えて、ソースファイル名を修正するだけで無事に動くようになりました。 |
Savitzky-Golayフィルターの心
一言で結論をまとめると「ある点におけるn階微分係数をテーラー展開と多項式近似から推定する」ということになります。
のデータセットがあった時のことを考えます。
多項式のにおけるd階微分係数をと書くことにすると、周りのd次のテーラー展開は次のように書けます。
の近くの個の点、について、を行列で書くと
はVendermonde行列で、は を対角成分にもった対角行列です。 また、は微分係数の列ベクトルです。
一方で、をのd次多項式の最小二乗法で回帰分析すると
回帰係数の推定値をとして
と書けますから、
となります。 の両辺に左からをかけて整理していけば、
となって、微分係数を求めることができます。
相互相関関数
は行列です。行列目の成分をと書くことにすれば、 における階微分は
として求められることが分かります。
は自己相関関数と呼ばれるもので、のかわりにそれを逆順に並べたものを利用すれば、畳込みになります。
データ点が等間隔であるならば、どのについても、同じを適用できます。 また、フーリエ変換での畳み込み積分は、DSP.jlの中に実装済みで簡単に利用できます。
ここまでくれば、あとはコードを実装するだけです。
実装してみる。
Vincentさんのブログのコードは、そのままは古くて動かなかったので、野良パッケージSavGol.jlを作成しました。40行程度の簡単なコードです。 インストール方法はjuliaのREPLを立ち上げて、以下のようにタイプするだけです。
julia>] # ]キーでパッケージモードに移動
(pkg)>add https://github.com/hanafsky/SavGol.jl.git # バックスペースでjuliaモードに戻る
julia>using SavGol
コードの流れをざっくり確認してみます。
2回微分まで求めるとして、長さが10の場合の窓関数をプロットしてみます。 SG関数は、窓関数の長さの半分と微分階数を引数にしていて、窓関数を行列で返してくれます。 (1列目はスムージング用、2列目は1階微分用、3列目は2階微分用)
using SavGol
sg1 = SavGol.SG(5,3)
p0=scatter(sg1[:,1],label="window for smoothing")
scatter!(sg1[:,2], label="window for 1st order derivative")
scatter!(sg1[:,3], label="window for 2nd order derivative")
なお、Vincentさん曰く、畳み込む列が小さい時は、直接畳込みを実行した方が速いそうです。 VincentさんのDirectConvolution.jlをforkして使えるように修正したので、 最後に実行時間の比較をしてみたいと思います。
実際に微分してみる。
シグモイド関数の変曲点を求めてみたいと思います。
一階微分は次のように書けます。
この例では、で傾きが最大になります。ノイズを加えたデータを生成してプロットしてみます。
using Plots, Random
Random.seed!(123)
sigmoid(x) = 1/(1+exp(-x+1))
sigmoid_d(x) = exp(-x+1)/(1+exp(-x+1))^2
p_1=plot(sigmoid,xlabel="x",ylabel="y",label="sigmoid curve",legend=:topleft)
x = -4:0.1:4
y = sigmoid.(x) + 0.01randn(length(x))
scatter!(p_1,x,y,label="observed")
FFTを使ったフィルタリング
では、フィルターをかけて微分を求めてみます。 実際の微分値に合わせるため、返り値をデータの刻み幅(0.1)で割っていることに注意してください。
deriv1_1 = apply_filter(sg1[:,2],y)/0.1
println(deriv1_1)
p_2 = plot(sigmoid_d,label="theoretical 1st order derivative",legend=:topleft)
scatter!(p_2,x,deriv1_1,label="smoothed 1st order derivative")
荒っぽいですが、真の微分係数に近い値が得られています。
直接畳み込みの場合
同じことをDirectConvolution.jlで実行してみます。 DirectConvolution.jlではその名の通り、FFTを使わずに直接畳み込みを実行します。 私のGitHubレポジトリにあるDirectConvolution.jlをREPLのpkgモードでaddしてインストールします。
julia>]
(pkg)>add https://github.com/hanafsky/DirectConvolution.jl.git
julia>using DirectConvolution
今度は、窓関数の長さを倍にしてもう少し滑らかなデータになるか検討してみます。
using DirectConvolution
sg2 = SG_Filter(Float64,halfWidth=10,degree=2)
deriv1_2 = apply_SG_filter(y,sg2,derivativeOrder=1)/0.1
p_3 = plot(sigmoid_d,label="theoretical 1st order derivative",legend=:topleft)
scatter!(p_3,x,deriv1_2,label="smoothed 1st order derivative")
ピーク値がやや下がりましたが、より滑らかなデータを得ることができました。
ベンチマーク
最後に直接畳み込みとFFT畳み込みで速度の比較をしてみたいと思います。 公平のために窓関数の大きさを同じにしておきます。
using BenchmarkTools
sg3 = SG_Filter(Float64,halfWidth=5,degree=2)
@btime apply_SG_filter(y,sg3,derivativeOrder=0) # 直接畳み込み
@btime apply_filter(sg1[:,2],y) # FFT畳み込み
536.684 ns (1 allocation: 736 bytes)
12.954 μs (24 allocations: 5.64 KiB)
めでたしめでたし