実践編:Huluで特定デバイスだけに施策を入れたときの効果検証(profile_id × 時間のログからDIDを組む)

本ノートは、『効果検証入門』第4章のDID(差分の差分法)を、Huluの実務にそのまま当てはめるための手順書である。第4章ノート(ch4_did_and_causalimpact.md)で学んだ考え方を前提にしつつ、 手元に profile_id × 時間 の粒度のログがある 状況で、何をどの順番で行い、どこで判断を誤りやすいかを、架空データを実際に動かしながら追う。数値はすべて説明のために作った架空のものであり、実際の施策の成果を表すものではない。

想定する状況は次である。新しいホーム画面のトレイ構成(以下「新ホーム」)を、アプリの実装の都合で Fire TVだけに先行リリース した。ユーザ単位のA/Bテストは組めなかった。リリースから4週間が経ち、「新ホームは視聴を増やしたのか」を問われている。手元には、視聴イベントのログ(どのプロフィールが、いつ、どのデバイスで、何を、何分見たか)がある。

0. 全体の流れ

先に全体の流れを示す。以降の節はこの順に対応している。

edit_note1. 問いを固める

介入・対象・期間・指標を文章で決める。

arrow_forward
person2. 群を固定する

介入前の主デバイスで介入群・非介入群を決める。

arrow_forward
table_chart3. パネルを作る

profile × 週 の表。離脱は 0 で埋める。

arrow_forward
analytics4〜6. 推定と検証

トレンド確認 → DID → 標準誤差とプラセボ。

build7〜8. 頑健性と異質性

対照・期間・尺度を変えても結論が動かないか。誰に効いたか。

arrow_forward
format_list_numbered9〜10. 報告と次の設計

仮定と限界を明記して報告。次回は設計段階で先手を打つ。

図: デバイス限定施策の検証の流れ。群の固定とパネル作成が全体の土台になる。

実行用のスクリプトは output/ch4_hulu_device_did.py にまとめており、リポジトリのルートで次を実行すると、以降の表の数値がすべて再現される。

uv run output/ch4_hulu_device_did.py

1. 問いを固める:何の効果を、誰について、どの指標で

分析を始める前に、次の4点を文章で決める。ここが曖昧なまま集計を始めると、あとから「結局これは何の数字なのか」が説明できなくなる。

介入(何をしたか) 。新ホームをFire TVアプリに配信した。配信日を t∗t^* とする。Fire TVで新ホームを見た人だけが介入を受ける。

対象(誰への効果か) 。介入は「デバイス」に対して行われたが、視聴するのは「プロフィール」である。ここで大事なのは、 プロフィールは複数のデバイスを使う ことである。テレビでもスマホでも見る人がいる。したがって「Fire TVのユーザ」を厳密に定義する必要がある。本ノートでは、 介入前の期間にFire TVを主なデバイスとして使っていたプロフィール を介入群と定義する。この定義の理由は2節で詳しく述べる。

推定したい量 。上の定義のもとで推定されるのは、「介入前にFire TVを主デバイスにしていた人たちに新ホームを配信したことによる、その人たちの視聴の変化」である。これは第1章の言葉で言えば介入群における平均処置効果(ATT)であり、配信された人が実際に新ホームを見たかどうかを問わない ITT(intention-to-treat、配信意図に基づく効果) の性質を持つ。「新ホームを実際に見た人への効果」ではないことに注意する。

アウトカム(どの指標で) 。暗黙的フィードバックの文脈では、次の3種類を使い分ける。

指標 型 何をとらえるか 注意点
その週に何か見たか(any_view) 0/1 視聴に至る人が増えたか(広がり) もともと毎週見る人には効果が出ない
週あたり視聴分数(minutes) 連続、0が多い 視聴の総量 分布の尾が長く、ヘビーユーザに引っ張られる
週あたり視聴分数の対数(log(1+minutes)) 連続 比率での変化 0の扱いに恣意性がある
図: 介入前の週あたり視聴分数の分布(何か見た週のみ)。右に長い尾を持ち、平均は少数のヘビーユーザに引っ張られる。対数や0/1の指標を併用する理由である(Plotly。線や点にカーソルを合わせると数値が出る)

主指標を一つ決め(本ノートでは視聴分数)、他は副指標として併記する。さらに、施策が「ホームのトレイ」なら、 トレイ経由で再生に至ったか という、施策により近い指標をGA4のクリックログから作ると、効果の経路を確認できる。

集計の粒度 。日次だと曜日の変動が大きく、週次に丸めると読みやすくなる。介入前8週、介入後4週の計12週を使う。介入前が長いほど「介入前のトレンドが平行か」の確認が確かになる。

週 −8 〜 −1(介入前 8 週)

群を決める材料。主デバイスの判定、介入前トレンドの確認、プラセボ検定はこの期間だけで行う。

配信日 t*

Fire TV に新ホームを配信。配信が数日にまたがるなら、その週を分析から外す(washout)ことも検討する。

週 0 〜 3(介入後 4 週)

効果を測る期間。週ごとの効果を見て、新奇性による一時的な山か、持続する効果かを区別する。

図: 分析期間の区切り。介入前のデータだけで決めるべきものを、介入後のデータで決めない。

2. 群を決める:介入前の主デバイスで固定する

この節が、本ノートでもっとも重要である。ここを誤ると、あとの計算がどれだけ正確でも結論が歪む。

介入後の行動で群を決めてはいけない

素朴に考えると、「介入後にFire TVで見た週」を介入あり、「それ以外の週」を介入なしとして比べたくなる。しかしこれは、 介入のあとに決まる行動(どのデバイスで見たか)で群を分けている ので、第2章で学んだPost treatment biasそのものである。

なぜ問題になるのか。介入後にFire TVを使う人には、「もともとFire TVを使っていた人」に加えて、「介入後にFire TVを使い始めた人」が混ざる。後者は、たとえば新しいテレビを買った人であり、テレビを買い替えた直後は施策と無関係に視聴が増える。あるいは、新ホームが話題になって「試しにFire TVで見てみた」ヘビーユーザかもしれない。いずれにせよ、介入後にFire TVへ移った人は、 施策とは別の理由で視聴が変化している最中の人 に偏る。この人たちを介入群に入れると、その変化が施策の効果に混ざる。

架空データで確かめる。介入後にFire TVを使った週と使わなかった週の平均視聴分数を素朴に比べると、79.7分と62.6分で17分の差がある。しかし、これは大部分が「Fire TVを使う人はもともとよく見る人だ」という差である。さらに、介入後の主デバイスで群を決め直してDIDを行うと、効果は9.86分と推定される。一方で、介入前の主デバイスで群を決めたDIDは5.49分である。差の原因を探ると、介入前はAndroid TV群だったのに介入後にFire TVが主デバイスになったプロフィールが56人おり、その人たちの介入前の平均視聴分数は149分と、全体平均の60分の2.5倍であった。 もともとのヘビーユーザが、介入後にたまたまFire TVへ移ってきて、その変化ごと介入群に数えられた のである。

介入前の主デバイスで群を固定する

したがって、群は 介入前の期間のログだけを見て 決める。手順は次である。

  1. 介入前8週のログから、プロフィールごと・デバイスごとに「そのデバイスでログが残った週数」を数える。
  2. 週数が最大のデバイスを、そのプロフィールの主デバイスとする。
  3. 主デバイスがFire TVなら介入群、比較対象のデバイス(ここではAndroid TV)なら非介入群とする。それ以外(スマホが主など)は今回の分析から外す。
  4. 一度決めた群は、介入後にデバイスを乗り換えても変えない。

4番目が肝心である。介入後にFire TVへ乗り換えた人は、実際には新ホームを見ているのに非介入群に残る。これは非介入群を「汚染」し、効果をやや小さく見せる(9節で確認する)。しかし、乗り換えを理由に群を動かすと上で見た大きなバイアスが入るので、 小さな過小評価を受け入れて、群は動かさない のが正しい判断である。

介入後のデバイスで群を決める

「介入後に Fire TV を使った人」= 介入群。

テレビを買い替えたヘビーユーザが介入群に流入。効果 9.86 分(真の値 9.23 分だが、正しい理由で当たったわけではない)。

介入前のデバイスで群を固定する

「介入前 8 週の主デバイスが Fire TV」= 介入群。介入後の乗り換えでは動かさない。

効果 5.49 分。乗り換え組による非介入群の汚染で少し過小だが、方向が分かっている。

図: 群の決め方。介入後の行動で決めると、施策と無関係な変化が効果に混ざる。
図: 群の決め方と推定値。素朴な比較と「介入後の主デバイスで群を決めたDID」は真の効果から大きく外れる方向に動き得る。介入前で固定したDIDは控えめだが方向が分かっている。誤差棒は95%区間(Plotly。線や点にカーソルを合わせると数値が出る)

架空データでは、介入前にログがあった6,000プロフィールのうち、Fire TVが主デバイスの2,990人と、Android TVが主デバイスの2,992人が分析対象になった。主デバイスの利用割合(そのデバイスでログが残った週の割合)は両群とも約85%で、残りはスマホなど他のデバイスで見ている。

ほかに決めておくこと

比較対象のデバイス 。Android TVを選んだ理由は「同じテレビ画面で、同じ利用文脈(リビングで、家族で、長時間)」だからである。スマホは利用文脈が違い、トレンドが平行である根拠が薄い。テレビ系デバイスが複数あるなら(Apple TV、ゲーム機、スマートテレビ内蔵アプリ)、介入前のトレンドがもっとも近いものを選ぶか、複数を非介入群として使い、それぞれの結果を併記する。

分析に含めるプロフィール 。介入前8週の少なくとも1週にログがあるプロフィールに限る(介入前の情報がないと群を決められない)。介入後に新規登録したプロフィールは、別のコホートとして扱うか、今回は外す。キッズプロフィールは、キッズ向けホームが別なら外す。

離脱の扱い 。介入後にログがなくなったプロフィールは、分析から外すのではなく、 視聴0として残す 。外すと「残った人」だけの比較になり、施策が離脱を増やしたり減らしたりした効果が消えてしまう。

3. パネルを作る:BigQueryからPolarsへ

BigQueryでの集計

視聴イベントは hj-data-01.report.content_viewing に、プロフィール・視聴開始時刻・視聴秒数・デバイス分類( device_low_category )とともに記録されている。ここから、プロフィール × 週 × デバイスの粒度まで落とした表を作る。イベント単位の36億行をローカルに持ってくる必要はなく、集計はBigQuery側で済ませる。

-- 介入前 8 週〜介入後 4 週の視聴を、profile × 週 × デバイス に集計する
DECLARE rollout_date DATE DEFAULT DATE '2026-06-01';   -- Fire TV への配信日(架空)

WITH base AS (
  SELECT
    profile_id,
    device_low_category                                   AS device_used,
    -- 配信日を週 0 の始まりとして、週番号を振る(負が介入前)
    DIV(DATE_DIFF(date, rollout_date, DAY) - IF(date < rollout_date, 6, 0), 7) AS week,
    watched_seconds,
    series_id
  FROM `hj-data-01.report.content_viewing`
  WHERE _PARTITIONDATE BETWEEN DATE_SUB(rollout_date, INTERVAL 56 DAY)
                           AND DATE_ADD(rollout_date, INTERVAL 27 DAY)
    AND account_status IN ('paid', 'promo', 'pending')    -- 視聴可能な会員のみ
)
SELECT
  profile_id,
  week,
  device_used,
  SUM(watched_seconds) / 60.0                              AS minutes,
  COUNT(DISTINCT series_id)                                AS series_cnt,
  1                                                        AS any_view
FROM base
GROUP BY profile_id, week, device_used

出力は「その週にそのデバイスで何か見た」行だけを持つ。見なかった週の行は存在しないので、次の段階で0を埋める。プロフィール属性(キッズか、契約継続か)は personalize.daily_personalize_viewing_kpi の is_kids や keizoku_flag を介入前の日付で結合して取る。

Polarsでの群の固定とパネルの構築

BigQueryから落とした表を panel とする。列は profile_id 、 week 、 device_used 、 minutes 、 any_view である。

import numpy as np
import polars as pl

WEEKS = np.arange(-8, 4)  # -8..-1 が介入前、0..3 が介入後


def assign_groups(panel: pl.DataFrame) -> pl.DataFrame:
    """介入前 8 週のログだけを見て、各プロフィールの主デバイスを決める。"""
    pre = panel.filter(pl.col("week") < 0)
    usage = (
        pre.group_by("profile_id", "device_used")
        .agg(pl.len().alias("pre_weeks"))          # そのデバイスでログが残った週数
        # 同数のときはデバイス名で決める(並べ替えを決定的にして、実行ごとに群が変わらないようにする)
        .sort("profile_id", "pre_weeks", "device_used", descending=[False, True, False], maintain_order=True)
    )
    primary = usage.group_by("profile_id", maintain_order=True).first()
    return (
        primary.filter(pl.col("device_used").is_in(["fire_tv", "android_tv"]))
        .with_columns(
            group=pl.col("device_used"),
            treat=(pl.col("device_used") == "fire_tv").cast(pl.Int64),
        )
        .select("profile_id", "group", "treat")
    )


def build_balanced_panel(panel: pl.DataFrame, cohort: pl.DataFrame) -> pl.DataFrame:
    """profile × 週 の完全な格子を作り、ログのない週は 0 で埋める。"""
    weekly = panel.group_by("profile_id", "week").agg(pl.col("minutes").sum(), pl.col("any_view").max())
    grid = cohort.join(pl.DataFrame({"week": WEEKS}), how="cross")   # 全プロフィール × 全週
    return (
        grid.join(weekly, on=["profile_id", "week"], how="left")
        .with_columns(
            pl.col("minutes").fill_null(0.0),
            pl.col("any_view").fill_null(0),
            post=(pl.col("week") >= 0).cast(pl.Int64),
        )
        .with_columns(
            D=pl.col("treat") * pl.col("post"),          # 介入を受けた観測 = 介入群 × 介入後
            log_minutes=(pl.col("minutes") + 1).log(),
        )
        .sort("profile_id", "week")
    )

cross 結合で「全プロフィール × 全週」の格子を作ってから左結合するのが、離脱を0で埋めるための要点である。架空データでは、5,982プロフィール × 12週 = 71,784行のバランスしたパネルになる。 D 列が第4章の Gi×PiG_i \times P_i 、すなわち介入変数である。

4. まず眺める:週ごとの群平均とイベントスタディ

推定に入る前に、週ごとの群平均を並べる。DIDの妥当性は、この表を見て初めて判断できる。

週 Android TV Fire TV 差(Fire − Android)
−8 56.7 61.0 4.2
−7 54.9 65.0 10.2
−6 52.2 58.8 6.6
−5 59.0 65.1 6.0
−4 64.8 74.6 9.7
−3 55.7 68.9 13.2
−2 53.5 62.8 9.3
−1 51.6 62.0 10.4
0 64.7 83.8 19.1
1 57.2 72.5 15.3
2 59.6 71.4 11.9
3 56.1 66.6 10.6
図: 週ごとの群平均(上)と差(下)。両群が同じ方向に動く週(−4、0)は共通ショック、差の変化だけが効果の候補である(Plotly。線や点にカーソルを合わせると数値が出る)

読み方は三つある。

  • 両群が同じ方向に動いているか 。週 −4 と週 0 に両群とも山がある。連休や人気作の配信のような、デバイスによらない共通の変化である。DIDはこの共通の動きを打ち消す。
  • 介入前の差が安定しているか 。介入前の差は4〜13分の範囲で動き、やや広がる傾向がある。完全に平行ではない。この「介入前でも差が数分は動く」という事実が、あとで効果の大きさを判断する物差しになる。
  • 介入後に差が変わったか 。週0で差が19.1分に跳ね、その後4週かけて10分台に戻っている。導入直後の新奇性による山と、その後の落ち着きに見える。

イベントスタディ:差の変化を区間つきで見る

「差の変化」を、介入直前の週(週 −1)を基準に、標準誤差つきで並べたものがイベントスタディである。各プロフィールについて「その週の値 − 週 −1 の値」を作り、その群間の平均の差をとる。標準誤差は、プロフィール単位で独立と見なした2標本の計算である。

def event_study(bal: pl.DataFrame, outcome: str, ref_week: int = -1) -> pl.DataFrame:
    ref = bal.filter(pl.col("week") == ref_week).select("profile_id", pl.col(outcome).alias("y_ref"))
    diff = bal.join(ref, on="profile_id").with_columns(dy=pl.col(outcome) - pl.col("y_ref"))
    rows = []
    for w in WEEKS:
        sub = diff.filter(pl.col("week") == w)
        a = sub.filter(pl.col("treat") == 1).get_column("dy").to_numpy()
        b = sub.filter(pl.col("treat") == 0).get_column("dy").to_numpy()
        se = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b)) if w != ref_week else 0.0
        rows.append({"week": int(w), "estimate": float(a.mean() - b.mean()), "se": float(se)})
    return pl.DataFrame(rows)
週 推定値(分) 標準誤差 95%区間
−8 −6.1 3.0 [−12.1, −0.2]
−7 −0.2 3.2 [−6.4, 6.0]
−6 −3.7 3.0 [−9.6, 2.1]
−5 −4.3 3.1 [−10.4, 1.7]
−4 −0.6 3.2 [−7.0, 5.7]
−3 2.8 3.2 [−3.5, 9.1]
−2 −1.1 3.0 [−6.9, 4.7]
−1 0(基準)
0 8.8 3.3 [2.4, 15.1]
1 4.9 3.2 [−1.3, 11.1]
2 1.5 3.5 [−5.3, 8.3]
3 0.2 3.3 [−6.3, 6.7]
図: イベントスタディ。週 −1 を基準にした差の変化と95%区間。介入前はほぼ0をまたぎ、介入後は週0に山があって減衰する(Plotly。線や点にカーソルを合わせると数値が出る)

介入前の7つの推定値は、週 −8 を除いて95%区間に0を含む。週 −8 だけが有意に負だが、7回検定すれば1回程度は偶然で有意になる。「介入前に明確なトレンドの乖離はない」と言ってよいが、「完全に平行」とも言えない。介入後は、週0に明確な正の効果があり、週を追うごとに小さくなっている。 4週の平均だけを報告すると、この「効果の減衰」が見えなくなる 。イベントスタディの表は必ず報告に含める。

5. 推定する:2×2のDIDから固定効果回帰へ

2×2の集計

まず第4章の基本形、群 × 前後の4つの平均を作る。

群 介入前の平均(分/週) 介入後の平均 前後の差
Fire TV(介入群) 64.8 73.6 +8.8
Android TV(非介入群) 56.1 59.4 +3.3
τ^=(73.6−64.8)−(59.4−56.1)=8.8−3.3=5.5\hat\tau=(73.6-64.8)-(59.4-56.1)=8.8-3.3=5.5

新ホームは、Fire TVを主デバイスにしていたプロフィールの週あたり視聴分数を、介入後4週の平均で5.5分増やしたと推定される。介入前の水準64.8分に対して +8.5% である。Android TVでも3.3分増えており、Fire TVの前後比較(+8.8分)のうち3.3分は施策と無関係な共通の増加であった。

図: 2×2のDID。Android TVの変化(+3.3分)をFire TVに当てた反実仮想(破線)と実測の差が効果 5.5 分である(Plotly。線や点にカーソルを合わせると数値が出る)

同じ表を他の指標でも作る。

指標 Fire TVの前後差 Android TVの前後差 DID
any_view(何か見た割合) +0.003 −0.009 +0.012(+1.2ポイント)
log(1+minutes) +0.091 −0.010 +0.102(約 +10%)

なぜ回帰で行うのか:標準誤差と拡張のため

4つの平均の引き算で推定値は出るが、 その推定値がどれくらい不確かか は、プロフィール単位のばらつきを使って計算しなければ分からない。そこで第4章4.1.4と同じく回帰の形にする。ただし、ここではプロフィール × 週のパネルなので、グループダミー GiG_i の代わりに プロフィールごとの固定効果 αi\alpha_i を、介入後ダミー PtP_t の代わりに 週ごとの固定効果 λt\lambda_t を入れる。

Yit=αi+λt+τDit+εitY_{it}=\alpha_i+\lambda_t+\tau D_{it}+\varepsilon_{it}
  • αi\alpha_i はプロフィール ii の「地力」である。毎週100分見る人と、月に1回見る人の水準の差をすべて吸収する。第4章のJohn Snowの分析で地区ダミーを入れたことに対応し、推定値は変えずに標準誤差を小さくする。
  • λt\lambda_t は週 tt の共通の効果である。連休や人気作の配信による両群共通の変化を吸収する。
  • DitD_{it} は「介入群かつ介入後」で1になる介入変数、 τ\tau が求めたい効果である。

この形は 二方向固定効果(two-way fixed effects、TWFE)モデル と呼ばれる。パネルがバランスしている(全プロフィールが全週の行を持つ)場合、 τ^\hat\tau は2×2の集計のDIDと ぴったり一致 する。だから回帰は新しい推定値を出すためではなく、同じ推定値に標準誤差をつけ、あとで共変量や複数の対照群に拡張するために使う。

実装:二方向のwithin変換

プロフィール数が数千〜数百万になると、プロフィールダミーを列として持つ計画行列は作れない。代わりに、 各変数からプロフィール平均と週平均を引く(within変換)と、固定効果を消した上で τ\tau だけを1変数の回帰で求められる。バランスしたパネルでは次の変換が固定効果を正確に消す。

Y~it=Yit−Yˉi⋅−Yˉ⋅t+Yˉ⋅⋅\tilde{Y}_{it}=Y_{it}-\bar{Y}_{i\cdot}-\bar{Y}_{\cdot t}+\bar{Y}_{\cdot\cdot}

Yˉi⋅\bar{Y}_{i\cdot} はプロフィール ii の全週の平均、 Yˉ⋅t\bar{Y}_{\cdot t} は週 tt の全プロフィールの平均、 Yˉ⋅⋅\bar{Y}_{\cdot\cdot} は全体の平均である。 DitD_{it} にも同じ変換を施し、 Y~\tilde{Y} を D~\tilde{D} に回帰した係数が τ^\hat\tau になる。

def twfe_did(bal: pl.DataFrame, outcome: str) -> dict[str, float]:
    """Y_it = a_i + l_t + tau D_it + e_it を二方向 within 変換で推定し、プロフィールでクラスター化した SE を返す。"""
    n_i = bal.get_column("profile_id").n_unique()
    n_t = bal.get_column("week").n_unique()
    y = bal.get_column(outcome).to_numpy().astype(float).reshape(n_i, n_t)   # 行 = プロフィール、列 = 週
    d = bal.get_column("D").to_numpy().astype(float).reshape(n_i, n_t)

    def within(m: np.ndarray) -> np.ndarray:
        return m - m.mean(axis=1, keepdims=True) - m.mean(axis=0, keepdims=True) + m.mean()

    y_w, d_w = within(y), within(d)
    tau = float((d_w * y_w).sum() / (d_w**2).sum())
    resid = y_w - tau * d_w
    # クラスター(プロフィール)ごとにスコアを合計してから 2 乗する
    score_by_profile = (d_w * resid).sum(axis=1)
    var = (score_by_profile**2).sum() / (d_w**2).sum() ** 2 * (n_i / (n_i - 1))
    return {"tau": tau, "se_cluster": float(np.sqrt(var))}

結果

アウトカム τ^\hat\tau クラスター標準誤差 t 値 p 値 解釈
minutes 5.49 1.55 3.54 0.0004 介入前水準64.8分の +8.5%
any_view 0.0121 0.0081 1.50 0.13 +1.2ポイント。有意でない
log(1+minutes) 0.102 0.035 2.88 0.004 exp⁡(0.102)−1=+10.7%\exp(0.102)-1=+10.7\%

視聴分数への効果は統計的に有意で、規模は介入前水準の8%程度である。一方、「何か見た割合」への効果は1ポイント程度で有意ではない。この組み合わせは、 新ホームは新たに見始める人を増やしたのではなく、すでに見ている人の視聴時間を伸ばした という解釈を示唆する。トレイ経由の再生率を副指標として持っていれば、この解釈をさらに裏づけられる。

尺度の選び方

同じ効果が、分数では +8.5%、対数では +10.7% と違う数字になる。第4章4.1.3で見た「実数か比率か」の問題である。両群の水準が違う(Fire TVの方が15%ほど多く見る)とき、連休のような共通ショックは分数の差では平行にならず、比率の差では平行になる。すると分数のDIDにはトレンドの乖離が混ざり、対数のDIDの方が仮定に合う。一方、対数は「0分の週」をどう扱うかに恣意性がある(ここでは log⁡(1+x)\log(1+x) )。 どの尺度でも結論の方向が同じであることを確認し、報告では主指標を一つ決めて他を併記する のが実務的な落としどころである。

6. 不確かさを評価する:クラスター標準誤差の意味と限界

プロフィールでクラスター化する理由

同じプロフィールの12週分の観測は互いに似ている(先週100分見た人は今週も見る)。第4章4.1.5の自己相関である。観測を独立と仮定した標準誤差は1.42だが、プロフィールでクラスター化すると1.55に増える。この例では差は小さいが、週数が長くなるほど、独立を仮定した標準誤差の過小評価は大きくなる。 パネルデータのDIDでは、個体(プロフィール)でクラスター化した標準誤差を既定にする。

クラスター標準誤差が答えていない問い

ここで、正直に限界を述べる必要がある。プロフィールでクラスター化した標準誤差は、「プロフィールを別のサンプルに取り替えたら推定値がどれだけ変わるか」を測っている。しかし、この分析で本当に怖いのは別の種類の不確かさである。 「介入と同じ週に、Fire TVだけに起きた別の出来事」 である。たとえばFire TVアプリの別の改修、Amazon側のFire TVキャンペーン、Fire TVのOS更新による不具合などは、Fire TVの全プロフィールに同じ方向に働く。これは「デバイス × 週」の単位で起きるショックであり、プロフィールを何万人集めても平均されて消えない。

第4章4.1.5の言葉で言えば、本来クラスター化すべき単位は「デバイス」である。しかしデバイスは2つしかなく、介入されたデバイスは1つしかないので、デバイスでクラスター化した標準誤差は計算できないか、計算できても信頼できない。 プロフィールでクラスター化した標準誤差と p 値は、「デバイス × 週のショックが介入以外に存在しない」という仮定のもとでの値 であり、その仮定こそが平行トレンド仮定である。

personプロフィールのばらつき

誰がサンプルに入ったかによる推定値の揺れ。

クラスター SE で扱える

tvデバイス × 週のショック

Fire TV だけに同じ週に起きた別の出来事。人数を増やしても消えない。

SE では扱えない。プラセボと点検で対処

public全体の共通ショック

連休、人気作の配信。両デバイスに同じに働く。

週固定効果が吸収する

図: 三種類の不確かさ。真ん中の「デバイス × 週」のショックだけが、統計量では処理できない。

時間方向のプラセボ検定:介入前にも「効果」が出るか

デバイス × 週のショックの大きさを推し量る実務的な方法が、 介入前のデータだけで架空の介入を置いてDIDを繰り返す ことである。効果はゼロのはずなので、出てきた数字は「介入がなくてもDIDが出してしまう値」の目安になる。

def placebo_in_time(bal: pl.DataFrame, outcome: str) -> pl.DataFrame:
    pre = bal.filter(pl.col("week") < 0)
    rows = []
    for fake_start in range(-6, 0):
        fake = pre.with_columns(post=(pl.col("week") >= fake_start).cast(pl.Int64))
        fake = fake.with_columns(D=pl.col("treat") * pl.col("post"))
        r = twfe_did(fake, outcome)
        rows.append({"fake_start_week": fake_start, "tau": r["tau"], "se_cluster": r["se_cluster"]})
    return pl.DataFrame(rows)
架空の介入開始週 「効果」(分) クラスター標準誤差
−6 2.0 1.8
−5 2.7 1.6
−4 3.9 1.6
−3 3.6 1.6
−2 1.5 1.8
−1 1.9 2.3
図: 時間方向のプラセボ検定。介入前だけで架空の介入週を動かしても2〜4分の「効果」が出る。本物の5.5分はそれを上回るが、圧倒的な差ではない(Plotly。線や点にカーソルを合わせると数値が出る)

介入がないはずの期間でも、DIDは2〜4分の「効果」を出している。すべて正であり、介入前に両群の差がゆるやかに広がっていたことを示す(4節の表でも差が4分から10分へ広がっていた)。これは二つのことを意味する。

  • 本物の推定値5.5分は、プラセボの最大値3.9分を上回っているが、圧倒的な差ではない。 「5.5分のうち2〜3分は、介入と無関係な差の広がりかもしれない」 と読むのが誠実である。
  • プロフィールでクラスター化したp値(0.0004)が示す確信の強さは、この不確かさを含んでいない。報告では、p値と並べてプラセボ検定の結果を必ず示す。

7. 頑健性チェック:結論が動かないことを確かめる

主結果を出したあと、次の変更を加えて推定し直し、結論(効果の方向と大まかな規模)が動かないことを確認する。それぞれの結果を一つの表にまとめて報告する。

変更 何を確かめるか
比較対象をApple TVやゲーム機に変える 特定の対照デバイスに固有のショックに依存していないか
介入前の期間を4週、12週に変える 群の決め方や基準水準の選び方に敏感でないか
介入前の主デバイス利用割合が90%以上のプロフィールに絞る 複数デバイス利用による介入の「漏れ」の影響
週あたり視聴分数を上位1%で切り詰める(winsorize) 少数のヘビーユーザに結果が引っ張られていないか
分数・対数・0/1のすべてで推定する 尺度の選択に結論が依存していないか
離脱者を0埋めする場合と除外する場合の両方 離脱の扱いで結論が変わらないか
配信週(週0)を除いて推定する 配信が週の途中で行われた影響

同時に、 統計ではなく調査で 潰すべき項目がある。介入と同じ4週間に、Fire TVだけに起きた出来事を洗い出す。アプリのリリースノート、Amazon側のキャンペーン、Fire TV向けの推薦モデルの更新、Fire TVで多く見られる作品の配信開始などである。一つでも該当すれば、その効果と新ホームの効果は分離できない(第4章4.4.1のアクティビティバイアス)。見つかった事実は報告書に列挙する。

8. 誰に効いたか、いつまで効いたか

効果の異質性

介入前の視聴量の中央値でヘビー層とライト層に分け、それぞれでDIDを行う。分割の基準は 介入前の情報だけ で作る(介入後の視聴量で分けると、効果そのもので群が決まってしまう)。

層 τ^\hat\tau(分) 標準誤差 介入前のFire TV平均 相対効果
ヘビー(中央値以上) 7.5 2.9 103.0 +7.3%
ライト(中央値未満) 4.6 1.1 20.8 +22.0%
図: ヘビー層とライト層の効果。分数(左)ではヘビー層が大きく、比率(右)ではライト層が大きい(Plotly。線や点にカーソルを合わせると数値が出る)

分数ではヘビー層の方が大きいが、比率ではライト層の方が大きい。「もともとあまり見ない人の視聴を2割伸ばした」という読み方と、「視聴時間の増分の多くはヘビー層から来ている」という読み方の両方が成り立つ。どちらを強調するかは施策の目的(離脱防止か、総視聴時間か)で決める。

効果の持続

4節のイベントスタディで見た通り、介入後の効果は週0の8.8分から週3の0.2分へ減っている。新ホームの「見た目が変わった」ことによる一時的な探索行動が、4週目にはほぼ消えている可能性がある。 4週平均の5.5分を「この施策の効果」として全デバイス展開の意思決定に使うのは危険 で、少なくとも8〜12週の追跡を提案する。

9. 推定量の信頼性をシミュレーションで確かめる

架空データの利点は、真の効果が分かることである。同じ設定でデータを200回作り直し、DIDの推定値と真のATTを比べた。

アウトカム 推定値の平均 真のATTの平均 バイアス 推定値の標準偏差 クラスター標準誤差の平均 95%区間が真の値を含んだ割合
minutes 7.86 9.16 −1.30 1.59 1.58 89.5%
log(1+minutes) 0.134 0.150 −0.015 0.036 0.035 93.5%
図: 架空データを200回作り直したときのDID推定値の分布。真のATT(赤)に対して推定値の平均(青破線)はやや左にあり、本文のデータ(橙点線)は分布の左裾にあった(Plotly。線や点にカーソルを合わせると数値が出る)

三つのことが読み取れる。

  • クラスター標準誤差は妥当 である。推定値の標準偏差(1.59)と標準誤差の平均(1.58)がほぼ一致している。プロフィールのばらつきに関する不確かさは、正しく見積もられている。
  • 推定値はやや過小 である。分数で1.3分ほど小さい。原因は2節で述べた非介入群の汚染で、介入後にFire TVへ乗り換えた人が新機能の効果を受けたまま非介入群に残り、非介入群の変化を押し上げているためである。方向が分かっている過小評価なので、「控えめな推定」として報告できる。
  • 本文で見た5.5分の推定値は、真の値9.2分から2.4標準誤差ほど下にある 。200回のうち1割ほどはこの程度外れる。一つのデータセットからの推定値は、標準誤差の2倍程度は真の値からずれ得るという当たり前の事実を、実感として持っておくとよい。

10. 報告の書き方

報告書は、数字よりも「何を仮定して、何を確認したか」が伝わる構成にする。次の順序を推奨する。

  1. 問いと定義 。介入、対象(介入前8週の主デバイスがFire TVのプロフィール、2,990人)、比較対象(Android TV、2,992人)、期間、主指標。
  2. 主結果 。DIDの推定値と95%区間を主指標で一つ。「週あたり視聴分数を5.5分(95%区間 2.4〜8.5分、介入前水準の +8.5%)増やした」。
  3. 仮定の確認 。介入前の週ごとの群平均の表とイベントスタディ。平行トレンドがどの程度成り立っていたかを、数字で示す。
  4. 不確かさの正直な評価 。クラスター標準誤差が何を含み、何を含まないか。プラセボ検定の結果(介入前でも2〜4分の「効果」が出た)。同時期にFire TVだけに起きた出来事の一覧。
  5. 副指標と異質性 。any_view、対数、トレイ経由再生率、ヘビー・ライト別。
  6. 持続性 。週ごとの効果と、その減衰。
  7. 頑健性チェックの一覧表 。
  8. 結論と限界 。「介入前にも差が広がる傾向があり、5.4分のうち一部はそれで説明できる可能性がある。効果は導入直後に集中し、4週目にはほぼ消えている。全デバイス展開の判断には8〜12週の追跡を推奨する」といった、意思決定者が次の行動を決められる文章にする。

11. 次回に向けて:設計段階で先手を打つ

今回のようにデバイス単位でしか介入できない場合でも、設計の工夫で分析はずっと楽になる。

  • 順次展開を計画的に行う 。Fire TVの2週後にAndroid TV、さらに2週後にApple TVと展開すれば、介入されたデバイスが複数になり、時期がずれることで「介入時期ごとの効果」を比べられる。分析には第4章ノートの追加手法で述べたStaggered DID(Callaway-Sant’Anna)を使う。
  • Fire TVの中でホールドアウトを残す 。Fire TV内でプロフィール単位に5〜10%の非配信群を残せれば、それは第1章のRCTであり、DIDは不要になる。実装の都合でできないことが多いが、まず検討すべき選択肢である。
  • スイッチバック 。Fire TVで「新ホームの表示を日単位でオン・オフ」できるなら、日をランダムに割り当てて比べられる。人気度の共有のような干渉があっても使いやすい。
  • 介入日を他のイベントからずらす 。新作の配信開始日や大型キャンペーンと同じ日に配信しない。これだけでアクティビティバイアスの大半は避けられる。
  • 介入前のログを長く残す 。群の固定と平行トレンドの確認には介入前の期間が長いほどよい。分析を始める前に、少なくとも8週、できれば12週の介入前データが取れる配信日を選ぶ。

チェックリスト

分析を終える前に、次の項目をすべて確認する。

  • 群は介入前のデータだけで決め、介入後の行動で動かしていない。
  • 離脱したプロフィールを除外せず、0で埋めている。
  • 介入前の週ごとの群平均とイベントスタディを見て、平行トレンドの程度を数字で述べている。
  • 標準誤差はプロフィールでクラスター化している。
  • クラスター標準誤差が「デバイス × 週のショック」を含まないことを明記し、プラセボ検定を併記している。
  • 介入と同時期にFire TVだけに起きた出来事を調査し、列挙している。
  • 分数・対数・0/1の複数の尺度で方向が一致している。
  • 効果を4週平均だけでなく週ごとに示し、減衰の有無を述べている。
  • 異質性の分割は介入前の情報だけで行っている。
  • 結論に「次に何をすべきか」(追跡期間、展開判断、設計の改善)が書かれている。

参考文献

  • 安井翔太『効果検証入門:正しい比較のための因果推論/計量経済学の基礎』技術評論社、2020年。第4章。
  • Bertrand, M., Duflo, E., & Mullainathan, S. (2004). How much should we trust differences-in-differences estimates? Quarterly Journal of Economics, 119(1), 249–275. パネルDIDの標準誤差とプラセボ検定。
  • Angrist, J. D., & Pischke, J.-S. (2008). Mostly Harmless Econometrics. Princeton University Press. 第5章(パネルデータと固定効果)、第8章(クラスター標準誤差)。
  • Roth, J. (2022). Pretest with caution: Event-study estimates after testing for parallel trends. American Economic Review: Insights, 4(3), 305–322.
  • Callaway, B., & Sant’Anna, P. H. C. (2021). Difference-in-differences with multiple time periods. Journal of Econometrics, 225(2), 200–230. 順次展開のDID。
  • Bojinov, I., Simchi-Levi, D., & Zhao, J. (2023). Design and analysis of switchback experiments. Management Science, 69(7), 3759–3777.
  • 本リポジトリ docs/domain/data_catalog.md 。 content_viewing 、 daily_personalize_viewing_kpi の列定義。