C データエンジニアリング — iOS のカート追加が 38% 欠けたまま 11 日、dbt test は緑(イベント件数の監視)

2026-10-14 水曜 水曜 C: データエンジニアリング ★★★★★ dbt / BigQuery / GA4 export / Argo volume anomaly / MAD / 誤報予算 / 照合

概要

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/20dbt 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%。続く事故ほど見えない
430% に根拠がない取り込みが 18 時で止まった日(−28%)を 5 種中 4 種で見逃す。確実なのは二重取り込みだけ
5系列を名指せない「stg_events が −31%」では iOS チームに届かない
6段の比を見ない件数の残差のばらつき 0.107、比なら 0.045。−20% の欠落は件数では見えない
7注文 DB と照合しないpurchase ÷ 注文のばらつき 0.016。−12%(Safari の一部)は照合なら初日に見える
8dbt test が行の中身しか見ない欠けた行は not_null・unique の対象に入らない。freshness は「最新の行の時刻」だけ

図(SVG)— 基準をどう作るか

Good の判定(1 日ずつ進める。今日の判定が明日の基準を決める) GA4 exportevents_* 注文 DB数える場所が欠落の外 24 系列件数 12・段の比 9照合 3 水準同じ曜日 clean 8 日の中央値 催事の効果を引く過去の同種 3 回未満は判定せず |z| ≥ k=4.0または 0 件 基準に戻すのは「催事でない・鳴っていない」日だけ(is_clean)。意図した変化は人が mon_baseline_resets に登録 ばらつき = 直近 56 日の clean な残差の MAD × 1.4826。k は「誤報 月 1 回」から事故なしデータで校正 外すとどうなるか(同じ予算で k を校正し直して比較): カレンダー → 誤報 4.40/月 / 鳴った日の除外 → I1 の鳴る日 100%→75% / 比・照合 → −12% を 0/5 案 A' が本問の事故を逃す理由(種 1・3/10 の iOS カート追加) 学習 14 回にセール 2 日標準偏差 4,820(22.5%)→ z = −1.79 で鳴らない セール日を抜く標準偏差 1,626(7.9%)→ z = −4.82
誤報(回/月、棒の長さ = 回数 × 80 px)と本問の事故 I1 で鳴った日の割合 予算 1 回/月 Bad 前日比 ±30%2.56I1: 0% 案A テーブル z≥31.51I1: 0% 案A' 12 系列 z≥32.34I1: 0% 案A'' +催事除外2.59I1: 28% Good0.95I1: 100%(5 種とも初日) I1 = iOS カート追加 −38% を 30 日注入。割合は事故中に正しい系列で鳴った日 / 30(5 種の平均)。合成データでの実測

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/54/50×50×5 (100%)
I3 取り込み停止 −28%1/50/50/53/54/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%)

テストと変異テスト

対象結果
Good15 本 緑
案 A'(Elementary 相当)10 本 赤(手で作った週次データでも 2 日誤報)
Bad13 本 赤
変異 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 / STL24 系列の当てはめ運用と説明のコスト。主要因は中央値+カレンダーで取れた系列が数百、緩やかな変化(I6)を早く見たい
系列ごとに k1 本ずつの校正に長い事故なし期間が要る重要度別に予算を分ける(purchase は即時、page_view は週次)

ポイント解説

  1. 欠けた行は行のテストでは見つからない: dbt test が緑であることは、欠落が無いことを何も保証しない
  2. 基準を作るのが監視の本体: 前日比は曜日とセール、平均と標準偏差は学習のセール 2 日、鳴った日を入れると続く事故で壊れる。いちばん効いたのはチームの販促カレンダー
  3. 閾値は誤報予算から逆算: 「3σ」は誤報の回数を決めない。24 系列 × 毎日なら k=3 で月 5 回
  4. 見えない大きさを言える監視に: 件数では −12% は無理。比(0.045)と照合(0.016)を足し、「何 % を何日で」を表で持つ
  5. ロバストは保険で、今回は損をした: 正規分布のぶれでは中央値の効率は約 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 日間鳴り続けた(合成データでの実測)。ロバスト統計は今回のデータでは平均と標準偏差に負けた。どちらを採るかは本番の残差を測ってから決める。

自己評価(あとで記入)