PythonでDTW(動的時間伸縮)を行い2つの波形の類似度を計算

  • このエントリーをはてなブックマークに追加

 波形の比較手段の一つとしてDTW(動的時間伸縮)をPythonでコーディングしていきます。ここでは理解を深めるために数式とフルスクラッチのPythonコードを使って説明します。また、最後に外部ライブラリであるlibrosaを使ったコード例も示します。

こんにちは。wat(@watlablog)です。ここでは波形の類似度を計算することができるDTWをPythonでやってみます

DTWとは?

 DTWとは、動的時間伸縮Dynamic Time Warping)の略で、2つの時系列データの類似度を計算する手法です。WATLABブログでも過去に波形の相関を計算する記事を紹介していますが、リンク先の記事で紹介している手法はコサインの性質を利用して類似度を数値化するコサイン類似度と呼ばれる手法です。
Pythonでベクトルと関数の相関を計算してみる

 DTWはコサイン類似度よりも時間方向のデータのずれに頑健で、波形を扱うさまざまな分野で使用されています。DTWの説明は以下の英語版Wikiに載っている図や動画がわかりやすいと思うのでリンクを貼っておきます。
・Wiki:Dynamic Time Warping

 この記事はDTWのアルゴリズムを理解するために、順を追ってPythonで実装しながら進めていきます。

動作環境

Advertisements

 この記事のPythonは3.12.3で、外部ライブラリのバージョンはこちらです。

DTWのフルスクラッチコード

時系列データの定義

 この記事では、まず最初に簡単なサイン波を使ってDTWの基礎を学びます。波形の類似性を検証するために波形データは2つ用意します。波形のデータ形式は式(1)として用意します。

\[ \begin{eqnarray} x&=&[x_{1}, x_{2}, \dots, x_{n-1}, x_{n}] \\ y&=&[y_{1}, y_{2}, \dots, y_{m-1}, y_{m}] \end{eqnarray}\tag{1} \]

 アルゴリズムの検証のために、データは振幅と周波数、位相の要素を変更できるよう式(2)でサイン波をつくります。

\[ \begin{eqnarray} x = a_{1} \sin(2 \pi f_{1} t + \phi_{1}) \\ y = a_{2} \sin(2 \pi f_{2} t + \phi_{2}) \end{eqnarray}\tag{2} \]

 データをPythonでつくるとこんな感じです。matplotlibでグラフ化もしてみましょう。

 上記Pythonコードを実行すると周波数は同じで振幅と位相が異なる2つの波形がプロットされます。

生成された波形データ

局所コスト行列

 DTWは\(n\)個の\(x\)と\(m\)個の\(y\)でマトリクスをつくり、それぞれの波形要素同士で距離を計算する距離関数を用います。距離には色々な定義がありますが、ここではユークリッド距離を用いて説明します。距離関数を用いて各波形要素同士の計算を行うと、距離関数の行列ができます。これを局所コスト行列と呼び、式(3)で表現されます。

\[ \begin{eqnarray} D_{i, j} = d(x_{i}, y_{j}) = \sqrt{(x_{i}-y_{j})^{2}} \end{eqnarray}\tag{3} \]

 局所コスト行列の計算と2Dプロットまで行うPythonコードはこちらです。先ほど上の方で書いていた波形プロットウィンドウを消すと2Dプロット(白いほど距離が近い)が表示されます。

 局所コスト行列のプロット結果がこちらです。計算結果をそのままプロットすると行列の縦が\(x\)になります。基礎的な学習はデータの形を確かめるのが重要です。\(x\)か\(y\)のデータに[:50]等でスライスすることでデータのサイズを変更できますが、片方だけスライスしてみる等でデータ形式の検証ができます。
 今回はサイン波同士の計算であるため距離の近い(白い)部分は周期的に現れていることがわかります。

局所コスト行列の2Dプロット結果

累積コスト行列(グローバルコスト行列)

 続いて累積コスト行列を求めます。累積コスト行列とは、局所コスト行列\(D\)を使って「これまでの最小コストを積み上げた」行列を指しグローバルコスト行列\(C\)とも呼ばれます。

 局所コスト行列が既知(\(D \in \mathbb{R}^{n \times m}\))とすると、累積コスト行列\(C\)は次の式(4)〜式(6)で示されます。

\(\in \mathbb{R}^{n \times m}\)は実数全体\(n \times m\)行列の集合空間の要素である、ことを意味していて、「\(n\)行\(m\)列の実数行列である」ということですね。

 式(4)は前提条件です。コストの計算は必ず行列の[1,1]から始まるように設定します。後ほどこの左下のセルから始まるワーピングパスの計算を行います。

\[ \begin{eqnarray} C_{1,1}=D_{1,1} \end{eqnarray}\tag{4} \]

 行列の1行目と1列目は式(5)で累積計算します。

\[ \begin{eqnarray} C_{i, 1} &=& D_{i, 1} + C_{i-1, 1} \ \ \ (i=2, \dots,n)\\ C_{1, j} &=& D_{1, j} + C_{1, j-1} \ \ \ (j=2, \dots,m) \end{eqnarray}\tag{5} \]

 式(6)は\(i \geq 2, j \geq 2\)の場合の計算式で、縦横斜めのいずれの経路から来ても最小となるものを選びます。これを再帰的に繰り返しますが、このような複雑な最適化問題を小さな部分問題に分割してその解を利用しながら効率的に解く方法を動的計画法と呼びます。

\[ \begin{eqnarray} C_{i, j} &=& D_{i, j} + \min (C_{i-1, j}, C_{i, j-1}, C_{i-1, j-1}) \end{eqnarray}\tag{6} \]

 累積コスト行列を計算するPythonコードを示します。

 このコードを実行すると、ぼやっと斜めに白くなる2Dプロットを得ます。

累積コスト行列の2Dプロット結果

すでに人間の目には最適なワーピングパスが見える…かな?

最適ワーピングパスの探索

 ワーピングパスとは、以下の制約を持つ経路のことで、1次元の順序集合\(p=(p_{1},p_{2},\dots, p_{k})\)で表現します。

  • 境界条件:始点は[1, 1]、終点は[\(n\), \(m\)]である(式(7))。

 パスの始点と終点が規定されることで、各データ内要素すべてを経路対象として保証します。

\[ \begin{eqnarray} p_{1} = (1,1), \ \ \ p_{k}=(n, m) \end{eqnarray}\tag{7} \]
  • 単調性:パスが進む毎に必ずインデックス(時間)が進む(式(8))。

 DTWで自然なずらし合わせができる所以がここにあります。この式では行列のそれぞれのインデックスが「増えるか」「同じか」を意味しており、逆に戻ることはない(-1を許容しない)という特性を持ちます。この特性を単調性Monotonicity)と呼びます。

\[ \begin{eqnarray} i_{k+1} - i_{k} \in {0, 1}, \ \ \ j_{k+1} - i_{k} \in {0, 1} \end{eqnarray}\tag{8} \]
  • ステップサイズ制約:次のステップへは縦横斜めに1つのみ(式(9))。

 上記単調性と共にステップサイズが固定になることで大ジャンプを防ぎます。これは局所的な連続性が保たれることを意味します。

\[ \begin{eqnarray} (i_{k+1} - i_{k}, \ \ \ j_{k+1} - i_{k}) \neq (0, 0) \end{eqnarray}\tag{9} \]

 ワーピングパスを計算するPythonコードを以下に示します。

 得られたワーピングパスを累積コスト行列の上に描画すると、次のプロットを得ます。

ワーピングパスの描画結果

これでワーピングパスが決定されました!

 ちなみに、振幅a2=1, 位相phi2=0として2つの波形をまったく同じにすると、次の絵になります。波形の長さもサンプリングも一緒であるため、綺麗な斜め45度線が確認できました(迷いのないパス)。

ワーピングパス(2つの波形を同じにした場合)

DTW距離

 時系列データ\(x\)と\(y\)がどれだけ似ているかはDTW距離という指標で評価できます。DTW距離は単純に累積コスト行列のワーピングパス終端数値です。パスの長さ\(k\)を使って正規化するとDTW距離は式(10)で示されます。

\[ \begin{eqnarray} \mathrm{DTW}(x,y)=\frac{C_{n,m}}{k} \end{eqnarray}\tag{10} \]

 DTW距離は波形がまったく同じ場合は0になります(下図それぞれのプロット右下に計算結果を記載)。

色々な波形変化で結果を観察してみる

下準備(コードを見やすくする)

 ここまででDTWの概要は説明完了です。あとは波形の素性をいじって遊んでみましょう!その前に、現状のコードはプロットウィンドウをいちいち消してからワーピングパスの確認までしていたので、まずはこのプロットをまとめるコードに変更します。さらに、次のコードでワーピングパスの情報を使って時系列波形のどことどこが対応するかも可視化してみましょう。

 下図が実行結果です。これまで学んできた各プロットが全部まとまりました。さらに、ワーピングパスの情報から時間波形上で対応部分の線分を描画することで、ワーピングパスの屈曲点と線の密集具合が対応していることもわかりやすくなったと思います。

位相変化

 まずは位相の変化をみてみましょう。サイン波形\(y\)の位相を360°分変化させ、動画として保存するために、上記Pythonコードをさらに次のように変更します。
 GIF動画の作り方はWATLABブログの過去記事から引用しました。
Pythonで複数画像からGIFを作る時に便利な処理まとめ

 コードを実行するとoutputフォルダが新規作成され、その中に個別のグラフが保存されます。そしてプログラム実行フォルダ直下にmovie.gifが保存されます。
 以下の動画がその結果です。位相をずらしていくと180°を超えたところで累積コスト行列上のワーピングパスにジャンプが確認されました。

位相変化の動画

振幅変化

 振幅の変化はメインコードの一部(ハイライト部分)を変更するだけで確認できます。

 振幅変化の場合はワーピングパスの屈曲点が出てくるという特徴が確認できました。

振幅変化の動画

周波数変化

 同じようにメインコードを変更して(ハイライト部分)、周波数変化も見てみましょう。

 途中までは累積コスト行列の明るいところとワーピングパスの対応がとれているようでしたが、あるところで破綻しました。これはおそらく終点からパスを探索するというアルゴリズムがあるからだと思います。

周波数変化の動画

librosaでDTWをする

 音声解析のPython外部ライブラリとして有名なlibrosaは、以下のコードでシンプルにDTWを行い累積コスト行列とワーピングパスを取得できます。データの形を合わせたり、境界の行列に余分なものがついていたり(空行列対策とからしい)と少しクセがあります。ただし、よほどのことがなければ有名ライブラリを使っていた方が良いかも?
・librosa:https://librosa.org/doc/latest/index.html

まとめ

 ここではDTW(動的時間伸縮)を使って2つの波形を比較しました。DTWはまずはじめに局所コスト行列を計算し、そこから累積コスト行列を計算することがわかりました。また、累積コスト行列を使って逆から(終点から)ワーピングパスを探索することもコードを書いて理解しました。

 librosaを使っても簡単にDTWができることもわかりましたが、フルスクラッチでも書けたので今後はどちらを使うかは考え中。速度次第?
 何はともあれ、音声解析の強力な武器を手に入れた感覚がするので一件落着。

DTWは色々なことにすぐにでも使えそうな技術ですね!
Xでも関連情報をつぶやいているので、wat(@watlablog)のフォローお待ちしています!

  • このエントリーをはてなブックマークに追加

SNSでもご購読できます。

コメントを残す

*