概要
3 軸で ○×
日・チャネル・キャンペーンのそれぞれで足せるかを列ごとに決める。
部品だけ置く
比率と UU の完成品は持たない。分子・分母・スケッチを持つ。
残高は期末
キャンペーン間は足せるが、時間は足せない(半加法)。
照合で守る
行の一意性ではなく「足すと元の事実に一致するか」を検査する。
問題
MOps 販促システムチームは mart_campaign_kpi_daily(日 × キャンペーン × チャネル)を Looker Studio「販促 KPI」で見せている。既定期間は直近 7 日、合計行あり。定例で次の声が出た(合成データ上の値)。
| 発言 | 中身 |
|---|---|
| 事業部長 | 週間 UU が 14 万人。会員管理では 5.6 万人 |
| 経理 | キャンペーン経由売上 7,945 万円 > 全社売上 3,762 万円 |
| CS 責任者 | 未使用クーポン残高 1.6 億円。台帳は 4,010 万円 |
| マーケ | 購入者率は週 7%。会員分析チームは 9.7% |
select v.visit_date, v.campaign_id, v.channel,
v.uu, -- count(distinct member_id)
safe_divide(o.buyers, v.uu) as buyer_rate,
safe_divide(o.revenue_own, o.orders) as aov,
t.revenue_touched as campaign_revenue, -- 触れた全キャンペーンに丸ごと
b.outstanding_yen / 2 as coupon_outstanding_yen -- 日末残高を 2 チャネルに半分ずつ
from v left join o using (...) left join t using (...) left join fct_coupon_balance_daily b using (...)
-- テスト: unique_combination_of_columns [visit_date, campaign_id, channel] のみ
-- Looker Studio: uu・campaign_revenue・coupon_outstanding_yen は SUM、buyer_rate・aov は AVG
- 会員 12 万人・キャンペーン 30 本(小さいほど CVR 高)・来訪 約 2 万件/日 仮定
- 注文の 59.5% は購入前 7 日に複数キャンペーンへ接触 実測・合成
- 案 A(後輩): UU の集計を AVG に、
APPROX_COUNT_DISTINCTに。案 B(先輩): 生のファクトを直接COUNT(DISTINCT)
期待する回答形式: 問題点 8 点/改善後の dbt モデル・テスト・ダッシュボード側クエリ/実行例/適用パターン/案 A・B の評価と前提が変わったときの判断/扱わなかった範囲
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 比率は分子と分母を別列で持ち、期間が決まってから割る
- UU は近似でよいなら HLL スケッチを持って MERGE
- 残高は期末の値(
non_additive_dimension) - 複数接触の売上は重みの合計 1 で按分するか、合計行に出さない
- テストは「足すと元の事実に一致するか」
ヒント3 — 誘導
hll_count.init(member_id, 15) as uu_sketch
sum(revenue_yen) as revenue_last_touch -- 全行の合計 = 全社売上
sum(revenue_yen * attribution_weight) as revenue_attributed
-- ダッシュボード側: hll_count.merge(uu_sketch), safe_divide(sum(rev), sum(orders))問題点分析(8 点)
| # | 問題点 | 足せない軸 | 何が起きるか |
|---|---|---|---|
| 1 | uu を日をまたいで SUM | 時間 | 日次 UU 合計 110,196 vs 週の正解 55,898(+97%)実測・合成 |
| 2 | uu をチャネル・キャンペーンをまたいで SUM | チャネル・キャンペーン | 7 日で 140,752(2.52 倍、+151.8%)。キャンペーン別でも最大 35.9% 実測・合成 |
| 3 | buyer_rate を完成品で AVG | すべて | 1 日 +29.5%(小さい高 CVR 行が同じ重み)、週 −27.7%(週をまたぐ購入者を数えない)実測・合成 |
| 4 | aov を完成品で AVG | すべて | 1 日 −3.2%・週 +0.1%。たまたま小さい(客単価と規模が無相関な合成データ) |
| 5 | 関与売上を合計行で SUM | キャンペーン | 7,945 万円 = 全社 3,762 万円の 2.11 倍 実測・合成 |
| 6 | 残高を日をまたいで SUM | 時間(半加法) | 1.60 億円 = 期末 4,010 万円の 4.0 倍。1 日表示では正しい 実測・合成 |
| 7 | 残高を 2 チャネルに半分ずつ | — | 「app の残高」という存在しない数字。粒度の違う事実を 1 表に押し込んだ |
| 8 | 粒度の一意性テストだけ | — | 上の 7 点は dbt build 緑のまま。Good 用の検査 14 本を Bad に当てると 8 赤・2 エラー |
図(SVG)— 列の加法性と、壊れ方の大きさ
客単価は Bad でも +0.1% しかずれなかった。比率の平均が外れるのは分母の大きさと比率が相関するとき。今回は合成データで無相関だったため。
Bad → Good(コード差分)
-- 完成品の数字を持つ
select visit_date, campaign_id, channel,
count(distinct member_id) as uu, -- 足せない
safe_divide(buyers, uu) as buyer_rate, -- AVG される
safe_divide(rev, orders) as aov, -- AVG される
revenue_touched as campaign_revenue,
-- 触れた全キャンペーンに丸ごと
outstanding_yen / 2 as coupon_outstanding_yen
-- 時間で SUM される・チャネルで割る
from ...
# テスト: 粒度の一意性だけ
# BI: SUM と AVG の既定集計+合計行-- 足せる部品だけを持つ
select visit_date, campaign_id, channel,
hll_count.init(member_id, 15) as uu_sketch, -- MERGE で足せる
hll_count.init(buyer_id, 15) as buyer_sketch,
count(*) as orders,
sum(revenue_yen) as revenue_last_touch,
-- 全行の合計 = 全社売上
sum(revenue_yen * weight) as revenue_attributed
-- 注文ごとの重みの合計 = 1
from ...
-- 残高は別ファクト(日 × キャンペーン)
-- non_additive_dimension: window_choice: max
# テスト: 足すと元の事実に一致する(日別照合)
# BI: 期間で絞ってから MERGE・合計どうしで割る模範解答(要点)
dbt モデル
-- models/marts/fct_campaign_kpi_daily.sql 粒度: visit_date × campaign_id × channel
{% set HLL_PRECISION = 15 %} -- 既定。99% 信頼区間 ±1.72%、最大 32 KiB
with visits as (
select visit_date, campaign_id, channel,
hll_count.init(member_id, {{ HLL_PRECISION }}) as uu_sketch
from {{ ref('fct_visits') }} group by 1, 2, 3
), orders as (
select order_date as visit_date, last_campaign_id as campaign_id, channel,
count(*) as orders, sum(revenue_yen) as revenue_last_touch,
hll_count.init(member_id, {{ HLL_PRECISION }}) as buyer_sketch
from {{ ref('fct_orders') }} group by 1, 2, 3
), attributed as ( -- 重み付き橋渡し。注文ごとの重みの合計は 1
select order_date as visit_date, a.campaign_id, channel,
sum(revenue_yen * a.weight) as revenue_attributed
from {{ ref('bridge_order_campaign') }} a join {{ ref('fct_orders') }} using (order_id)
group by 1, 2, 3
)
select ... from visits full join orders using (...) full join attributed using (...)
-- models/marts/bridge_order_campaign.sql
select order_id, campaign_id,
1 / count(*) over (partition by order_id) as weight -- 線形按分(替えるならここだけ)
from {{ ref('fct_orders') }}, unnest(touched_campaign_ids) as campaign_id
テスト(照合)
-- tests/assert_kpi_reconciles_with_orders.sql(行が返れば失敗)
select d.visit_date, d.rev_mart, d.rev_attr, f.rev_fact
from (select visit_date, sum(revenue_last_touch) rev_mart, sum(revenue_attributed) rev_attr
from {{ ref('fct_campaign_kpi_daily') }} group by 1) d
join (select order_date visit_date, sum(revenue_yen) rev_fact
from {{ ref('fct_orders') }} group by 1) f using (visit_date)
where d.rev_mart != f.rev_fact or abs(d.rev_attr - f.rev_fact) > 1 -- 按分の端数 1 円まで
-- tests/assert_bridge_weights_sum_to_one.sql
select order_id from {{ ref('bridge_order_campaign') }}
group by order_id having abs(sum(weight) - 1) > 1e-9
意味の定義(dbt Semantic Layer)
measures:
- name: coupon_outstanding_yen
agg: sum # キャンペーン間は足す
non_additive_dimension:
name: visit_date
window_choice: max # 時間は期末の値(min なら期首)
metrics:
- name: aov
type: ratio # 分子と分母を別々に集計してから割る
type_params: {numerator: revenue_last_touch, denominator: orders}
ダッシュボード側(Looker Studio カスタムクエリ)
with k as (
select campaign_id,
hll_count.merge_partial(uu_sketch) as uu_sketch, -- 日・チャネルを併合
hll_count.merge_partial(buyer_sketch) as buyer_sketch,
sum(orders) orders, sum(revenue_last_touch) rev, sum(revenue_attributed) rev_attr
from `mops.fct_campaign_kpi_daily`
where visit_date between parse_date('%Y%m%d', @DS_START_DATE)
and parse_date('%Y%m%d', @DS_END_DATE) -- 期間で絞ってから
group by campaign_id
)
select campaign_id,
hll_count.extract(uu_sketch) as uu,
safe_divide(hll_count.extract(buyer_sketch), hll_count.extract(uu_sketch)) as buyer_rate,
safe_divide(rev, orders) as aov, -- 分母 0 は NULL
rev_attr
from k
-- 合計行は BI の「合計」を使わない。全体 UU は全行の MERGE を 1 行クエリで出す
検証用 Python(要点)
def good_dashboard(mart, balance, start, end):
rs = [r for r in mart if start <= r["date"] <= end]
uu = HLL(); buyers = HLL()
for r in rs:
uu = uu.merge(r["uu_sketch"]) # 登録値の max。推定値の合計ではない
buyers = buyers.merge(r["buyer_sketch"])
orders = sum(r["orders"] for r in rs)
rev = sum(r["revenue"] for r in rs)
last = max(d for d, _ in balance if start <= d <= end) # 半加法: 期末
return {
"uu": uu.estimate(),
"buyer_rate": buyers.estimate() / uu.estimate() if rs else None,
"aov": rev / orders if orders else None, # 1 回目はここで ZeroDivisionError
"coupon_outstanding": sum(v for (d, _), v in balance.items() if d == last),
}
適用した設計パターン
- 加法・半加法・非加法ファクト(Kimball): 比率は加法的な部品を持ち、足してから割る
- 重み付き橋渡しテーブル(Kimball の多値ディメンション):
bridge_order_campaign.weight - 集約可能なスケッチ(BigQuery HLL++): 精度 15 で 95% ±1.15%・99% ±1.72%
- 半加法の指標定義(dbt
non_additive_dimension) - 書籍対応: 第 5 章 プリミティブ型への執着(数に足し方のルールが無い)/第 14 章 目的別モデリング
一次資料(2026-10-07 参照): Kimball Group "Additive, Semi-Additive, and Non-Additive Facts" / BigQuery "Sketches"・"HyperLogLog++ functions" / dbt "Measures" / Looker Studio "Connect to Google BigQuery"。URL は MD 版に記載。
実行例(実測・合成データ)
python3 demo.py # 正解・Bad・Good を並べる(約 3.5 秒)
python3 -m unittest -v test_mart # Good の約束 14 本(約 25 秒)
BAD=1 python3 -m unittest test_mart # 同じ検査を Bad に(赤が期待値)
python3 mutate.py # Good を 9 通りに壊して赤になるか
| 7 日(10/1〜10/7) | 正解 | Bad | Bad 誤差 | Good | Good 誤差 |
|---|---|---|---|---|---|
| UU | 55,898 | 140,752 | +151.8% | 55,998 | +0.18% |
| 購入者率 | 0.0973 | 0.0703 | −27.7% | 0.0973 | +0.05% |
| 客単価 | 6,540 | 6,544 | +0.1% | 6,540 | 0.00% |
| 売上 | 37,619,805 | 79,454,802 | +111.2% | 37,619,805 | 0.00% |
| クーポン残高 | 40,099,000 | 160,293,800 | +299.7% | 40,099,000 | 0.00% |
| キャンペーン別 UU 最大誤差 | — | — | 35.9% | — | 0.89% |
- テスト: Good 14 本緑(期間 28 通りすべて)/Bad 8 赤・2 エラー
- 1 回目の Good は 1 本エラー: 注文 0 件の日に客単価を割って
ZeroDivisionError。Bad の SQL はSAFE_DIVIDEで守っていたのに、直すときに落とした → None にして検査を追加 - 変異テスト: 9 通り(併合→推定値の合計、比率の平均×2、残高の合計/期首、重み 1、精度 10、線形計数なし、分母 0→0)すべて赤。M9 は 1 回目の検査構成なら見逃していた
- 案 A(UU を AVG): 日次 UU の平均 15,742 vs 週の正解 55,898(−71.8%)
迷った案と採らなかった理由
| 案 | 採らなかった理由 | こう変わったら採る |
|---|---|---|
案 A: UU を AVG・APPROX_COUNT_DISTINCT | −71.8%。APPROX_COUNT_DISTINCT は INT64 で後から併合できない | 「1 日あたり平均 UU」が本当に欲しい指標と明記するなら |
案 B: 生ファクトを直接 COUNT(DISTINCT) | 正確だが費用が期間 × 閲覧回数。導出・仮定 来訪 400 万行/日で 90 日 約 13 GB/クエリ、スケッチは上限 約 0.34 GiB(約 1/40) | 1 人単位の正確さが要る指標(景品の当選者数など)、来訪が 1 日数十万行以下 |
| 精度 14 | 99% で ±2.44%、合意した 1% 程度を超えうる | 許容が 3% に緩む・費用が効く |
| 精度 16 以上 | 費用 2 倍で経営判断は変わらない(仮定) | 小さいキャンペーンの比較に使う(その用途は案 B が合う) |
| 全部 MetricFlow に寄せる | count_distinct は生の行から数え直すので費用は案 B と同じ | dbt Cloud Semantic Layer を契約済みで行数が小さい |
| 最終接触だけで売上を付ける | 「きっかけ」を作ったキャンペーンが 0 円になる | 予算配分に使わない運用レポートだけなら |
ポイント解説
- 壊れているのはマートではなく足し方: 行は全部正しい。期間を延ばすほど壊れる(キャンペーン別 UU 誤差 1 日 2.8% → 7 日 35.9%)
- 比率の平均は「たまたま合う」: 客単価 +0.1%。今合っていることは設計が正しい証拠にならない
- UU の誤差許容を決めると設計が楽になる: 誰が 1% を許したかを残すのも設計の一部
- 残高は時間だけ足せない: 1 日表示では正しく、7 日で 4 倍。期末か期首かは業務の問い
- 按分しない売上は合計行に出さない: 按分モデルは
bridgeの 1 か所で替えられるように
扱わなかった範囲(止め方)
- BigQuery・dbt・Looker Studio は未実行。
@DS_START_DATEの形式(YYYYMMDD)は記憶で、公式ページでは形式まで未確認 - HLL は自作の代理(密表現のみ)。BigQuery HLL++ の疎表現・バイアス補正とは小さい範囲の誤差が違う
- 費用比較は仮定から出した上限。オンデマンド単価は掛けていない
- アトリビューションのモデル選び、パーセンタイル(KLL スケッチ)、遅延到着の再計算(9/30 の型)は扱わない
今日のまとめ
マートの行が全部正しくても、BI が既定の SUM・AVG をかけた瞬間に週間 UU は 2.5 倍・売上は全社の 2.1 倍・残高は 4 倍になった。列ごとに足せる軸を決め、足せる部品(件数・按分済み売上・HLL スケッチ・期末で取る残高)だけを置くと、28 通りの期間すべてで売上と客単価は一致し、UU と購入者率の誤差は 0.89% 以内(合成データでの実測)。