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

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

言語 JA

Hotelling の T^2 で異常値にラベルを付ける

01.3.7

Hotelling の T^2 で異常値にラベルを付ける

最終更新 2025-11-03 読了時間 3 分
まとめ
Hotelling の T^2 統計量は、各観測値が平均からどれだけ離れているかを分散で割って測るマハラノビス距離の 2 乗です。データが多変量正規分布に従うなら T^2 は自由度 \(d\) の \(\chi^2\) 分布に従うので、scipy.stats.chi2.ppf(1 - alpha, df=d) で「正常なら 95% がこの値以下に収まる」という閾値が 1 行で決まります。このページでは正規乱数 500 点に 10 個の異常値を混ぜた時系列を作り、T^2 が閾値 3.84 を超えた点に異常ラベルを付けます。読み終えると、alpha を 0.05 から 0.01 に下げると検出される点がどう減るか、1 次元の T^2 が z スコアの 2 乗と同じものだと説明できるようになります。

直感 #

工場のセンサーが毎秒 1 つの値を返しているとします。ほとんどの値は平均 0 のまわりに標準偏差 1 でばらつき、たまに 6 前後の値が飛び込みます。「平均から標準偏差の何倍離れたか」を 2 乗した値が T^2 で、1 次元では z スコアの 2 乗そのものです。正常な点の T^2 は \(\chi^2_1\) 分布に従うので、「正常な点のうち 5% しか超えない値」= 3.84 を線として引けば、それを超えた点に機械的に異常ラベルを付けられます。多変量になっても考え方は同じで、分散で割る代わりに共分散行列 \(\Sigma^{-1}\) をはさみ、\(\chi^2\) の自由度を次元数 \(d\) に変えるだけです。

式で書くと何を測っているか #

特徴量ベクトル \(x \in \mathbb{R}^d\)、平均 \(\mu\)、共分散 \(\Sigma\) に対し、

$$ T^2 = (x - \mu)^\top \Sigma^{-1} (x - \mu) $$

を計算します。\((x - \mu)\) が「平均からのずれ」、\(\Sigma^{-1}\) が「ばらつきの大きい方向ほど小さく数える重み」です。多変量正規を仮定すると \(T^2\) は自由度 \(d\) の \(\chi^2_d\) に従うため、検出閾値 \(\tau\) は

$$ \tau = \chi^2_d(1 - \alpha) $$

で与えられます。\(T^2 > \tau\) となる観測は異常と判定できます。1 次元では \(\Sigma\) が分散 \(\sigma^2\) 1 個になるので、T^2 は z スコア \((x-\mu)/\sigma\) の 2 乗と一致し、\(\alpha = 0.05\) の閾値は \(\chi^2_1(0.95) = 3.84\)、つまり「z スコアの絶対値が 1.96 を超えたら異常」と同じ判定になります。

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

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

見るポイント:少ない回数で孤立する点ほど異常。外れ値は 1〜5 回で孤立し、密集した点は 8 回切っても 5 点が残る。T^2 も「密集した点から遠い点を異常とみなす」点は同じで、違いは距離を \(\chi^2\) 分布の閾値 3.84 と比べて判定するところです。

サンプルデータを作成する #

次のコードは平均 0・標準偏差 1 の正規乱数 500 点を作り、ランダムに選んだ 10 点に平均 6 のずれを足して異常値にします。正解を知っているデータなので、後で検出結果と赤い × が重なるかを確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(100)
n_samples = 500
time = np.arange(n_samples)
series = rng.normal(loc=0.0, scale=1.0, size=n_samples)

anomaly_index = rng.choice(n_samples, size=10, replace=False)
series[anomaly_index] += rng.normal(loc=6.0, scale=1.0, size=anomaly_index.size)

plt.figure(figsize=(10, 3))
plt.plot(time, series, ".", alpha=0.8, label="観測値")
plt.plot(anomaly_index, series[anomaly_index], "x", ms=10, c="red", label="挿入した異常値")
plt.title("合成した時系列データ")
plt.xlabel("時刻")
plt.ylabel("値")
plt.legend()
plt.tight_layout()
plt.show()

サンプルデータを作成するの図

T^2 を計算してラベル付けする #

次のコードは系列の平均と分散から各点の T^2 を求め、chi2.ppf(0.95, df=1) = 3.84 を閾値にして超えた時刻を取り出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from scipy.stats import chi2

def detect_hotelling_t2(series: np.ndarray, alpha: float = 0.05) -> tuple[np.ndarray, np.ndarray, float]:
    series = np.asarray(series)
    mean = series.mean()
    var = series.var(ddof=1)
    t2_scores = ((series - mean) ** 2) / var
    threshold = chi2.ppf(1 - alpha, df=1)
    detections = np.where(t2_scores > threshold)[0]
    return detections, t2_scores, threshold

detected_index, t2_scores, threshold = detect_hotelling_t2(series, alpha=0.05)

plt.figure(figsize=(10, 3))
plt.plot(time, t2_scores, label="T^2 スコア")
plt.axhline(threshold, color="crimson", linestyle="--", label=f"閾値 (α = 0.05)")
plt.scatter(detected_index, t2_scores[detected_index], c="red", marker="o", s=60, label="検出された異常値")
plt.xlabel("時刻")
plt.ylabel("T^2")
plt.title("Hotelling の T^2 スコアと検出結果")
plt.legend()
plt.tight_layout()
plt.show()

T^2 を計算してラベル付けするの図

実行結果の読み方 #

挿入した 10 点の T^2 はおよそ 25〜50 に達し、閾値 3.84 をはるかに超えるので赤丸で拾われます。一方、正常な 490 点のうちおよそ 5%、つまり 25 点前後も閾値をわずかに超えて赤丸になります。これは \(\alpha = 0.05\) が「正常でも 5% は異常扱いにする」という設定だからで、誤報を減らしたいなら alpha=0.01(閾値 6.63)に下げます。

使いどころと落とし穴 #

次元が高い場合は共分散行列 \(\Sigma\) の逆行列を直接計算せず、scipy.linalg.solve で連立方程式として解くと数値的に安定します。閾値を決める \(\alpha\) は検出したい異常率に合わせて調整し、アラートを減らしたい監視用途では 0.01 など小さい値から試します。正規性が崩れる右に裾を引いたデータでは、先に Box-Cox や Yeo-Johnson 変換で対称化するか、RobustScaler のように中央値ベースの推定に替えると誤検知が減ります。オンライン監視では平均と共分散を一定期間ごとに再計算して、データドリフトに追随させてください。分布を仮定したくない場合や 2 次元以上で形が複雑なデータでは、次のページの Isolation Forest が代替になります。