概要
BigQuery・dbt・Elementary は未実行。数値は Python(標準ライブラリのみ)で組んだ合成データ(2024-11〜2026-10、乱数の種 5 つ)上の実測。案 A は Elementary のドキュメントの記述から再現した相当実装、案 B は構造からの導出。
系列ごとに見る
全体の 1.19% の系列が 38% 欠けても、テーブル全体では −0.45%。
基準を作る
同じ曜日の中央値。販促カレンダーの日は学習から外し、効果を引く。
予算から閾値
誤報は月 1 回まで → 校正用データで k = 4.0。鳴った日は基準に入れない。
比と照合
件数のぶれ 0.107 → 段の比 0.045 → 注文 DB との照合 0.016。
問題
MOps 販促システムチームは GA4 / Firebase の BigQuery エクスポートを dbt で日次集計し、カゴ落ちメール・リターゲティング・CVR ダッシュボードに使っている。3 月に次のことが起きた(合成データ上の値)。
| 日 | 出来事 |
|---|---|
| 3/10(火) | iOS アプリ v5.2 リリース。カート追加イベントの送信が一部の経路で抜けた(−38%) |
| 3/10〜3/20 | dbt build は毎晩緑。「行数が前日比 ±30%」の通知は月 2〜3 回来ていたが毎回「セールだから」で、チャンネルはミュート |
| 3/21(土) | マーケが「iOS の CVR が急に 1.6 倍」と問い合わせ。分母が欠けていただけ |
| 判明後 | 11 日間で欠けた iOS カート追加 106,184 件 実測・合成 |
この実装と運用には 8 つの問題 が隠れています。見つけてから先へ進んでください。
# sources: freshness warn_after 26 hour(最新の行の時刻だけ)
# stg_events: event_id not_null / unique, platform accepted_values
THRESHOLD = 0.30
def check(client, slack):
rows = client.query("""select event_date, count(*) as n from `mops.stg_events`
where event_date >= date_sub(current_date('Asia/Tokyo'), interval 2 day)
group by 1 order by 1""").result()
yesterday, today = [r.n for r in rows][-2:]
if abs(today / yesterday - 1) > THRESHOLD: # テーブル全体・前日比
slack.post("#data-alerts", f"stg_events の行数が前日比 {today/yesterday-1:+.0%}")
- 系列 12(ios / android / web × page_view / product_view / add_to_cart / purchase)。曜日・共通のぶれ約 5%・platform ごと約 3% 仮定
- 販促カレンダーあり: 月次セール(25〜28 日、流入 1.6 倍)・Black Friday・年末年始・GW・お盆。カレンダーに無い急増が年 3 日 仮定
- 注文 DB は正しい。web の purchase イベントは注文の 93% しか届かない 仮定
- チームの合意: 誤報は月 1 回まで 仮定
- 案 A(後輩): Elementary の
volume_anomaliesを既定値で。案 B(先輩): SDK の送信カウンタと到着数を照合
期待する回答形式: 問題点 8 点/改善後の dbt モデル・判定ロジック・閾値の決め方・通知の運用/実行例(誤報数と検知の遅れ)/案 A・B の評価と前提が変わったときの判断/扱わなかった範囲
ヒント(段階的開示)
ヒント1 — 方向性
欠けた行は、行を検査するテストでは原理的に見つからない(検査される行が無い)。見るのは件数という時系列。「何と比べて少ないのか」の基準が、前日・先週・平均のそれぞれで何に壊されるかを考える。
ヒント2 — アプローチ
- テーブル全体ではなく系列ごとに
- 曜日は同じ曜日と比べ、催事は過去の同じ種類の催事の効果を引く。催事日を学習に入れるとばらつきが膨らむ
- 件数より比(カート ÷ 商品閲覧)のほうが静か
- 独立した数え先(注文 DB)があるものは照合
- 閾値は誤報の予算から逆算し、鳴った日は基準に入れない
ヒント3 — 誘導
level = median(同じ曜日・催事でない・鳴っていない直近 8 日の log 件数)
resid = log(今日) - level - (催事日なら 過去の同種の催事の resid の中央値)
scale = 1.4826 * MAD(直近 56 日の clean な resid)
fired = 0 件 or |resid / scale| >= K # K は誤報予算から校正問題点分析(8 点)
| # | 問題点 | 何が起きるか(実測・合成) |
|---|---|---|
| 1 | テーブル全体の行数しか見ない | iOS カート追加は全行数の 1.19%。38% 欠けても全体は −0.45% |
| 2 | 前日比で比べる | 誤報 2.56/月。153 回の内訳は月次セール 60・セール明け 51・カレンダー外の急増 25・BF と連休 17 → ミュートされる |
| 3 | 前日比は 2 日目から黙る | 2 日目は壊れた前日と比べるので変化 0%。続く事故ほど見えない |
| 4 | 30% に根拠がない | 取り込みが 18 時で止まった日(−28%)を 5 種中 4 種で見逃す。確実なのは二重取り込みだけ |
| 5 | 系列を名指せない | 「stg_events が −31%」では iOS チームに届かない |
| 6 | 段の比を見ない | 件数の残差のばらつき 0.107、比なら 0.045。−20% の欠落は件数では見えない |
| 7 | 注文 DB と照合しない | purchase ÷ 注文のばらつき 0.016。−12%(Safari の一部)は照合なら初日に見える |
| 8 | dbt test が行の中身しか見ない | 欠けた行は not_null・unique の対象に入らない。freshness は「最新の行の時刻」だけ |
図(SVG)— 基準をどう作るか
I6(週 1.5% ずつ減る)は Good でも 7〜68 日かかった。日々のぶれ 0.07〜0.1 の件数で緩やかな変化を早く見るのは今回の設計の範囲外。
Bad → Good(コード差分)
Bad
# テーブル全体の行数
select event_date, count(*) as n
from `mops.stg_events` group by 1
THRESHOLD = 0.30 # 根拠なし
# 前日と比べる(曜日・セールで毎月鳴る)
if abs(today / yesterday - 1) > THRESHOLD:
slack.post("#data-alerts", ...)
# 系列を名指せない・持ち主不明
# 2 日目は壊れた前日と比べて黙る
# dbt test: not_null / unique / accepted_values
# → 欠けた行は検査の対象に来ない
# freshness: 最新の行の時刻だけGood
-- 24 系列: 件数 12・段の比 9・注文 DB との照合 3
-- 0 件の日も行を作る(grid)
{% set K = 4.0 %} -- 誤報予算 月1回 から校正
level = 同じ曜日 clean 8 日の中央値
effect = 過去の同種の催事の残差の中央値
-- 過去 3 回未満なら unchecked
scale = 1.4826 * MAD(直近 56 日の clean な残差)
fired = value = 0 or |z| >= K
is_clean = kind is null and not fired
-- 鳴った日・催事日は基準に戻さない
-- 意図した変化は mon_baseline_resets に登録
# 通知: 1 日 1 通、系列の持ち主へメンション
# 続く事故は「継続 N 日目」で同じスレッド模範解答(要点)
方針
| 何を | どう | 理由(実測) |
|---|---|---|
| 監視の単位 | テーブル全体 → 系列 12 + 段の比 9 + 照合 3 | #1・#5・#6・#7 |
| 水準 | 同じ曜日・催事でない・鳴っていない直近 8 日の中央値(log) | #2・#3 |
| 催事 | カレンダーの日は学習から外し、過去の同種の催事の効果を引いて判定 | 外すと誤報 4.40/月(k=8 でも予算に入らない) |
| 閾値 | 誤報予算 月 1 回 → 校正用の種 101〜105 で k = 4.0 | #4 |
| 鳴った日 | 基準にもばらつきにも入れない | 入れると I1 の鳴る日 100% → 75%、I7 90% → 33% |
| 0 件 | 統計の外。ばらつきが 0 でも必ず鳴らす | 手で作ったデータで確認 |
dbt モデル(系列を縦持ちで 1 か所に)
-- models/monitoring/mon_series_daily.sql 粒度: d × series
with ev as (
select parse_date('%Y%m%d', event_date) as d, lower(platform) as platform,
case event_name when 'view_item' then 'product_view' else event_name end as event,
count(*) as n
from {{ source('ga4', 'events') }}
where _table_suffix >= format_date('%Y%m%d', date_sub(current_date('Asia/Tokyo'), interval 3 day))
and event_name in ('page_view', 'view_item', 'add_to_cart', 'purchase')
group by 1, 2, 3
), grid as ( -- 0 件の日も行を作る。行が無いと「0 件」を検査できない(#8)
select d, platform, event, coalesce(ev.n, 0) as n
from unnest(generate_date_array(...)) as d
cross join unnest(['ios', 'android', 'web']) as platform
cross join unnest(['page_view', 'product_view', 'add_to_cart', 'purchase']) as event
left join ev using (d, platform, event)
), wide as (...), orders as ( -- 独立した数え先
select order_date as d, platform, count(*) as n_orders from {{ ref('fct_orders') }} group by 1, 2
)
select d, concat('vol:', platform, ':', event) as series, n as value from grid
union all select d, concat('ratio:', platform, ':', s.name), safe_divide(s.num, s.den) from wide, unnest([...]) s
union all select d, concat('recon:', platform, ':purchase/orders'), safe_divide(pur, n_orders)
from wide join orders using (d, platform)
判定(1 日ずつ積む incremental)
-- models/monitoring/mon_series_alerts.sql 過去の行({{ this }})を基準に使う
{% set K = 4.0 %} {% set LEVEL_WEEKS = 8 %} {% set SCALE_DAYS = 56 %} {% set MAD_TO_SIGMA = 1.4826 %}
level as ( -- 先に直近 8 日に絞り(内側の QUALIFY)、外側で中央値。逆にすると全履歴の中央値
select series, any_value(med) as level from (
select series, percentile_cont(lv, 0.5) over (partition by series) as med from (
select t.series, h.lv from today t join hist h
on h.series = t.series and h.is_clean
and extract(dayofweek from h.d) = extract(dayofweek from t.d)
qualify row_number() over (partition by t.series order by h.d desc) <= {{ LEVEL_WEEKS }}))
group by series
), ...
select *,
value = 0 or (not unchecked and abs(coalesce(z, 0)) >= {{ K }}) as fired,
-- 0 件はばらつきが 0(z が NULL)でも鳴る
kind is null and not (...fired...) as is_clean
-- 鳴った日・催事日は明日以降の基準に使わない
from judged
この SQL は 1 日ずつしか進められない(今日の判定が明日の基準を決める)。
--full-refresh では「鳴った日を基準に入れない」が効かないので、作り直しは同じ判定の Python で 1 日ずつ計算して投入する。2 言語で同じ仕様を持つので、同じ評価データでの一致を契約テストにする(今回は未実施)。seed(人が更新する表)
seeds:
- name: promo_calendar # d, kind。販促チームが更新する(監視でいちばん効いた部品)
- name: mon_baseline_resets # series, reset_from, reason, approved_by。意図した変化を「新しい水準」に
- name: mon_series_owners # ios:* → @ios-team。通知のメンション先
閾値の校正(Python)
def calibrate(seeds, budget_per_month, base=GoodConfig(), grid=(3.0, 3.5, 4.0, ...)):
"""事故なしの校正用データで、誤報が予算以下になる最小の k を選ぶ(評価用とは別の種)。"""
datas = [generate(s) for s in seeds]
for k in grid:
table[k] = mean(false_alarms_per_month(detect_good(d, replace(base, k=k)), d) for d in datas)
return next(k for k in grid if table[k] <= budget_per_month) # → 4.0実行例(実測・合成データ)
cd 2026-10-14-C-data-engineering-code
python3.14 evaluate.py # 校正 → 4 方式の比較 → 部品を 1 つずつ外す(約 66 秒)
python3.14 -m unittest -v test_volmon # Good の約束 15 本
DETECTOR=elementary python3.14 -m unittest test_volmon # 案 A' に当てる(赤が期待値)
python3.14 mutate.py # Good を 14 通りに壊す
校正(事故なし・校正用の種)
k=3.0: 5.07 k=3.5: 1.84 k=4.0: 0.70 k=4.5: 0.43 k=5.0: 0.32
予算 1.0/月 以下の最小 k = 4.0
検知(遅れ日数 × 5 種、括弧は事故中に鳴った日の割合)
| 事故 | Bad | 案 A | 案 A' | 案 A'' | Good |
|---|---|---|---|---|---|
| I1 iOS カート −38% | — (0%) | — (0%) | — (0%) | 0×5 (28%) | 0×5 (100%) |
| I2 二重取り込み | 0×5 (100%) | 4/5 | 4/5 | 0×5 | 0×5 (100%) |
| I3 取り込み停止 −28% | 1/5 | 0/5 | 0/5 | 3/5 | 4/5 |
| I4 Android purchase −50% | — | — | — | (34%) | 0×5 (100%) |
| I5 −38% がセール初日に開始 | — | — | 1/5 が 15 日目 | セール明け 4 日目 | 0×5 (100%) |
| I7 Web purchase −12% | — | — | — | (3%) | 0×5 (90%) |
| I8 iOS カート −20%(数え先なし) | — | — | — | (9%) | 0/0/0/1/0 (61%) |
| I6 週 1.5% ずつ減る | — | — | — | (1%) | 7〜68 日目 (37%) |
テストと変異テスト
| 対象 | 結果 |
|---|---|
| Good | 15 本 緑 |
| 案 A'(Elementary 相当) | 10 本 赤(手で作った週次データでも 2 日誤報) |
| Bad | 13 本 赤 |
| 変異 1 回目(テスト 11 本) | 4 種生存: 比を外す(照合が代わりに捕まえた)・同じ曜日をやめる(乱数のぶれに埋もれた)・0 件の特別扱い(ほぼ等価)・催事日を基準に入れる(中央値が吸収) |
| 変異 2 回目(テスト 15 本) | 14 種すべて赤 |
迷った案と採らなかった理由
| 案 | 採らなかった理由 | 前提がこう変わったら採る |
|---|---|---|
| 案 A: Elementary を既定値で | テーブル全体では 1% 前後の系列が見えない。系列ごとでも学習のセール 2 日で標準偏差 3 倍、z −4.82 → −1.79。催事を除外しても学習に事故が入り鳴る日 28%。相当実装 | テーブルが数百あり最低限の網を張るなら既定値の Elementary は安く広い。売上に効く数系列だけ作り込む 2 段構え |
| 案 B: SDK カウンタと照合 | 本問の事故は SDK が送っていない。カウンタも同じだけ少なく、照合は一致して緑。照合が効くのは数える場所が欠落の外にあるとき(=注文 DB)。構造からの導出 | 欠落の主因が取り込み側(取りこぼし・Storage Write API の失敗)と分かっているなら誤報ゼロで確実 |
| 平均と標準偏差(k=3.5) | 今回のデータではこちらが勝った(I8 87% vs 61%、I3 5/5 vs 4/5)。採らなかったのは本番の裾の重いぶれへの保険。ただし測っていない | 本番で 2 週間のシャドー運用。残差が正規分布に近ければ切り替える |
| 前週同曜日比 | 基準が 1 点なので先週がセールなら今週が誤報 | 履歴 2 か月未満の新しい系列(unchecked と記録して閾値を広く) |
| Prophet / STL | 24 系列の当てはめ運用と説明のコスト。主要因は中央値+カレンダーで取れた | 系列が数百、緩やかな変化(I6)を早く見たい |
| 系列ごとに k | 1 本ずつの校正に長い事故なし期間が要る | 重要度別に予算を分ける(purchase は即時、page_view は週次) |
ポイント解説
- 欠けた行は行のテストでは見つからない: dbt test が緑であることは、欠落が無いことを何も保証しない
- 基準を作るのが監視の本体: 前日比は曜日とセール、平均と標準偏差は学習のセール 2 日、鳴った日を入れると続く事故で壊れる。いちばん効いたのはチームの販促カレンダー
- 閾値は誤報予算から逆算: 「3σ」は誤報の回数を決めない。24 系列 × 毎日なら k=3 で月 5 回
- 見えない大きさを言える監視に: 件数では −12% は無理。比(0.045)と照合(0.016)を足し、「何 % を何日で」を表で持つ
- ロバストは保険で、今回は損をした: 正規分布のぶれでは中央値の効率は約 64%。一方で催事の登録漏れ(M14)の被害を小さくしたのは中央値
扱わなかった範囲(止め方)
- BigQuery・dbt・Elementary は未実行。SQL は構文検査もしていない。Python と SQL の一致(契約テスト)も未実施
- 案 A は Elementary のドキュメントから再現した相当実装(
exclude_detection_period_from_trainingの既定値はページが 404 で未確認)。案 B はシミュレーションしていない - 合成データのぶれは正規分布だけ。本番の裾の重さは未測定
- 世界の実物の中核概念 11 個(鮮度・件数・スキーマ変更・列の異常・系列ごと・季節性・学習と検知の期間・除外・感度・通知先・事故のまとめ)のうち扱ったのは約 6〜7 割。鮮度・スキーマ変更・列の異常は扱っていない
- 被害額は回収率の根拠が無いので出していない。時間単位の監視(当日中の検知)も範囲外
今日のまとめ
欠けた行は行のテストでは見つからず、テーブル全体の前日比では 1% の系列の 38% 欠落は −0.45% にしか見えなかった。系列ごとに同じ曜日の中央値と販促カレンダーの効果を引いた残差で判定し、閾値を誤報予算(月 1 回)から k = 4.0 と校正し、鳴った日を基準から外し、比と注文 DB との照合を足すと、誤報 0.95/月 のまま本問の事故を 5 種とも初日に名指しし、30 日間鳴り続けた(合成データでの実測)。ロバスト統計は今回のデータでは平均と標準偏差に負けた。どちらを採るかは本番の残差を測ってから決める。