2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RustのBurnでLSTM-Autoencoder時系列異常検知を実装してみた

2
Last updated at Posted at 2025-12-12

この記事は Qiita Advent Calendar 2025 - 時系列データ の12日目の記事です。

Day 10の記事では、LSTMの予測型アプローチで時系列異常検知を行いました。今回は同じLSTMを使いながら、Autoencoder(再構成型) アプローチを実装し、2つのアプローチを比較したいと思います。

予測型 vs 再構成型

LSTMを使った時系列異常検知には主に2つのアプローチがあります。

アプローチ 原理 異常の判定基準
予測型(Day 10) 過去のデータから次の値を予測 予測誤差が大きい
再構成型(今回) 入力シーケンスを圧縮・復元 再構成誤差が大きい

予測型の特徴

Day 10で実装した予測型は、「過去30日から翌日を予測」するモデルでした。

入力: [day1, day2, ..., day30] → 予測: day31の値

異常検知の原理: 予測と実際の値が大きくズレている = 異常

このアプローチは 個々の値のスパイク(急激な変化) を検出するのに適しています。

再構成型の特徴

再構成型(Autoencoder)は「正常パターンの復元方法」を学習します。

学習: 正常データだけで「圧縮→復元」を訓練
  ↓
推論: 入力を復元してみて、うまくいくか確認
  - 正常データ → うまく復元できる(誤差小)
  - 異常データ → 復元できない(誤差大)

異常検知の原理: うまく復元できれば正常、復元できなければ異常

正常パターンしか学習していないので、見たことのない異常パターンは再現できません。このアプローチは シーケンス全体のパターン逸脱 を検出するのに適しています。

週単位のシーケンスを採用

Autoencoder はシーケンス全体のパターンを評価します。そのため、シーケンスの区切り方に意味を持たせたほうがいいと考えました。そこで 月曜始まりの7日間(1週間) を1シーケンスとしました。

月 火 水 木 金 土 日  ← 1週間 = 1シーケンス

これで「この週は異常だった」という解釈が可能になります。

// 最初の月曜日を見つけて、そこからシーケンスを開始
fn find_first_monday(dates: &[String]) -> Option<usize> {
    dates.iter().position(|date_str| {
        NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
            .map(|d| d.weekday() == Weekday::Mon)
            .unwrap_or(false)
    })
}

LSTM Encoder-Decoder

今回実装するのは、Malhotra et al. (2016) の LSTM-based Encoder-Decoder です。

アーキテクチャ

┌─────────────────── Encoder ───────────────────┐
│                                               │
│   x1      x2      x3           xL             │
│    ↓       ↓       ↓            ↓             │
│ ┌─────┐ ┌─────┐ ┌─────┐     ┌─────┐          │
│ │LSTM │→│LSTM │→│LSTM │→...→│LSTM │→ h_enc   │
│ └─────┘ └─────┘ └─────┘     └─────┘          │
│                                     │         │
└─────────────────────────────────────│─────────┘
                                      ↓
                               hidden state
                                      ↓
┌─────────────────── Decoder ───────────────────┐
│                                     │         │
│                                     ↓         │
│ ┌─────┐ ┌─────┐ ┌─────┐     ┌─────┐          │
│ │LSTM │←│LSTM │←│LSTM │←...←│LSTM │← h_enc   │
│ └──┬──┘ └──┬──┘ └──┬──┘     └──┬──┘          │
│    ↓       ↓       ↓            ↓             │
│   x'1     x'2     x'3          x'L            │
│                                               │
│  (逆順で出力: x'L → x'L-1 → ... → x'1)        │
└───────────────────────────────────────────────┘

ポイントは 逆順で再構成 することです。Encoder は x1 から xL まで順に処理し、Decoder は xL から x1 へ逆順に復元します。

逆順で再構成

なぜ逆順($x_L, x_{L-1}, ..., x_1$)で再構成するのでしょうか?

主な理由は学習効率です。逆順にすることで、入力と出力の対応する要素間の距離が短くなり、勾配が効率よく伝わります。

順方向の場合:  入力 x_1 → ... → 出力 x'_L  (経路が長い)
逆順の場合:    入力 x_1 → ... → 出力 x'_1  (経路が短い)

Day 10の記事で紹介した通り、LSTM は Cell State により長期記憶を保持できますが、学習時の勾配伝播は経路が短いほど安定します。

この逆順再構成は Sutskever et al. (2014) の seq2seq 論文でも採用されており、機械翻訳などでも効果が確認されています。

自己回帰的デコーディング

Decoder は 自分の予測だけを頼りに次を出力 します。

ステップ1: decoder(x_L)     → 予測: x'_L
ステップ2: decoder(x'_L)    → 予測: x'_{L-1}  ← 自分の予測を入力に
ステップ3: decoder(x'_{L-1}) → 予測: x'_{L-2}
...

Decoder が使える情報は Encoder からの hidden state だけです。Encoder は「シーケンス全体を復元するのに必要な情報」を hidden state に詰め込まないと、Decoder が正しく復元できません。

実装

モデル定義(model.rs)

use burn::prelude::*;
use burn::nn::lstm::{Lstm, LstmConfig, LstmState};
use burn::nn::{Linear, LinearConfig};

#[derive(Module, Debug)]
pub struct LstmAutoencoder<B: Backend> {
    encoder: Lstm<B>,
    decoder: Lstm<B>,
    output_linear: Linear<B>,
    d_hidden: usize,
    d_input: usize,
    seq_len: usize,
}

Encoder と Decoder は別々の LSTM です。output_linear は hidden state から出力値を生成する線形層です。

Forward パス

impl<B: Backend> LstmAutoencoder<B> {
    pub fn forward(&self, input: Tensor<B, 3>) -> Tensor<B, 3> {
        let [batch_size, seq_len, _] = input.dims();
        let device = input.device();

        // Encode: 入力シーケンスを処理し、最終状態を取得
        let initial_state = self.init_state(batch_size, &device);
        let (_encoder_output, encoder_final_state) =
            self.encoder.forward(input.clone(), Some(initial_state));

        // Decode: Encoder の最終状態を初期状態として逆順再構成
        let mut decoder_state = encoder_final_state;
        let mut outputs: Vec<Tensor<B, 2>> = Vec::with_capacity(seq_len);

        // 最後の要素から開始(逆順再構成のため)
        let mut decoder_input = input
            .clone()
            .slice([0..batch_size, (seq_len - 1)..seq_len, 0..self.d_input])
            .reshape([batch_size, 1, self.d_input]);

        for _ in 0..seq_len {
            // 1ステップ decode
            let (decoder_output, new_state) =
                self.decoder.forward(decoder_input.clone(), Some(decoder_state));
            decoder_state = new_state;

            // Hidden state から予測値を生成
            let hidden = decoder_output.reshape([batch_size, self.d_hidden]);
            let prediction = self.output_linear.forward(hidden);
            outputs.push(prediction.clone());

            // 自己回帰: 予測値を次の入力に
            decoder_input = prediction.reshape([batch_size, 1, self.d_input]);
        }

        // 出力を結合: [batch_size, seq_len, d_input]
        Tensor::stack(outputs, 1)
    }
}

Decoder の各ステップでは、LSTM の出力を Linear 層に通して予測値を生成し、その予測値を次のステップの入力に使います(自己回帰)。

┌─ Decoder の各ステップ(自己回帰)──────────────────┐
│                                                   │
│  h_enc ───→ LSTM ───→ hidden ───→ Linear ───→ x'L │
│               ↑                        │          │
│               └────────────────────────┘          │
│                                                   │
│  x'L ────→ LSTM ───→ hidden ───→ Linear ───→ x'L-1│
│               ↑                        │          │
│               └────────────────────────┘          │
│                         ...                       │
│                                                   │
│  x'2 ────→ LSTM ───→ hidden ───→ Linear ───→ x'1  │
└───────────────────────────────────────────────────┘

再構成誤差の計算

pub fn reconstruction_error(&self, input: Tensor<B, 3>) -> (Tensor<B, 3>, Tensor<B, 2>) {
    let [batch_size, seq_len, d_input] = input.dims();

    let reconstruction = self.forward(input.clone());

    // 入力を逆順にして比較(Decoder は逆順で出力するため)
    let input_reversed = self.reverse_sequence(input);

    // MSE per timestep
    let diff = reconstruction.clone() - input_reversed;
    let squared = diff.clone() * diff;
    let error = squared
        .sum_dim(2)
        .reshape([batch_size, seq_len])
        .div_scalar(d_input as f32);

    (reconstruction, error)
}

// 逆順変換
pub fn reverse_sequence<B: Backend>(input: Tensor<B, 3>) -> Tensor<B, 3> {
    let [batch_size, seq_len, d_input] = input.dims();
    let mut slices: Vec<Tensor<B, 3>> = Vec::with_capacity(seq_len);

    // 後ろから順にスライスを取得
    for i in (0..seq_len).rev() {
        let slice = input.clone().slice([0..batch_size, i..(i+1), 0..d_input]);
        slices.push(slice);
    }

    Tensor::cat(slices, 1)
}

学習ループ(train.rs)

for epoch in 1..=config.num_epochs {
    for range in &train_batch_indices {
        let batch_items = get_batch_items(&train_dataset, range.clone());
        let batch = batcher.batch::<B>(batch_items, &device);

        let input = batch.sequences.clone();
        let reconstruction = model.forward(input.clone());

        // ターゲットは入力を逆順にしたもの
        let target_reversed = reverse_sequence::<B>(input);

        // 再構成誤差(MSE: Mean Squared Error)
        let loss = MseLoss::new().forward(
            reconstruction,
            target_reversed,
            burn::nn::loss::Reduction::Mean,
        );

        // 逆伝播
        let grads = loss.backward();
        let grads = GradientsParams::from_grads(grads, &model);
        model = optim.step(config.learning_rate, model, grads);
    }
}

MSE = Mean Squared Error(平均二乗誤差)
予測値と実際の値の差を二乗して平均したもの。誤差が大きいほど値が大きくなります。

\text{MSE} = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2

異常検知

pub fn detect_anomalies<B: Backend>(
    model: &LstmAutoencoder<B>,
    dataset: &TimeSeriesDataset,
    device: &B::Device,
    threshold_multiplier: f32,
) -> AnomalyDetectionSummary {
    const BATCH_SIZE: usize = 64;
    let batcher = TimeSeriesBatcher::new();
    let mut results = Vec::with_capacity(dataset.items.len());

    // バッチ処理で再構成誤差を計算
    for (batch_idx, chunk) in dataset.items.chunks(BATCH_SIZE).enumerate() {
        let batch = batcher.batch::<B>(chunk.to_vec(), device);
        let (_reconstruction, error) = model.reconstruction_error(batch.sequences);

        let error_data: Vec<f32> = error.to_data().to_vec().unwrap();
        let seq_len = chunk[0].sequence.len();

        for (i, item) in chunk.iter().enumerate() {
            let start_idx = i * seq_len;
            let end_idx = start_idx + seq_len;
            let item_errors = &error_data[start_idx..end_idx];

            let mean_error = item_errors.iter().sum::<f32>() / seq_len as f32;
            let max_error = item_errors.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
            results.push(AnomalyResult {
                index: batch_idx * BATCH_SIZE + i,
                mean_error,
                max_error,
                is_anomaly: false,
            });
        }
    }

    // 閾値: mean + k × std
    let errors: Vec<f32> = results.iter().map(|r| r.mean_error).collect();
    let mean_error: f32 = errors.iter().sum::<f32>() / errors.len() as f32;
    let variance: f32 =
        errors.iter().map(|e| (e - mean_error).powi(2)).sum::<f32>() / errors.len() as f32;
    let std_error = variance.sqrt();
    let threshold = mean_error + threshold_multiplier * std_error;

    // 閾値を超えたシーケンスを異常と判定
    for result in &mut results {
        result.is_anomaly = result.mean_error > threshold;
    }

    AnomalyDetectionSummary { results, threshold, mean_error, std_error }
}

データ準備

データリークの防止

Autoencoder の学習では、Train/Test の分割に注意が必要です。

オーバーラップするウィンドウは避ける
予測型では1日ずつスライドするウィンドウを使いましたが、Autoencoder ではオーバーラップしないウィンドウを使います。

オーバーラップがあると、学習データとテストデータに同じ日のデータが含まれ、「見たことがあるパターン」を再構成できてしまいます。

// 週単位: 月曜始まりのカレンダー週に揃えて非オーバーラップ
let start_idx = if seq_len == 7 {
    find_first_monday(&dates).unwrap_or(0)
} else {
    0
};

let mut i = start_idx;
while i + seq_len <= normalized.len() {
    items.push(SequenceItem { sequence: normalized[i..i+seq_len].to_vec() });
    i += seq_len;  // 7日(1週間)ごとにジャンプ
}

こうしたことからウィンドウ自体にも意味を持たせたほうが適切と考え、月曜日から始まる週を採用しました。

結果

学習まとめ

項目
データ期間 2023-01-01 〜 2025-12-08(1,073日)
分割 Train: 122週, Test: 30週
シーケンス長 7日(暦週・月曜始まり)
Hidden次元 64
エポック数 50
最終Train Loss 0.0052
最終Test Loss 0.0116

comparison.png

検出結果の比較

テスト期間(2025-05-08〜)のみ で比較:

アプローチ モデル 検出数 検出タイプ
Day 10(予測型) LSTM Predictor 15 個々のスパイク
Day 12(再構成型) LSTM Autoencoder 4 週単位のパターン逸脱

検出された異常週

再構成型が検出した4つの異常週:

期間 再構成誤差
1 2025-05-26(月)〜 2025-06-01(日) 0.053
2 2025-06-09(月)〜 2025-06-15(日) 0.299
3 2025-06-23(月)〜 2025-06-29(日) 0.258
4 2025-08-04(月)〜 2025-08-10(日) 0.032

まとめ

検出の違い

予測型は個々の日の急激な変化を検出します。コントリビューション数が101、99、96のように突出した日、つまり「昨日まで10だったのに今日は100」のような ポイントでの異常 を捉えます。

一方、再構成型は週全体のパターンを評価します。「この週の活動パターンは普段と違う」という コンテキストでの異常 を検出できるのが特徴です。

参考


Qiita Advent Calendar 2025 - 時系列データ 13日目は @YASUHARA-Wataru さんの 新しい周期性解析手法を使った気温差の解析 です

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?