MTGFlow:正規化フローと動的グラフでラベルゼロの異常検知を実装する

異常検知の最大の壁のひとつは「ラベルがない」ことだ。実システムで「これは異常」と印のついたデータはほとんど手に入らない。それどころか、「完全に正常」と保証された訓練データすら用意できないことが多い。ラベルの知識ゼロで、どうやって異常を見つけるのか。

ひとつの強力な答えが 密度推定 だ。「異常は正常よりも疎な(=低密度の)領域に現れる」という広く受け入れられた仮定に立てば、データの確率密度 $p(x)$ を推定し、$p(x)$ が低い点を異常とすればよい。問題は「多変量時系列の複雑な密度をどう精密に推定するか」。これに 正規化フロー(normalizing flow)動的グラフ構造学習 を組み合わせて答えたのが MTGFlow(Zhou et al., “Detecting Multivariate Time Series Anomalies with Zero Known Label,” AAAI 2023)である。

本記事はこれまでのシリーズより一段踏み込み、正規化フローの変数変換公式の導出から、MTGFlow の各モジュールの数式、そして簡易版を PyTorch で実装して実際に学習・検証するところまで一気通貫で扱う。最後には、合成多変量データで AUROC 0.99 を達成し、しかも崩れた変量を名指しできることを実測で示す。なぜこれを学ぶのか ―― ラベルなしで動く密度ベース異常検知の設計を、原理から実装まで自分の手で掴むためだ。なお正規化フローの基礎は別記事でも扱っているので、必要に応じて参照してほしい。

MTGFlowの概念:正常は高密度、異常は低密度。密度を推定し低密度を異常とする

出発点はシンプルだ。正常データは高密度の領域に集中し、異常は疎な低密度領域に現れる。だから、データの密度 $p(x)$ を学べば、$p(x)$ が低い点を ラベルなしで 異常と判定できる。鍵は「複雑な多変量時系列の密度をどう精密に推定するか」。ここで正規化フローが効く。

正規化フロー ― 複雑な分布をガウスへ可逆変換する

密度推定の難しさは、現実のデータ分布が複雑で、確率密度を解析的に書けないことにある。正規化フローのアイデアは、可逆な変換 $f_\theta$ でデータ $x$ を扱いやすい既知分布(標準正規分布)の変数 $z$ へ写像することだ。

正規化フロー:複雑なデータ分布を可逆変換で扱いやすいガウスへ写像

左の複雑なデータ分布を、可逆変換 $z = f_\theta(x)$ で右の標準正規分布へ移す。可逆だから逆向き $x = f_\theta^{-1}(z)$ も計算でき、$z$ 側の密度が分かれば $x$ 側の密度も復元できる。その橋渡しをするのが 変数変換公式 だ。

変数変換公式 ― 密度を厳密に計算する

確率の保存則から、可逆変換のもとでの密度は次の関係を満たす。

$$ \begin{equation} p_X(x) = p_Z\big(f_\theta(x)\big) \left| \det \frac{\partial f_\theta}{\partial x^\top} \right| \end{equation} $$

変数変換公式:潜在密度×ヤコビアン行列式で厳密に密度を計算

直感はこうだ。変換 $f_\theta$ が空間を局所的に伸縮させると、確率密度はその逆数で調整されなければならない(確率の総量は1で保存される)。その伸縮率を測るのが ヤコビアン行列式 $\det(\partial f_\theta / \partial x^\top)$ である。$|\det J|$ が大きい(体積を引き伸ばす)領域では密度が薄まり、小さい領域では濃くなる。

両辺の対数を取ると、計算しやすい形になる。

$$ \begin{equation} \log p_X(x) = \log p_Z\big(f_\theta(x)\big) + \log\left| \det \frac{\partial f_\theta}{\partial x^\top} \right| \end{equation} $$

第1項は潜在空間(ガウス)での対数密度 ―― $z$ が原点に近いほど大きい。第2項はヤコビアンの対数 ―― 体積補正だ。この $\log p_X(x)$ を 最大化 するように $f_\theta$ を学習すれば(=最尤推定)、データの密度モデルが手に入る。実装では、ヤコビアン行列式が安く計算できる affine coupling 層(RealNVP)を積み重ねてフローを作る ―― これは後の実装で具体化する。

ここまでが正規化フローの基礎だ。だが多変量時系列には固有の難しさがある ―― 変量(entity)間に相関があり、しかもその相関が時間とともに変わる。MTGFlow はこれを動的グラフで捉える。

動的グラフ構造学習 ― 変量間の関係を窓ごとに学ぶ

多変量時系列の密度を精密に推定するには、変量間の依存関係を考慮しなければならない。MTGFlow は、変量間の関係を self-attention でデータから学習する。事前に固定のグラフを与えるのではなく、時間窓ごとに動的な隣接行列を推定する。

動的グラフ構造学習:self-attentionでentity間の関係を窓ごとに学ぶ

変量 $i, j$ の関係スコアを Query-Key の内積で測り、softmax で正規化して隣接行列 $A$ を作る。

$$ \begin{equation} e_{ij} = \frac{(x_i W^Q)(x_j W^K)^\top}{\sqrt{T}}, \qquad a_{ij} = \frac{\exp(e_{ij})}{\sum_{j} \exp(e_{ij})} \end{equation} $$

時間窓ごとに別の $A$ が得られるので、相関が時間とともに変わっても追従できる。この動的グラフが、次の時空間条件を作る材料になる。

時空間条件 ― RNNで時間、グラフ畳み込みで空間

正規化フローを「文脈に応じた」密度推定器にするため、MTGFlow は各変量に 時空間条件 $C$ を与える。時間方向は RNN(GRU)で、空間方向(変量間)はグラフ畳み込みで捉える。

時空間条件:RNNで時間、グラフ畳み込みで空間依存を捉える

各変量の系列を RNN に通して時間依存の隠れ状態 $H$ を得て、動的グラフ $A$ でグラフ畳み込みする。

$$ \begin{equation} C = \mathrm{ReLU}(A H W_1 + H W_2) W_3 \end{equation} $$

$AHW_1$ が「隣接変量からの情報集約(空間)」、$HW_2$ が「自分自身の時間情報」だ。こうして得た条件 $C$ を正規化フローに渡すことで、フローは「いまの文脈ではこの値はどれくらい起こりやすいか」を判断できるようになる。

Entity-aware 正規化フロー ― 各変量を固有の正規分布へ

最後の部品が entity-aware 正規化フロー だ。各変量(entity)$k$ を、条件 $C$ のもとで固有の正規分布へ写像する。

entity-aware正規化フロー:各entityを固有の正規分布へ写像、パラメータは共有

$$ \begin{equation} z_k = f_\theta(x_k \mid C), \qquad z_k \sim \mathcal{N}(\mu_k, I) \end{equation} $$

ポイントは2つある。ひとつは、フローのパラメータ $\theta$ を 全変量で共有 すること ―― 変量数が増えてもパラメータが爆発しない。もうひとつは、各変量が 固有の平均 $\mu_k$ を持つこと ―― 変量ごとに正常範囲が違っても、それぞれの目標分布で表現できる(fine-grained な密度推定)。共有フロー+変量固有の目標、という設計だ。

全体アーキテクチャと損失関数

ここまでの部品を組み上げると、MTGFlow の全体像が見える。

MTGFlow全体アーキテクチャ:動的グラフ→時空間条件→entity-aware正規化フロー→対数尤度

多変量時系列の窓を、①動的グラフ学習で $A$ を、②RNN+グラフ畳み込みで時空間条件 $C$ を作り、③entity-aware 正規化フローで各変量の対数尤度 $\log p(x_k)$ を計算する。学習は 最尤推定(対数尤度の最大化=負の対数尤度 NLL の最小化)だ。

$$ \begin{equation} W^* = \arg\max_W \frac{1}{NK} \sum_{c=1}^{N} \sum_{k=1}^{K} \left[ -\frac{1}{2}\lVert z_k^c – \mu_k \rVert_2^2 + \log\left| \det \frac{\partial f_\theta}{\partial x_k^c{}^\top} \right| \right] \end{equation} $$

第1項が「変換後の $z_k$ が目標 $\mu_k$ にどれだけ近いか(ガウスの対数密度)」、第2項がヤコビアンの対数(体積補正)だ。異常スコアは、窓全体の 負の平均対数尤度 とする。

$$ \begin{equation} S_c = -\frac{1}{K} \sum_{k=1}^{K} \log p_{X_k}(x_k^c) \end{equation} $$

$S_c$ が高い(=低密度)窓ほど異常の可能性が高い。変量別スコア $S_{ck} = -\log p_{X_k}(x_k^c)$ を見れば、どの変量が異常に効いているかも分かる。では、これを実装して本当に動くか確かめよう。

PyTorch実装 ― 簡易MTGFlowを組む

論文の構成要素(動的グラフ・時空間条件・条件付きフロー・最尤学習)をそのまま備えた簡易版を実装する。まず動的グラフ学習と、条件付き affine coupling 層だ。

import torch
import torch.nn as nn
import numpy as np

class GraphLearner(nn.Module):
    """self-attentionで entity間の動的隣接行列 A を学ぶ"""
    def __init__(self, w, d=16):
        super().__init__()
        self.q = nn.Linear(w, d); self.k = nn.Linear(w, d); self.d = d
    def forward(self, x):                       # x:(B,K,W)
        Q = self.q(x); K = self.k(x)
        e = torch.matmul(Q, K.transpose(1, 2)) / (self.d ** 0.5)
        return torch.softmax(e, dim=-1)         # (B,K,K)

class CondNet(nn.Module):
    """GRUで時間、graph convで空間 → 各entityの時空間条件 C_k"""
    def __init__(self, w, h=16):
        super().__init__()
        self.gru = nn.GRU(1, h, batch_first=True)
        self.w1 = nn.Linear(h, h); self.w2 = nn.Linear(h, h); self.h = h
    def forward(self, x, A):                     # x:(B,K,W), A:(B,K,K)
        B, K, W = x.shape
        _, hn = self.gru(x.reshape(B * K, W, 1)) # GRUで時間方向を集約
        H = hn.squeeze(0).reshape(B, K, self.h)
        return torch.relu(self.w1(torch.matmul(A, H)) + self.w2(H))  # graph conv

class CondCoupling(nn.Module):
    """条件付きaffine coupling: 半分を固定し、条件Cでscale/shiftを生成"""
    def __init__(self, w, hcond, hid=32):
        super().__init__()
        self.w = w; self.half = w // 2
        self.net = nn.Sequential(
            nn.Linear(self.half + hcond, hid), nn.ReLU(),
            nn.Linear(hid, hid), nn.ReLU(),
            nn.Linear(hid, (w - self.half) * 2))
    def forward(self, x, c):                     # x:(M,w), c:(M,hcond)
        x1 = x[:, :self.half]; x2 = x[:, self.half:]
        s, t = self.net(torch.cat([x1, c], 1)).chunk(2, 1)
        s = torch.tanh(s)
        x2 = x2 * torch.exp(s) + t               # アフィン変換
        return torch.cat([x1, x2], 1), s.sum(1)  # 出力とlog|det J|

CondCoupling が正規化フローの心臓だ。入力の前半 $x_1$ はそのまま通し、後半 $x_2$ を「$x_1$ と条件 $C$ から生成した」スケール $s$ とシフト $t$ でアフィン変換する。この三角構造のおかげでヤコビアン行列式が $\exp(\sum s)$ と簡単に求まり、log|det J| = s.sum() で計算できる。これらを積み重ねて MTGFlow 本体を組む。

class MTGFlow(nn.Module):
    def __init__(self, w, K, hcond=16, nflow=6):
        super().__init__()
        self.gl = GraphLearner(w); self.cond = CondNet(w, hcond)
        self.flows = nn.ModuleList([CondCoupling(w, hcond) for _ in range(nflow)])
        self.perm = [torch.randperm(w) for _ in range(nflow)]
        self.mu = nn.Parameter(torch.randn(K, w) * 0.1)   # entity固有の平均
        self.w = w; self.K = K
    def forward(self, x):                        # x:(B,W,K)
        B = x.shape[0]
        xk = x.permute(0, 2, 1)                  # (B,K,W)
        A = self.gl(xk)                          # 動的グラフ
        C = self.cond(xk, A)                     # 時空間条件
        z = xk.reshape(B * self.K, self.w); c = C.reshape(B * self.K, -1)
        ldj = torch.zeros(B * self.K)
        for i, fl in enumerate(self.flows):
            z = z[:, self.perm[i]]               # 各層で次元を入れ替え
            z, ld = fl(z, c); ldj = ldj + ld
        mu = self.mu.unsqueeze(0).expand(B, -1, -1).reshape(B * self.K, self.w)
        logpz = -0.5 * ((z - mu) ** 2).sum(1) - 0.5 * self.w * np.log(2 * np.pi)
        return (logpz + ldj).reshape(B, self.K)  # 各entityの対数尤度

forward がそのまま変数変換公式の対数版になっている ―― logpz(ガウスの対数密度)に ldj(ヤコビアンの対数)を足すだけだ。学習は、この対数尤度の符号を反転した NLL を最小化する。

model = MTGFlow(w=20, K=4)
opt = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-5)
for ep in range(120):
    for xb in train_batches:                     # xb:(B,W,K)
        loss = -model(xb).mean()                 # NLL最小化 = 最尤
        opt.zero_grad(); loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
        opt.step()

これを、2つの潜在因子から相関を持って生成される4変量の合成データ(AR(1)で時間相関つき)で学習させた。訓練データは正常のみ ―― ラベルは一切使わない。

訓練曲線:NLLが28.6から15.2に低下し密度モデルが学習

訓練の負の対数尤度は 28.6 から 15.2 へ単調に低下 した ―― フローが正常データの密度をうまく学習していることを示す。問題は、この密度モデルが本当に異常を検出できるかだ。

実測 ― ラベルゼロでAUROC 0.99

テストデータには、性質の異なる3種類の異常を仕込んだ。①変量0-1の相関崩壊(変量0だけが独立に大きく振れる)、②全変量への高周波重畳、③変量2-3への振幅スパイク。学習済みモデルで各窓の異常スコア($-$平均対数尤度)を計算する。

異常スコア時系列:3つの異常区間すべてでスコアが明確に上昇、AUROC0.993

3つの異常区間すべてで、異常スコアが平常時の低い値から 明確にスパイク している。値そのものの異常(②③)だけでなく、値域は正常なのに相関だけが崩れた異常① もしっかり捉えている点が、動的グラフ+密度推定の威力だ。定量的には次の通りだ。

ROC曲線とPR曲線:AUROC0.993、AP0.970

AUROC 0.993、AP 0.970(異常率7.5%に対して)―― ラベルを一切使わず、密度推定だけでこの精度が出る。スコアの分布を見ると、分離の鮮やかさがよく分かる。

スコア分布:正常窓は低スコア、異常窓は高スコア側に裾を引く

正常窓はスコアが低い領域(高密度)に密集し、異常窓は高スコア(低密度)側に裾を引いて分離している。さらに、変量別スコアを見ると 根本原因の特定 もできる ―― 相関が崩れた異常①の区間では、崩れた変量0のスコアが 129.4(正常時の変量0は 14.1)と突出した。どの変量が異常に効いたかを名指しできるわけだ。これらはすべて、上のコードを実行した実測値である。

評価 ― 先行手法GANFを上回る

論文は SWaT・WADI・PSM・MSL・SMD の5つの実データセットで、DeepSVDD・USAD・DAGMM・GANF など7手法と比較している。

論文結果:5データセットで先行手法GANFを上回る(SWaTで+5.0%)

AUROC で見ると、MTGFlow は先行手法 GANF を全データセットで上回る ―― SWaT 79.8→84.8%(+5.0%)、WADI 90.3→91.9%、PSM 81.8→85.7%、MSL 64.5→67.2%、SMD 89.2→91.3%。GANF も正規化フローを使うが、固定的なグラフ(DAG)を前提とする。MTGFlow が self-attention による動的グラフ で時間変化する関係を捉え、entity-aware に各変量の密度を細かく推定したことが、この差を生んでいる。

なお、密度推定ベースの異常検知には前提もある。「異常は低密度」という仮定が成り立たないケース(正常と同じくらいの密度を持つ巧妙な異常)では検出が難しい。また訓練データに異常が大量に混じると、その異常まで高密度として学習してしまう(MTGFlow は少量混入には頑健だが、限度はある)。

まとめ

MTGFlow のエッセンスを整理する。

  • 密度推定で異常検知:「異常は低密度」の仮定のもと、データの確率密度 $p(x)$ を推定し、低密度を異常とする。ラベルゼロで動く。
  • 正規化フロー:可逆変換 $f_\theta$ でデータをガウスへ写像し、変数変換公式 $\log p_X(x) = \log p_Z(z) + \log|\det J|$ で厳密に密度を計算。affine coupling でヤコビアンを安く求める。
  • 動的グラフ構造学習:self-attention で変量間の関係を窓ごとに学習。時間変化する相関に追従する。
  • 時空間条件 + entity-aware フロー:RNN(時間)+グラフ畳み込み(空間)で条件 $C$ を作り、各変量を固有の正規分布 $\mathcal{N}(\mu_k, I)$ へ写像。フローのパラメータは共有しつつ、変量ごとの正常範囲を別々に表現。
  • 実測:簡易実装で訓練 NLL 28.6→15.2、テスト AUROC 0.993。相関崩壊の異常も検出し、崩れた変量を変量別スコア(129.4 vs 正常14.1)で名指しできた。
  • 評価:実データ5つで先行手法 GANF を上回る(SWaT +5.0%)。動的グラフと entity-aware 密度推定が効いている。

正規化フローによる密度推定は、ラベルなしで多変量時系列の異常を捉える強力な道具だ。この MTGFlow は、本シリーズで扱った Graph-MoE が「プラグアンドプレイで底上げするベース」として選んだ手法でもある。密度推定(MTGFlow)・再構成(USAD・OmniAnomaly)・関係の崩壊(SARAD・GCAD)・周波数(CATCH)と、異常を測る原理を並べて読むと、多変量時系列異常検知の地図が原理のレベルで見渡せるはずだ。

Graph-MoE:GNN各層を専門家にしてレジームを選ぶ
MTGFlowをベースに後付けで性能を底上げするMoEモジュール。MTGFlowの正規化フロー密度推定がどう発展するかを見たい。
画像なし
正規化フロー:可逆変換による密度推定
MTGFlowの土台となる正規化フローの基礎。変数変換公式とヤコビアンをより詳しく解説。
OmniAnomaly:確率的RNNによる多変量時系列の異常検知
再構成確率で異常を測る手法。MTGFlowの密度推定と『確率で異常を測る』思想を比較したい。