6
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?

BigQuery AI.FORECAST / AI.DETECT_ANOMALIES で時系列予測と異常検知

6
Posted at

概要

2026年4月に開催された Google Cloud Next '26 では、BigQuery に関する多数のアップデートが発表されました。BigQuery は、従来のデータウェアハウスとしての用途に加えて、AI / ML を活用するためのデータ分析基盤としての位置づけを強めています。

当記事では、その中でも KPI 分析に取り入れやすい AI.FORECASTAI.DETECT_ANOMALIES を取り上げます。EC サイトを想定したサンプルデータを作成し、売上や問い合わせ数といった時系列データに対して、予測と異常検知を行います。

BigQuery の最近の AI / ML 関連アップデートをキャッチアップするための、実践的な入門記事として位置づけています。


当記事のゴール

当記事では、以下の内容を実施します。

  1. EC サイトを想定したサンプルデータを作成する
  2. 日次 KPI を確認する
  3. AI.FORECAST で売上を予測する
  4. AI.DETECT_ANOMALIES で異常を検知する
  5. Shoes カテゴリの売上低下と問い合わせ増加を確認する

今回の主な目的は、KPI に対して「何が起きたのか」を BigQuery 上で把握することです。

異常の背景にある商品、顧客、問い合わせ、キャンペーンなどの関係性については、第2回で BigQuery Graph を用いて分析します。


想定するシナリオ

題材として、EC サイトを想定します。

ある時期に、Shoes カテゴリの売上が低下しました。また、同じ時期に Shoes 関連の問い合わせが増加しました。

このような状況に対して、BigQuery 上のデータを用いて以下を確認します。

いつ異常が起きたのか?
どの KPI が異常だったのか?
どのカテゴリで異常が起きたのか?

当記事では、これらの問いに対して、AI.FORECASTAI.DETECT_ANOMALIES でアプローチします。


前提知識

AI.FORECAST

AI.FORECAST は、時系列データの将来値を予測する BigQuery ML の関数です。

たとえば、過去の日次売上や注文数をもとに、将来の売上や注文数を予測することができます。BigQuery ML の組み込み TimesFM モデルを利用するため、明示的にモデルを作成することなく、SQL 関数として呼び出せる点が特徴です。

当記事では、カテゴリ別の売上に対して AI.FORECAST を実行します。

異常を検知するためには、まず「通常であれば、どの程度の値になるはずか」を把握する必要があります。この通常時の予測値を作成するために、AI.FORECAST を利用します。

AI.DETECT_ANOMALIES

AI.DETECT_ANOMALIES は、時系列データの異常を検知する BigQuery ML の関数です。

過去データから通常の傾向を把握し、検査対象のデータが通常の範囲から外れているかどうかを判定します。

当記事では、以下の KPI を対象に異常検知を行います。

  • 売上
  • 注文数
  • 問い合わせ数

これにより、カテゴリ別、KPI 別に、異常が発生している日を確認することができます。


実装

前提

当記事の手順では、以下の環境を前提とします。

  • Google Cloud にログインできること
  • 課金が有効な Google Cloud プロジェクトがあること
  • BigQuery を利用できる権限があること
  • BigQuery Studio を開けること

必要な権限の目安は以下のとおりです。

  • BigQuery Job User
  • BigQuery Data Editor
  • BigQuery Data Viewer

BigQuery Studio を開く

Google Cloud コンソールから BigQuery Studio を開きます。

  1. Google Cloud コンソールを開く
  2. 対象のプロジェクトを選択する
  3. 上部の検索バーに BigQuery と入力する
  4. BigQuery をクリックする
  5. BigQuery Studio が開くことを確認する
  6. 上部の SQL query をクリックする

以降は、SQL editor にクエリを貼り付けて実行します。


データセットの作成

まず、当記事で使用する作業用データセットを作成します。

BigQuery Studio の画面で、以下のように操作します。

  1. 左側の Explorer を開く
  2. 対象プロジェクト名を探す
  3. プロジェクト名の右側にある をクリックする
  4. Create dataset をクリックする
  5. Dataset IDbq_ai_graph_handson と入力する
  6. Location typeMulti-region を選択する
  7. Multi-regionUS を選択する
  8. Create dataset をクリックする

image.png

これにより、今回のハンズオン用データセットが作成されます。


サンプルデータの作成

次に、EC サイトを想定したサンプルデータを作成します。

作成するテーブルは以下のとおりです。

テーブル 内容
customers 顧客マスタ
products 商品マスタ
campaigns キャンペーン
orders 注文
order_items 注文明細
support_tickets 問い合わせ
daily_category_kpi 日次 KPI
kpi_long 異常検知用の KPI テーブル

このサンプルデータには、以下のような事象を意図的に含めています。

  • 2026年4月中旬に Shoes カテゴリの売上が低下する
  • 同じ時期に Shoes 関連の問い合わせが増加する
  • 問い合わせ理由として Delivery delay が増加する
  • Shoes 商品が Spring Shoes Sale キャンペーンに紐づく

SQL query をクリックし、以下の SQL を実行します。

CREATE OR REPLACE TABLE `bq_ai_graph_handson.customers` AS
SELECT
  customer_id,
  CONCAT('Customer ', CAST(customer_id AS STRING)) AS customer_name,
  CASE MOD(customer_id, 4)
    WHEN 0 THEN 'Tokyo'
    WHEN 1 THEN 'Osaka'
    WHEN 2 THEN 'Nagoya'
    ELSE 'Fukuoka'
  END AS region,
  CASE MOD(customer_id, 3)
    WHEN 0 THEN 'Enterprise'
    WHEN 1 THEN 'SMB'
    ELSE 'Consumer'
  END AS segment
FROM UNNEST(GENERATE_ARRAY(1, 60)) AS customer_id;

CREATE OR REPLACE TABLE `bq_ai_graph_handson.products` AS
SELECT * FROM UNNEST([
  STRUCT(101 AS product_id, 'Urban Sneaker' AS product_name, 'Shoes' AS category, 12000 AS unit_price, 1 AS campaign_id),
  STRUCT(102 AS product_id, 'Trail Runner' AS product_name, 'Shoes' AS category, 15000 AS unit_price, 1 AS campaign_id),
  STRUCT(103 AS product_id, 'Classic Leather' AS product_name, 'Shoes' AS category, 18000 AS unit_price, 1 AS campaign_id),
  STRUCT(201 AS product_id, 'Daily Backpack' AS product_name, 'Bags' AS category, 9000 AS unit_price, 2 AS campaign_id),
  STRUCT(202 AS product_id, 'Travel Tote' AS product_name, 'Bags' AS category, 11000 AS unit_price, 2 AS campaign_id),
  STRUCT(301 AS product_id, 'Logo Cap' AS product_name, 'Accessories' AS category, 3500 AS unit_price, 3 AS campaign_id),
  STRUCT(302 AS product_id, 'Sport Socks' AS product_name, 'Accessories' AS category, 1800 AS unit_price, 3 AS campaign_id),
  STRUCT(401 AS product_id, 'Light Jacket' AS product_name, 'Apparel' AS category, 16000 AS unit_price, 2 AS campaign_id),
  STRUCT(402 AS product_id, 'Dry T-Shirt' AS product_name, 'Apparel' AS category, 5000 AS unit_price, 3 AS campaign_id)
]);

CREATE OR REPLACE TABLE `bq_ai_graph_handson.campaigns` AS
SELECT * FROM UNNEST([
  STRUCT(1 AS campaign_id, 'Spring Shoes Sale' AS campaign_name, DATE '2026-04-10' AS start_date, DATE '2026-04-20' AS end_date),
  STRUCT(2 AS campaign_id, 'Travel Goods Fair' AS campaign_name, DATE '2026-03-01' AS start_date, DATE '2026-03-15' AS end_date),
  STRUCT(3 AS campaign_id, 'Accessory Bundle' AS campaign_name, DATE '2026-02-01' AS start_date, DATE '2026-02-14' AS end_date)
]);

CREATE OR REPLACE TABLE `bq_ai_graph_handson.orders` AS
WITH dates AS (
  SELECT d AS order_date
  FROM UNNEST(GENERATE_DATE_ARRAY(DATE '2026-01-01', DATE '2026-04-30')) AS d
),
base AS (
  SELECT
    d.order_date,
    c.customer_id
  FROM dates d
  CROSS JOIN `bq_ai_graph_handson.customers` c
  WHERE MOD(ABS(FARM_FINGERPRINT(CONCAT(CAST(d.order_date AS STRING), '-', CAST(c.customer_id AS STRING)))), 10) < 3
)
SELECT
  100000 + ROW_NUMBER() OVER() AS order_id,
  order_date,
  customer_id,
  CASE
    WHEN order_date BETWEEN DATE '2026-04-10' AND DATE '2026-04-20' THEN 1
    WHEN order_date BETWEEN DATE '2026-03-01' AND DATE '2026-03-15' THEN 2
    WHEN order_date BETWEEN DATE '2026-02-01' AND DATE '2026-02-14' THEN 3
    ELSE NULL
  END AS campaign_id,
  CASE
    WHEN order_date BETWEEN DATE '2026-04-15' AND DATE '2026-04-18'
      AND MOD(customer_id, 3) != 0 THEN 'cancelled'
    ELSE 'completed'
  END AS status
FROM base;

CREATE OR REPLACE TABLE `bq_ai_graph_handson.order_items` AS
SELECT
  order_id,
  CASE
    WHEN order_date BETWEEN DATE '2026-04-10' AND DATE '2026-04-20'
      THEN [101, 102, 103][ORDINAL(1 + MOD(order_id, 3))]
    WHEN MOD(order_id, 9) IN (0, 1) THEN 201
    WHEN MOD(order_id, 9) IN (2, 3) THEN 202
    WHEN MOD(order_id, 9) = 4 THEN 301
    WHEN MOD(order_id, 9) = 5 THEN 302
    WHEN MOD(order_id, 9) = 6 THEN 401
    WHEN MOD(order_id, 9) = 7 THEN 402
    ELSE 101
  END AS product_id,
  1 + MOD(order_id, 3) AS quantity
FROM `bq_ai_graph_handson.orders`;

CREATE OR REPLACE TABLE `bq_ai_graph_handson.support_tickets` AS
WITH purchase_rows AS (
  SELECT
    o.order_id,
    o.customer_id,
    o.order_date,
    oi.product_id,
    p.category
  FROM `bq_ai_graph_handson.orders` o
  JOIN `bq_ai_graph_handson.order_items` oi
    USING (order_id)
  JOIN `bq_ai_graph_handson.products` p
    USING (product_id)
)
SELECT
  200000 + ROW_NUMBER() OVER() AS ticket_id,
  customer_id,
  DATE_ADD(order_date, INTERVAL 1 DAY) AS ticket_date,
  product_id,
  CASE
    WHEN category = 'Shoes'
      AND DATE_ADD(order_date, INTERVAL 1 DAY) BETWEEN DATE '2026-04-15' AND DATE '2026-04-21'
      THEN 'Delivery delay'
    WHEN MOD(order_id, 5) = 0 THEN 'Return request'
    WHEN MOD(order_id, 5) = 1 THEN 'Size issue'
    ELSE 'General question'
  END AS issue_type,
  CASE
    WHEN category = 'Shoes'
      AND DATE_ADD(order_date, INTERVAL 1 DAY) BETWEEN DATE '2026-04-15' AND DATE '2026-04-21'
      THEN 'Customers report delayed delivery after the Spring Shoes Sale.'
    ELSE 'Regular support inquiry.'
  END AS description
FROM purchase_rows
WHERE
  (
    category = 'Shoes'
    AND DATE_ADD(order_date, INTERVAL 1 DAY) BETWEEN DATE '2026-04-15' AND DATE '2026-04-21'
  )
  OR MOD(ABS(FARM_FINGERPRINT(CONCAT(CAST(order_date AS STRING), '-', CAST(customer_id AS STRING), '-', CAST(product_id AS STRING)))), 25) = 0;

CREATE OR REPLACE TABLE `bq_ai_graph_handson.daily_category_kpi` AS
WITH dates AS (
  SELECT d AS date
  FROM UNNEST(GENERATE_DATE_ARRAY(DATE '2026-01-01', DATE '2026-04-30')) AS d
),
categories AS (
  SELECT DISTINCT category
  FROM `bq_ai_graph_handson.products`
),
sales AS (
  SELECT
    o.order_date AS date,
    p.category,
    SUM(IF(o.status = 'completed', oi.quantity * p.unit_price, 0)) AS revenue,
    COUNT(DISTINCT IF(o.status = 'completed', o.order_id, NULL)) AS order_count
  FROM `bq_ai_graph_handson.orders` o
  JOIN `bq_ai_graph_handson.order_items` oi
    USING (order_id)
  JOIN `bq_ai_graph_handson.products` p
    USING (product_id)
  GROUP BY date, category
),
tickets AS (
  SELECT
    t.ticket_date AS date,
    p.category,
    COUNT(*) AS ticket_count
  FROM `bq_ai_graph_handson.support_tickets` t
  JOIN `bq_ai_graph_handson.products` p
    USING (product_id)
  GROUP BY date, category
)
SELECT
  d.date,
  c.category,
  COALESCE(s.revenue, 0) AS revenue,
  COALESCE(s.order_count, 0) AS order_count,
  COALESCE(t.ticket_count, 0) AS ticket_count
FROM dates d
CROSS JOIN categories c
LEFT JOIN sales s
  ON d.date = s.date
  AND c.category = s.category
LEFT JOIN tickets t
  ON d.date = t.date
  AND c.category = t.category;

CREATE OR REPLACE TABLE `bq_ai_graph_handson.kpi_long` AS
SELECT date, category, 'revenue' AS kpi_name, CAST(revenue AS FLOAT64) AS value
FROM `bq_ai_graph_handson.daily_category_kpi`
UNION ALL
SELECT date, category, 'order_count' AS kpi_name, CAST(order_count AS FLOAT64) AS value
FROM `bq_ai_graph_handson.daily_category_kpi`
UNION ALL
SELECT date, category, 'ticket_count' AS kpi_name, CAST(ticket_count AS FLOAT64) AS value
FROM `bq_ai_graph_handson.daily_category_kpi`;

スクリーンショット 2026-05-03 0.45.06.png

実行後、Explorerbq_ai_graph_handson データセットを開き、8 個のテーブルが作成されていることを確認します。
スクリーンショット 2026-05-03 0.47.13.png

campaignsテーブルの中身も見てみましょう。
スキーマタブを開くことで、テーブルの列名などのスキーマ情報を確認できます。
スクリーンショット 2026-05-03 0.47.38.png

プレビュータブを開くことで、テーブルに格納されている実データを確認できます。
スクリーンショット 2026-05-03 0.50.03.png


KPI の確認

AI を使う前に、まず通常の SQL で KPI を確認します。

SELECT
  date,
  category,
  revenue,
  order_count,
  ticket_count
FROM `bq_ai_graph_handson.daily_category_kpi`
WHERE date BETWEEN DATE '2026-04-10' AND DATE '2026-04-22'
ORDER BY date, category;

確認するポイントは以下のとおりです。

  • Shoes カテゴリの行を確認する
  • 2026年4月中旬のデータを確認する
  • revenue が低下しているか確認する
  • ticket_count が増加しているか確認する
    スクリーンショット 2026-05-03 0.53.44.png

全体の推移を集計して確認する場合は、以下の SQL を実行します。

SELECT
  date,
  SUM(revenue) AS total_revenue,
  SUM(order_count) AS total_order_count,
  SUM(ticket_count) AS total_ticket_count
FROM `bq_ai_graph_handson.daily_category_kpi`
GROUP BY date
ORDER BY date;

スクリーンショット 2026-05-03 0.55.51.png

AI の出力を確認する前に、元データの傾向を把握しておくことは重要です。これにより、後続の予測結果や異常検知結果を解釈しやすくなります。


AI.FORECAST による売上予測

ここから AI.FORECAST を使います。まずはカテゴリ別の売上を予測します。

今回は、2026-04-07 までの売上データを過去データとして使用し、その後 23 日分を予測します。

AI.FORECAST では、予測対象となる時系列データをサブクエリで渡します。今回の例では、以下の 3 列を入力データとして使います。

役割
date 時系列の日付です。予測の時間軸になります。
category 商品カテゴリです。カテゴリごとに別の時系列として扱います。
revenue 売上です。今回の予測対象です。

つまり、今回のクエリは「カテゴリごとの日次売上」を予測します。

SELECT *
FROM AI.FORECAST(
  (
    SELECT
      date,
      category,
      revenue
    FROM `bq_ai_graph_handson.daily_category_kpi`
    WHERE date <= DATE '2026-04-07'
  ),
  data_col => 'revenue',
  timestamp_col => 'date',
  id_cols => ['category'],
  horizon => 23,
  model => 'TimesFM 2.5',
  output_historical_time_series => TRUE
)
ORDER BY category, time_series_timestamp;

主な引数は以下のとおりです。

引数 今回の指定 意味
data_col 'revenue' 予測対象の数値列です。ここで指定した列が予測されます。
timestamp_col 'date' 時系列の日付列です。時間の並びを表します。
id_cols ['category'] 時系列を分ける列です。今回はカテゴリごとに別々に予測します。
horizon 23 予測する期間です。今回は 23 日先まで予測します。
model 'TimesFM 2.5' 使用する時系列モデルです。
output_historical_time_series TRUE 履歴データも結果に含めるかどうかです。

それぞれの値を変えると、予測の対象や粒度が変わります。

たとえば、data_colorder_count に変えると注文数の予測になります。id_colscategory を指定しているため、Shoes、Bags、Accessories などのカテゴリごとに別々の予測が行われます。horizon を大きくすると、より先の日付まで予測されます。

出力では、以下の列を確認します。

意味
time_series_type history または forecast を表します。
time_series_timestamp 対象の日付です。
time_series_data 実績値または予測値です。
prediction_interval_lower_bound 予測下限です。
prediction_interval_upper_bound 予測上限です。

スクリーンショット 2026-05-03 1.09.02.png

output_historical_time_series => TRUE を指定しているため、履歴データと予測データの両方が出力されます。

time_series_type = 'history' の行は、過去の実績データです。この行では、prediction_interval_lower_boundprediction_interval_upper_boundNULL になります。予測区間を確認したい場合は、time_series_type = 'forecast' の行を確認します。

予測結果のみを確認したい場合は、以下のようにします。

SELECT *
FROM AI.FORECAST(
  (
    SELECT
      date,
      category,
      revenue
    FROM `bq_ai_graph_handson.daily_category_kpi`
    WHERE date <= DATE '2026-04-07'
  ),
  data_col => 'revenue',
  timestamp_col => 'date',
  id_cols => ['category'],
  horizon => 23,
  model => 'TimesFM 2.5'
)
ORDER BY category, forecast_timestamp;

この結果では、以下の列が重要です。

意味
forecast_timestamp 予測対象の日付です。
forecast_value 予測値です。
prediction_interval_lower_bound 予測下限です。
prediction_interval_upper_bound 予測上限です。

forecast_value が予測された売上です。prediction_interval_lower_boundprediction_interval_upper_bound は、予測値の幅を表します。実績値がこの範囲から大きく外れている場合、通常とは異なる動きの候補として確認できます。

スクリーンショット 2026-05-03 1.15.10.png


実績と予測の比較

次に、Shoes カテゴリに絞って、実績値と予測値を比較します。

WITH forecast AS (
  SELECT *
  FROM AI.FORECAST(
    (
      SELECT
        date,
        category,
        revenue
      FROM `bq_ai_graph_handson.daily_category_kpi`
      WHERE date <= DATE '2026-04-07'
    ),
    data_col => 'revenue',
    timestamp_col => 'date',
    id_cols => ['category'],
    horizon => 23,
    model => 'TimesFM 2.5'
  )
)
SELECT
  f.category,
  DATE(f.forecast_timestamp) AS date,
  f.forecast_value,
  f.prediction_interval_lower_bound,
  f.prediction_interval_upper_bound,
  k.revenue AS actual_revenue
FROM forecast f
JOIN `bq_ai_graph_handson.daily_category_kpi` k
  ON f.category = k.category
  AND DATE(f.forecast_timestamp) = k.date
WHERE f.category = 'Shoes'
ORDER BY date;

確認するポイントは以下のとおりです。

  • actual_revenue が実績値である
  • forecast_value が予測値である
  • 実績値が予測下限を下回っていないか確認する
  • 2026年4月中旬に大きな差がないか確認する

スクリーンショット 2026-05-03 1.17.44.png

この比較により、Shoes カテゴリの売上に通常とは異なる動きがあることを確認できます。

ただし、この段階では異常であると機械的に判定しているわけではありません。次に、AI.DETECT_ANOMALIES を用いて異常検知を行います。


AI.DETECT_ANOMALIES による異常検知

次に、AI.DETECT_ANOMALIES を使って異常検知を行います。

AI.DETECT_ANOMALIES には、過去データと検査対象データを渡します。過去データから通常の傾向を把握し、検査対象データが通常の範囲から外れているかを判定します。

今回は、以下のように期間を分けます。

データ 期間 役割
過去データ 2026-01-01 〜 2026-04-07 通常の傾向を把握するためのデータ
検査対象データ 2026-04-08 〜 2026-04-30 異常かどうかを判定するデータ

入力に使う kpi_long テーブルは、KPI を縦持ちにしたテーブルです。

役割
date 時系列の日付です。異常検知の時間軸になります。
category 商品カテゴリです。カテゴリごとに別の時系列として扱います。
kpi_name KPI 名です。売上、注文数、問い合わせ数を区別します。
value KPI の値です。今回の異常検知の対象です。

つまり、今回のクエリは「カテゴリ × KPI」ごとに時系列を分けて、異常を検知します。

たとえば、以下のような時系列が別々に扱われます。

Shoes × revenue
Shoes × order_count
Shoes × ticket_count
Bags × revenue
Bags × order_count
Bags × ticket_count

まずは、異常行だけに絞らず、全体の結果を確認します。

WITH result AS (
  SELECT *
  FROM AI.DETECT_ANOMALIES(
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date <= DATE '2026-04-07'
    ),
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date BETWEEN DATE '2026-04-08' AND DATE '2026-04-30'
    ),
    data_col => 'value',
    timestamp_col => 'date',
    id_cols => ['category', 'kpi_name'],
    model => 'TimesFM 2.5',
    anomaly_prob_threshold => 0.95
  )
)
SELECT *
FROM result
ORDER BY anomaly_probability DESC;

主な引数は以下のとおりです。

引数 今回の指定 意味
1つ目のサブクエリ date <= DATE '2026-04-07' 通常の傾向を把握するための過去データです。
2つ目のサブクエリ 2026-04-08 以降 異常かどうかを判定する検査対象データです。
data_col 'value' 異常検知の対象となる数値列です。
timestamp_col 'date' 時系列の日付列です。
id_cols ['category', 'kpi_name'] 時系列を分ける列です。今回はカテゴリと KPI 名ごとに判定します。
model 'TimesFM 2.5' 使用する時系列モデルです。
anomaly_prob_threshold 0.95 異常と判定するしきい値です。

それぞれの値を変えると、異常検知の対象や判定の細かさが変わります。

たとえば、data_col は異常検知したい数値列です。今回は value を指定しているため、売上、注文数、問い合わせ数の値を異常検知の対象にしています。

id_colscategorykpi_name を指定しているため、カテゴリ別、KPI 別に別々の時系列として扱われます。もし id_cols['category'] だけにすると、KPI 名ごとの違いを区別できなくなります。

anomaly_prob_threshold は、異常と判定するしきい値です。値を大きくすると、異常と判定される行は少なくなります。値を小さくすると、異常と判定される行は多くなります。まずは 0.95 で確認し、結果が少ない場合に 0.90.8 に下げて確認するのがよいでしょう。

出力では、以下の列を確認します。

意味
category 商品カテゴリです。
kpi_name KPI 名です。
time_series_timestamp 判定対象の日付です。
time_series_data 実績値です。
is_anomaly 異常と判定されたかどうかです。
lower_bound 予測下限です。
upper_bound 予測上限です。
anomaly_probability 異常らしさを表す値です。

is_anomaly = TRUE の行が、異常候補です。

ただし、最初から WHERE is_anomaly = TRUE を指定すると、結果には異常行だけが表示されます。その場合、表示された行の is_anomaly はすべて TRUE になります。全体のうち何件が異常かを見たい場合は、まず絞り込みを行わずに確認します。

件数を確認する場合は、以下の SQL を実行します。

WITH result AS (
  SELECT *
  FROM AI.DETECT_ANOMALIES(
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date <= DATE '2026-04-07'
    ),
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date BETWEEN DATE '2026-04-08' AND DATE '2026-04-30'
    ),
    data_col => 'value',
    timestamp_col => 'date',
    id_cols => ['category', 'kpi_name'],
    model => 'TimesFM 2.5',
    anomaly_prob_threshold => 0.95
  )
)
SELECT
  is_anomaly,
  COUNT(*) AS row_count
FROM result
GROUP BY is_anomaly
ORDER BY is_anomaly;

異常候補だけを確認する場合は、以下のようにします。

WITH result AS (
  SELECT *
  FROM AI.DETECT_ANOMALIES(
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date <= DATE '2026-04-07'
    ),
    (
      SELECT
        date,
        category,
        kpi_name,
        value
      FROM `bq_ai_graph_handson.kpi_long`
      WHERE date BETWEEN DATE '2026-04-08' AND DATE '2026-04-30'
    ),
    data_col => 'value',
    timestamp_col => 'date',
    id_cols => ['category', 'kpi_name'],
    model => 'TimesFM 2.5',
    anomaly_prob_threshold => 0.95
  )
)
SELECT *
FROM result
WHERE is_anomaly = TRUE
ORDER BY anomaly_probability DESC;

スクリーンショット 2026-05-03 1.30.49.png

結果が少ない場合は、しきい値を下げて確認します。

SELECT *
FROM AI.DETECT_ANOMALIES(
  (
    SELECT
      date,
      category,
      kpi_name,
      value
    FROM `bq_ai_graph_handson.kpi_long`
    WHERE date <= DATE '2026-04-07'
  ),
  (
    SELECT
      date,
      category,
      kpi_name,
      value
    FROM `bq_ai_graph_handson.kpi_long`
    WHERE date BETWEEN DATE '2026-04-08' AND DATE '2026-04-30'
  ),
  data_col => 'value',
  timestamp_col => 'date',
  id_cols => ['category', 'kpi_name'],
  model => 'TimesFM 2.5',
  anomaly_prob_threshold => 0.6
)
WHERE is_anomaly = TRUE
ORDER BY anomaly_probability DESC;

Shoes カテゴリの詳細確認

異常検知の結果を確認したら、Shoes カテゴリに絞って詳細を確認します。

SELECT
  date,
  category,
  revenue,
  order_count,
  ticket_count
FROM `bq_ai_graph_handson.daily_category_kpi`
WHERE category = 'Shoes'
  AND date BETWEEN DATE '2026-04-10' AND DATE '2026-04-22'
ORDER BY date;

ここでは、以下を確認します。

  • 売上が低下した日
  • 注文数が低下した日
  • 問い合わせ数が増加した日
  • それらが同じ期間に発生しているか

スクリーンショット 2026-05-03 1.43.57.png

さらに、問い合わせ内容を確認します。

SELECT
  ticket_date,
  issue_type,
  COUNT(*) AS ticket_count
FROM `bq_ai_graph_handson.support_tickets` t
JOIN `bq_ai_graph_handson.products` p
  USING (product_id)
WHERE p.category = 'Shoes'
  AND ticket_date BETWEEN DATE '2026-04-10' AND DATE '2026-04-22'
GROUP BY ticket_date, issue_type
ORDER BY ticket_date, ticket_count DESC;

スクリーンショット 2026-05-03 1.45.18.png

Delivery delay が増加している場合、配送遅延が原因候補の 1 つとして考えられます。

ただし、この時点ではまだ仮説です。原因を断定するには、商品、顧客、問い合わせ、キャンペーンの関係をさらに確認する必要があります。


考察

ここまでの結果から、以下のような状況が確認できます。

2026年4月中旬に、Shoes カテゴリで売上の低下が見られた。
同じ時期に、Shoes 関連の問い合わせが増加した。
問い合わせ理由として Delivery delay が多かった。

このことから、以下のような仮説を立てることができます。

Spring Shoes Sale の期間中、またはその直後に、Shoes 商品の配送遅延が発生した。
その結果として問い合わせが増加し、売上にも影響した可能性がある。

当記事では、AI.FORECASTAI.DETECT_ANOMALIES により、「何が起きたのか」を確認しました。

一方で、なぜそれが起きたのか、どの商品や顧客に影響したのか、どのキャンペーンと関係しているのか、といった点は、さらに関係性をたどって調査する必要があります。


今回のポイント

今回のポイントは以下の 3 点です。

1 つ目は、AI.FORECAST によって、SQL から時系列予測を実行できることです。明示的にモデルを作成しなくても、カテゴリ別の売上予測を行うことができます。

2 つ目は、AI.DETECT_ANOMALIES によって、複数の時系列に対してまとめて異常検知を行えることです。id_cols にカテゴリや KPI 名を指定することで、カテゴリ別、KPI 別の異常を確認できます。

3 つ目は、異常検知は調査のゴールではなく、起点であるということです。異常を検知した後は、その異常に関連する商品、顧客、問い合わせ、キャンペーンなどの関係をたどる必要があります。


次回予告

次回は、BigQuery Graph を使います。

今回見つけた Shoes カテゴリの異常を起点に、以下の関係をたどります。

Customer -- Purchased --> Product
Customer -- Opened --> SupportTicket
SupportTicket -- RelatedTo --> Product
Product -- PromotedBy --> Campaign

第2回では、GQL を用いて以下の問いに答えます。

Shoes を購入した顧客は誰か?
その顧客はどのような問い合わせを出しているか?
問い合わせはどの商品に集中しているか?
その商品はどのキャンペーンに紐づいているか?

AI.FORECASTAI.DETECT_ANOMALIES で「何が起きたか」を確認し、BigQuery Graph で「何が関係していそうか」をたどる構成です。


まとめ

当記事では、AI.FORECASTAI.DETECT_ANOMALIES を使って、EC サイトを想定したデータの KPI 異常を検知しました。

AI.FORECAST により通常時の予測値を作成し、AI.DETECT_ANOMALIES により予測範囲から外れた値を検知しました。

今回確認できた内容は以下のとおりです。

2026年4月中旬に Shoes カテゴリの売上が低下した。
同じ時期に Shoes 関連の問い合わせが増加した。
問い合わせ理由として Delivery delay が多かった。

ただし、当記事ではまだ原因を断定していません。

次回は、BigQuery Graph と GQL を用いて、顧客、商品、問い合わせ、キャンペーンの関係をたどり、異常の原因候補を整理します。

6
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
6
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?