K_DM Book
用語集
対象者 標準
対象者別の入口 →

対象者の設定はこのブラウザの中にだけ保存されます。

言語 JA

動的時間伸縮(DTW)

中級

04.14.1

動的時間伸縮(DTW)

最終更新 2020-01-29 読了時間 6 分
まとめ
動的時間伸縮(Dynamic Time Warping, DTW)は、2 本の時系列の片方を時間方向に伸ばしたり縮めたりして、最も形が重なる対応づけを探し、その対応に沿った差の合計を距離とする手法です。同じ時刻どうしを比べるユークリッド距離では、山が 2 ステップずれただけで距離が大きくなりますが、DTW は「A の 5 番目と B の 7 番目」のようなずれた対応を許します。計算は \(n \times m\) の累積コスト行列を動的計画法で埋め、右上から逆にたどってワーピングパスを復元する 2 段階で、このページでは NumPy の dtw_distance を 30 行で実装し、位相のずれた sin 波との距離 5.02、cos 波との距離 10.19 を fastdtw の値と比べて確かめます。

直感 #

同じ曲を歌った 2 人の録音を比べるとき、1 人が少しゆっくり歌っていると、同じ時刻の音は別の音符になります。「3 秒目と 3 秒目」を比べるユークリッド距離では別の曲に聞こえてしまいますが、人間は「この音符とあの音符が対応する」と時間を伸縮して聞き比べます。DTW はこの伸縮を、2 本の系列の全点対の距離を格子に並べ、左下から右上へ「隣・上・斜め上」の 3 方向だけで進む最も安い経路として探します。経路が斜めに真っすぐなら 2 本は同じ速さ、横や縦に寝るところは片方が「待っている」区間です。

動的時間伸縮とは #

Dynamic Time Warping(DTW)は、時系列同士の類似度を測る際に時間軸の伸縮を許容して整列させる手法です。同じ形状でも開始タイミングや速度が異なるとユークリッド距離では大きな差になりますが、DTW なら時間軸を引き伸ばして「もっとも形が似る整列」を見つけられます。音声認識や手書き文字認識では発話速度や書き順の違いを吸収するために、製造ラインのセンサーデータでは周期が揺れる信号どうしを比べて異常を見つけるために、レコメンドではユーザー行動の経時パターンを比べて似た行動を持つユーザーや商品を探すために使われています。


何が起きているか(ステップで見る) #

コードを読む前に、この手法が図の上で何をしているかを追ってみましょう。「再生」を押すか、スライダーを動かすと 1 手順ずつ進みます。

見るポイント:DTW は時間を伸び縮みさせて形を合わせる。タイミングがずれた似た波形を「近い」と判定できる。

アルゴリズムの流れ #

2 本の時系列 \(X = (x_1, \dots, x_n)\)、\(Y = (y_1, \dots, y_m)\) に対して、DTW 距離は 4 段階で求めます。まず各点対の局所距離 \(|x_i - y_j|\) を計算して \(n \times m\) の距離行列 \(D\) を作ります。次に動的計画法で累積コスト行列 \(C\) を埋めます。

$$ C_{i,j} = D_{i,j} + \min\{C_{i-1,j}, C_{i,j-1}, C_{i-1,j-1}\} $$

初期条件は \(C_{1,1} = D_{1,1}\) で、\(C_{i,j}\) は「左上の角から \((i, j)\) まで来る最も安い経路の合計」を表します。3 つの候補はそれぞれ「\(Y\) を待たせて \(X\) だけ進む」「\(X\) を待たせて \(Y\) だけ進む」「両方同時に進む」に対応します。続いて \(C_{n,m}\) から逆向きに最小コストの前の点をたどり、整列の対応関係(ワーピングパス)を得ます。最後に \(C_{n,m}\) そのものが DTW 距離で、系列の長さが違うものどうしを比べるときはパス長で割って正規化します。

計算量は格子を全部埋めるので \(O(nm)\) です。系列が 1 万点を超えると格子が 1 億マスになるため、Sakoe–Chiba バンドで対角線から window 以上離れたマスを計算しない制約を加えたり、fastdtw のように粗い解像度で経路を見つけてから細かくする近似法を使います。


NumPy で DTW を実装する #

次の関数は上の 4 段階をそのまま書いたものです。window を渡すと Sakoe–Chiba バンドの制約つき、省くと制約なしの完全な DTW になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import numpy as np

def dtw_distance(
    x: np.ndarray,
    y: np.ndarray,
    window: int | None = None,
) -> tuple[float, list[tuple[int, int]]]:
    n, m = len(x), len(y)
    window = max(window or max(n, m), abs(n - m))

    cost = np.full((n + 1, m + 1), np.inf)
    cost[0, 0] = 0.0

    for i in range(1, n + 1):
        for j in range(max(1, i - window), min(m + 1, i + window + 1)):
            dist = abs(x[i - 1] - y[j - 1])
            cost[i, j] = dist + min(cost[i - 1, j], cost[i, j - 1], cost[i - 1, j - 1])

    i, j = n, m
    path: list[tuple[int, int]] = []
    while i > 0 and j > 0:
        path.append((i - 1, j - 1))
        idx = np.argmin((cost[i - 1, j], cost[i, j - 1], cost[i - 1, j - 1]))
        if idx == 0:
            i -= 1
        elif idx == 1:
            j -= 1
        else:
            i -= 1
            j -= 1

    path.reverse()
    return cost[n, m], path

例: 3 本の波形を比較する #

振幅 1.2 の sin 波(A)と、位相を 2 ステップずらし周期もわずかに変えた sin 波(B)、そして位相が 90 度違う cos 波(C)を 30 点ずつ作ります。A と B は形が同じで時間が 2 ステップずれているだけ、A と C は sin と cos で形そのものが違う例です。

1
2
python --version  # 例: Python 3.13.0
pip install matplotlib fastdtw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import matplotlib.pyplot as plt
from fastdtw import fastdtw

np.random.seed(42)
time = np.arange(0, 30)

series_a = 1.2 * np.sin(time / 2.0)
series_b = 1.1 * np.sin((time + 2) / 2.1) + 0.05 * np.random.randn(len(time))
series_c = 0.8 * np.cos(time / 2.0) + 0.05 * np.random.randn(len(time))

plt.figure(figsize=(10, 3))
plt.plot(time, series_a, label="Series A", color="black")
plt.plot(time, series_b, label="Series B", color="tab:red")
plt.plot(time, series_c, label="Series C", color="tab:blue")
plt.title("比較対象の時系列")
plt.legend()
plt.tight_layout()
plt.show()

例: 3 本の波形を比較するの図

自作の dtw_distance と近似ライブラリ fastdtw で、A–B 間と A–C 間の距離を計算して比べます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 素朴な DTW
dist_ab, path_ab = dtw_distance(series_a, series_b)
dist_ac, path_ac = dtw_distance(series_a, series_c)

# 近似 DTW(fastdtw)
fast_dist_ab, _ = fastdtw(series_a, series_b)
fast_dist_ac, _ = fastdtw(series_a, series_c)

print(f"DTW(A, B)  = {dist_ab:.3f}  / fastdtw = {fast_dist_ab:.3f}")
print(f"DTW(A, C)  = {dist_ac:.3f}  / fastdtw = {fast_dist_ac:.3f}")
1
2
DTW(A, B)  = 5.020  / fastdtw = 5.020
DTW(A, C)  = 10.194 / fastdtw = 10.194

Series B は振幅と位相がわずかに違うだけで形状は似ているため、DTW 距離が 5.02 と小さくなります。Series C は波形が異なるため距離が 10.19 と約 2 倍になり、fastdtw の近似もどちらも同じ値を返します。


ワーピングパスを可視化する #

A と B の全点対の距離を熱マップにし、その上に dtw_distance が返したパスを白線で重ねます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def plot_warping(a: np.ndarray, b: np.ndarray, path: list[tuple[int, int]]) -> None:
    fig, axes = plt.subplots(
        1,
        2,
        figsize=(12, 4),
        gridspec_kw={"width_ratios": [1, 1.2]},
    )

    axes[0].plot(a, label="Series A", color="black")
    axes[0].plot(b, label="Series B", color="tab:red")
    axes[0].set_title("時系列")
    axes[0].legend()

    axes[1].imshow(
        np.abs(np.subtract.outer(a, b)),
        origin="lower",
        interpolation="nearest",
        cmap="viridis",
    )
    path_arr = np.array(path)
    axes[1].plot(path_arr[:, 1], path_arr[:, 0], color="white", linewidth=2)
    axes[1].set_title("累積コストとワーピングパス")
    axes[1].set_xlabel("Series B index")
    axes[1].set_ylabel("Series A index")
    plt.tight_layout()
    plt.show()

plot_warping(series_a, series_b, path_ab)

ワーピングパスを可視化するの図

白線で示したワーピングパスは、Series A の遅れた山と Series B の 2 ステップ進んだ山を対応づけており、対角線から少し外れた帯を通ります。時間軸がずれていても形状が似ていればパスが暗いマスだけを通れるので、距離 5.02 という小さな値になります。


使いどころと落とし穴 #

DTW は時間方向のずれには強いものの、縦方向のずれ(ベースラインや振幅の差)はそのまま距離に残ります。Series C のように振幅が 0.8 と 1.2 で違う例では、位相を合わせても距離 10.19 が残ったのはこのためで、形だけを比べたいなら事前に z 正規化するか、傾きで比べる DDTW を使います。計算量 \(O(nm)\) は 1 万点どうしで 1 億マスになるので、window で Sakoe–Chiba バンドを設けるか fastdtw に切り替えます。DTW 距離は三角不等式を満たさないため、KMeans のような平均を取るクラスタリングにはそのまま使えず、tslearn の DBA(DTW Barycenter Averaging)や k-medoids を組み合わせます。

まとめ #

DTW は、2 本の系列の全点対の距離 \(|x_i - y_j|\) を格子に並べ、「上・左・斜め」の 3 方向だけで左下から右上へ進む最も安い経路の合計を距離とする手法です。この経路がワーピングパスで、対角線から外れた部分が「時間を伸縮して合わせた」区間に対応します。NumPy の 2 重ループで 30 行あれば実装でき、位相のずれた sin 波には 5.02、形の違う cos 波には 10.19 と、見た目の印象どおりの距離を返しました。実務では window による Sakoe–Chiba バンドか fastdtw の近似で計算量を抑え、振幅のずれが問題になるなら正規化や DDTW を組み合わせます。