BigQueryで機械学習 時系列推移の異常を検知する

はじめに

ドワンゴ教育事業でデータアナリストとして働いている小林です。
ZEN大学の開学やR高校の開校で今年の春は例年にもまして賑やかなものでした。ライブストリーミングされていた入学式やオリエン番組などを眺めていると皆様の人生の節目に立ち会えた嬉しさとともに、より良いサービスを作っていきたい思いを強くして、身が引き締まる思いです。

課題について(導入に代えて)

KPIダッシュボードを作って運用しているデータアナリストにとって、時系列推移で大きな変動があった時にいち早くキャッチしたいと願うのはとても自然なことです。スパイクがあれば要因を手早く分析してPJメンバーに伝えたいですし、激減した指標があればレコードの欠損から疑わねばなりません。いずれにしてもホットなトピックスを伝えて組織的なリアクションに繋げてもらうためには「検知の早さ」は重要な成功要因であるというのが私の意見です。
そこで、今回は「どのように異常を検知するか」をテーマに考察してみたいと思います。サービスとしてのZEN Studyには重要視される指標は多くありますが、今回は「何人のユーザが教材を学習してくれているか?」という指標を例にとって(スケールは公開できませんが)、具体的な検知の方法を考えます。

パッと思いついた案

そもそも「異常な推移」の定義はどのようなものでしょうか? パッと思いつくのは「通常では考えられないような数字が観測される」事象を異常と定義する考え方です。これはレコードの欠損の検知や過去(例えば直近の過去半年間)に例のない好調な数字を発見するために有効な方法に思えます。もちろん手で閾値を定めてその範囲をはみ出す値を検知する方法もありますが、サービスはプラスにもマイナスにも成長するものなので過去のレコード数の集合を確認しながら確率的な外れ値を検知する方法が適していそうです。そこでZスコアを利用することを検討してみます。

Zスコア

頻度論的統計学においてよく使われる、個々の値が集合の平均値からどれくらい離れているかを標準偏差の単位で表す指標です。1日の学習者数studied_dauの過去180日間の値がstudy_recordsテーブルに入っているとき、昨日の値yesterday_studied_dauを用いると

SELECT ( yesterday_studied_dau - AVG(study_records.studied_dau) ) / STDDEV(study_records.studied_dau ) AS Z_score ;

として求められる指標です(統計学ではZ = (X - μ) / σ と表されることが多いですね)。
この指標が便利なのはデータの集合が正規分布であればZスコアの大きさを確率的な事象として扱うことができることです。例えばZスコアが±3よりも大きな絶対値を持っていた場合、その値が起こりうる確率はおよそ0.3%以下になります(十分に異常な値といえます)。

Zスコアを使うことの疑問点①

まず、重要な点としてZスコアによる判断は調査したいデータの集合が正規分布に則っていることが前提になっています。お手元のデータの集合が正規分布に沿っているか確認する手段としてQ-Qプロットを描画してみるのがお手軽です。Pythonで以下のように書きますので、早速調べてみました。

import pandas as pd
from scipy import stats
import matplotlib.pyplot as plt
 
df_sample = pd.read_csv("sample_records.csv")
 
# Q-Qプロットの描画
stats.probplot(df_sample["studied_dau"], dist="norm", plot=plt)
plt.show()

実行結果は以下。

qq_plotの出力例


正規分布であれば赤い直線上にデータがプロットされます。しかし…うーん。この場合はちょっと無理がありそうですね。

Zスコアを使うことの疑問点②

サービスとしてのZEN StudyにはN高グループの学習プラットフォームとしての役割も担っており、この4月にはZEN大学も開学しています。サービス全体の利用動向を1年を通してみれば当然ながら季節性は存在します(やはり年度始まりや受験期には利用が活発になります)し、月間に限った推移を見てもピークや曜日による利用の増減を観測できます。
「異常な推移」とは何かを考える上で、確率的に希少な数字を捕捉するだけでは実務的に不十分な場合が多いということに思い至ったというのが2つ目の疑問点です。

例えば、ある期間のDAU推移が以下の画像だったとします。

もともとのDAU推移


イメージしにくい人はX軸が夏休みの時間軸、Y軸が夏休み宿題に取り組んだ小学3年生のあるクラスの生徒数(DAU)として考えてみてください(これは自分の体験をもとにした一般的な例です)。
これが担任の先生による「なるべく早く宿題を終わらせよう」という指導の結果、以下のような推移になったとします。

介入後のDAU推移


Zスコアを利用した異常検知をするとは、下の例を異常と判断しないということです(素晴らしい先生なのに!)。
個人的には、ここが非常に悩みました。「ピーク時期に発生した閑散期と同規模のレコード」も異常なら、「閑散期に記録したピーク並の推移」も異常と検知したいのです。

…前置きが長くなりましたが、以上のような検討過程を経てZスコアによる異常検知を諦めました。

BigQueryで機械学習:ARIMA_PLUSモデル

悩みに悩んだ末にチームのメンバーに教えてもらったのがBigQuery MLで、機械学習の概要を読み進めると異常検知に特化した説明ページもあるではないですか! 自分は統計検定2級+G検定を持っているくらい…武器としてはやや心許ないところではありますが、優秀なデータサイエンティストが同僚として隣に控えています。これは試すしかない。

ARIMAとは?

ARIMAモデルとは産業界の需要予測などで利用されることが多い機械学習モデルで、AR(自己回帰)とI(差分)とMA(移動平均)を組み合わせて将来予測をしてくれます。過去のデータに明確なトレンドや季節性がある場合などに力を発揮してくれやすいそうです。モデルを作って学習させるには以下のように書きます。

--24年1月1日〜25年4月30日までの学習に取り組んだDAU推移を学習させる
CREATE OR REPLACE MODEL
  `your_project_name.studied_dau_arima_model`
OPTIONS
  (
    model_type = 'ARIMA_PLUS',
    time_series_timestamp_col = 'dt',
    time_series_data_col = 'dau',
    data_frequency = 'DAILY',
    auto_arima = TRUE,
    holiday_region = 'JP'
  ) AS
SELECT
  dt,
  dau
FROM `your_project_name.studied_dau_daily`
WHERE
  dt between '2024-01-01' and '2025-04-30'
;


ここではstudied_dau_dailyテーブルに2つのカラム「dt」と「dau」があるものとしてサンプルクエリを用意しています。OPTIONSの引数time_series_timestamp_colにdtを、time_series_data_colにdauをセット。平日と祝日が利用者の増減に影響するならholiday_regionを設定します。
今回は24年1月1日〜25年4月30日までの1年3ヶ月分のレコードをモデルに与えて学習させてみました。では、さっそくこのモデルを使って5月1日〜30日の学習に取り組んだDAUを予測してもらって、実測値と比較します。以下のように書きます。

--モデルの予測と現実の対比。試しに30日先まで予測してみる
SELECT
  i.dt,
  i.dau,
  j.forecast_value,
  j.prediction_interval_lower_bound,
  j.prediction_interval_upper_bound
FROM `your_project_name.studied_dau_daily` AS i
JOIN ML.FORECAST(MODEL `your_project_name.studied_dau_arima_model`,STRUCT(30 AS horizon)) AS j
ON i.dt = date(j.forecast_timestamp,'Asia/Tokyo')
WHERE
  i.dt between '2025-05-01' and '2025-05-30'
ORDER BY i.dt
;


ML.FORECAST関数でモデルと予測したい日数を渡すと、予測値(forecast_value)と予測の下限値(prediction_interval_lower_bound)、予測の上限値(prediction_interval_upper_bound)を出してくれます。おそらくパラメータの調整などに専門的な知見が要求されているような気がしますが総体としては「めっちゃ簡単! すごい」という感想です(小並感)。

予測結果を評価する

結果は以下のようになりました。

30日先まで予測させて実測値と比較


視認性を上げるため予測値の上限下限は同じ灰色の線で表現、実測値が青線、赤線が予測値です。

予測結果を評価するためMAE(平均絶対誤差) = 1/n Σ |実測値 - 予測値 | (SQLとしてはAVG(ABS(dau - forecast_value) )を求めて、DAU平均と対比します。

MAE / AVG(dau) = 0.112 ! これは予測値が実際の値の10%前後のズレしかないところで収まっていることを表していて、すごい精度です…異常検知の文脈では予測の下限値(prediction_interval_lower_bound)、予測の上限値(prediction_interval_upper_bound)を閾値としてそれを踏み越えたレコードが発生したらアラートを出せば良いように思います(上の例だと1日だけ発生していますね)。

異常検知方法

実はARIMA_PLUSモデルには異常検知に特化した関数が用意されています。ML.DETECT_ANOMALIES関数はモデル+閾値+実測値を渡すことでTRUE/FALSEの判断結果を返してくれる関数で、閾値を調整することで偽陽性(異常じゃないのにアラート)と偽陰性(異常なのにアラートが来ない)のバランスを調整することができます。

具体的には以下のように書きます。前述の24年1月1日〜25年4月30日までの教材に取り組んだDAU推移を学習したモデルを使って25年5月1日の実測値をチェックしてみます。

--モデル名、閾値(デフォルトは0.95)、実測値を渡す
SELECT
  *
FROM
  ML.DETECT_ANOMALIES(
    MODEL`your_project_name.studied_dau_arima_model`,
    STRUCT(0.95 AS anomaly_prob_threshold),
    (
      SELECT
        dt,
        dau
      FROM
        `your_project_name.studied_dau_daily`
      WHERE
       dt = '2025-05-01'
    )
  );


結果のカラム構成は

dt dau is_anomaly lower_bound upper_bound anomaly_probability
この場合は2025-05-01 ここに実測値 True/False 異常と検知されたらTrue 予測の下限値 予測の上限値 異常である確率(この値がanomaly_prob_thresholdを上回っていたらis_anomalyがTrueとなる)


となります。
異常検知に求められる検出の厳密さはビジネスの性格によって異なります。anomaly_prob_thresholdのデフォルト値0.95を上下させて実験し予測の上下限値の幅を確認しながら最適な塩梅を探し出したいですね。

異常検知の実装

さて、モデルの優秀さも学習の仕方、異常検知の書き方も理解したところで実務的なクエリを実装していきます。以下の要件をスケジュールドクエリに落とし込みます。

  • 毎日、前日のレコードを実測値として、その異常を検知する
  • モデルは前々日までの直近500日程度を学習したものとする
  • 異常検知の結果はテーブルにINSERTしていく
  • 最終的には↑BigQueryのテーブルをコネクテッドシートとしてGoogleSpreadSheetに連携し、そこからSlackへ通知する


--前日のレコードを使った異常検知の結果をテーブルに挿入
INSERT INTO `my_data_set.anomaly_records` (dt, dau, is_anomaly, lower_bound, upper_bound, anomaly_probability)
SELECT
  *
FROM
  ML.DETECT_ANOMALIES(
    MODEL`your_project_name.studied_dau_arima_model`,
    STRUCT(0.95 AS anomaly_prob_threshold),
    (
      SELECT
        dt,
        dau
      FROM
        `your_project_name.studied_dau_daily`
      WHERE
       dt = date_add(current_date('Asia/Tokyo'), interval -1 DAY) 
    )
  )
 
--501日前〜前日までのレコードを利用してモデルを再学習させる
CREATE OR REPLACE MODEL
  `your_project_name.studied_dau_arima_model`
OPTIONS
  (
    model_type = 'ARIMA_PLUS',
    time_series_timestamp_col = 'dt',
    time_series_data_col = 'dau',
    data_frequency = 'DAILY',
    auto_arima = TRUE,
    holiday_region = 'JP'
  ) AS
SELECT
  dt,
  dau
FROM `your_project_name.studied_dau_daily`
WHERE
  dt BETWEEN date_add(current_date('Asia/Tokyo'), interval -501 DAY) and date_add(current_date('Asia/Tokyo'), interval -1 DAY)
;


これを毎日、ディメンションテーブル(実測値を記録しているテーブル)の更新の後にスケジュールしておきます。

SLACK通知の実装

BigQueryの結果をSlackに通知する方法は様々ありますが、今回は以下のフローを構築し通知を実現します。

  1. 異常検知結果をTableにINSERT
  2. 1の結果をconnected_sheetととしてGoogleSpreadSheetに連携する
  3. GoogleAppScriptでスプレ更新タイミングで異常が検知されたら通知
  4. 通知を実現する前提としてSlackチャンネルにIncoming Webhookが追加されていることとします。


手順1、2、さらにIncoming-webhookの設定に関しては他の記事をご参照いただくとして、ここでは具体的にGoogle App Scriptのスクリプト部分を例示しておきます。connected_sheetには前出の異常検知の出力結果として6つのカラムが列A〜Fまで入っているものとします。

function sendSlackNotificatio() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('anomaly_records'); // 異常検知結果があるシート名は[anomaly_records]
  const lastRow = sheet.getLastRow();
  const range = sheet.getRange(lastRow, 1, 1, sheet.getLastColumn()); // 最終行のデータを取得
  const values = range.getValues()[0];
 
  // スプレッドシートの列のインデックス: 日付(0), 実測値(1), is_anomaly(2), 予測下限値(3), 予測上限値(4), 異常確率(5)
  const date = values[0];
  const sendDate = Utilities.formatDate(date, Session.getScriptTimeZone(), 'YYYY-MM-dd');
  const actualDAU = values[1];
  const isAnomaly = values[2];
  const lowerBound = values[3];
  const upperBound = values[4];
  const anomalyProb = values[5];
 
  console.log(date,isAnomaly);
 
  if (isAnomaly === true) { // is_anomalyがTRUEの場合のみ通知
    const webhookUrl = 'your_webhook_url'; // ここに取得したWebhook URLを貼り付ける
 
    const message = {
      text: `【ZENStudy_KPI異常検知アラート】\n日付: ${sendDate}\n実測値: ${actualDAU}\n異常判定: ${isAnomaly ? '異常' : '正常'}\n予測範囲: ${lowerBound}${upperBound}\n異常確率: ${anomalyProb}`,
      attachments: [
        {
          color: isAnomaly ? "#FF0000" : "#36a64f", // 異常なら赤、正常なら緑
          fields: [
            {
              title: "詳細",
              value: `BigQueryでの詳細データを確認してください。`,
              short: false
            }
          ]
        }
      ]
    };
 
    const options = {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(message)
    };
 
    UrlFetchApp.fetch(webhookUrl, options);
  }
}

この例ではif(isAnomaly === true) として異常が検知された時のみに送るスクリプトになっていますが、異常が起きないと機構が確認できないので、最初の数日はif( 1 )とかに書き換えておくのがオススメです。

いずれにしてもあんまり不要な通知が多いと人はメッセージに緊急性を見い出しにくいことは「狼が来た」という有名な寓話で知るところであります。適当なアラート量にコントロールしたいところですね。

まとめ

予想よりも簡単にML(Mathine Learning)を使うことができました。

実はかなり夢中になって遊んでいたので、まだまだ書き足りなかったくらいです。またの機会にARIMA_PLUS_XREGやオートエンコーダ、K-meansなども使ってみた結果をレポートしたいと思います。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。
私の所属するチームは企画職と企画開発エンジニア、データエンジニア、データサイエンティストが混在して様々な実験的な取り組みを一緒にドライブするとっても刺激的な職場です。今回はデータサイエンティストだけではなく異常検知の分野で知見のある同僚のデータエンジニアからもアドバイスをもらいました。私たちはそれぞれの専門性を大切にしつつ、同時に自分のスキルを広げていくぞ!というマインドも大事にしています。
そんな職場の雰囲気を知りたい場合はカジュアル面談も行っていますので、お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com