活性化したら本当に効いたか ― 効果検証でループを閉じる
活性化した施策が本当に成果を動かしたのかを測り、ループを閉じます。holdoutでの素朴な比較から、ユーザー単位で分けられないときの考え方まで概観し、厳密な検証は因果連載・実験連載へ、再学習はKaggle連載へと接続します。
by Shin
活性化した施策は、本当に成果を動かしたのでしょうか。答えは、配信群と非配信群(holdout)の差=増分で見るしかありません。「活性化したら数字が上がった」は「効いた」ではないからです。本記事では、第5回で配信前に切っておいたholdoutを使って増分を測り、ループを閉じます。素朴な比較がどこで崩れるか、そしてユーザー単位で割り付けられない活性化(広告同期・成果返し)をどう扱うかまで概観し、厳密な実験は実験連載へ、観測からの因果判断は因果連載へ、再学習はKaggle連載へと送り出します。
これまでの第1〜5回は「動かす」側でした。本回は「動かしたら学ぶ」側です。ここを省くと、連載冒頭で挙げた「分析して終わり」の双子——「活性化して終わり」——に陥ります。
ループを閉じる ― 活性化の増分を測る
価値連鎖はひとつの輪です。作る(Kaggle連載・予測スコア)→ 整える(dbt連載・マート)→ 動かす(本連載の第1〜5回)→ 測る(本回)→ 学ぶ(再学習)。多くの現場は「動かす」で止まり、輪が閉じません。閉じるとは、活性化の結果を測って次のサイクルへ返すことです。
測る対象は「増分(incremental lift)」です。配信した群の成果と、配信しなかった群(holdout)の成果の差を取ります。
holdoutがランダムに割り付けられているなら、この差は反実仮想——「もし配信しなかったら」——の近似になり、平均処置効果(ATE)の推定として読めます。逆に言えば、holdoutが無い、あるいはランダムでないなら、この式は因果として読めません。だから第5回で、配信の設計段階でholdoutを切っておいたわけです。後付けはできません。
もう一点、何を Y に置くかが肝心です。開封率やクリック率、媒体が報告するCVではなく、実成果——成約・F2転換・LTV——を置きます。これは第4回で「媒体CVではなく実成果で最適化させる」と整理した話の検証側です。最適化に実成果を返したなら、効果検証も実成果で行うのが筋です。
まずは messages(holdout_flag付きの配信ログ)に orders(実購入)を結合し、群別の実成果を出します。
-- 配信ログに実成果(orders)を左結合し、holdout別に
-- 「配信→実購入」の転換率と一人当たり売上を出す
with sent as (
select
m.customer_id,
m.holdout_flag, -- 1 = 非配信(対照群)
m.sent_at
from messages m
where m.campaign = 'winback_2026q2' -- 検証したい施策に限定
),
outcome as (
select
s.customer_id,
s.holdout_flag,
-- 配信後14日以内の実購入を真の成果に(媒体CVではなく orders)
max(case when o.order_at between s.sent_at
and timestamp_add(s.sent_at, interval 14 day)
then 1 else 0 end) as converted_14d,
sum(case when o.order_at between s.sent_at
and timestamp_add(s.sent_at, interval 14 day)
then o.amount else 0 end) as revenue_14d
from sent s
left join orders o using (customer_id)
group by 1, 2
)
select
holdout_flag,
count(*) as users,
avg(converted_14d) as cvr, -- 群別の転換率
avg(revenue_14d) as arpu -- 一人当たり売上
from outcome
group by holdout_flag;観測窓(ここでは14日)は施策の効きの時間構造に合わせて決めます。短すぎると効果を取りこぼし、長すぎると無関係な購入を拾います。窓は一つに決め打ちせず、後述のとおり複数取って効果の持続を見るのが安全です。
holdoutでの素朴な比較とその限界
集計が出ると、つい「配信群15%、holdout群13%、増分2ポイント。効いた」と言いたくなります。ですが点推定だけでは、その2ポイントが「効果」なのか「ばらつき」なのか区別できません。増分は必ず区間で語ります。
ブートストラップで増分の標本分布を作り、区間が0を含むかどうかを見ます。
import numpy as np
# y_treat, y_holdout: 各ユーザーの実成果(converted_14d を 0/1 の配列で)
def bootstrap_lift(y_treat, y_holdout, n=10_000, seed=0):
rng = np.random.default_rng(seed)
diffs = np.empty(n)
for i in range(n):
bt = rng.choice(y_treat, size=len(y_treat), replace=True)
bh = rng.choice(y_holdout, size=len(y_holdout), replace=True)
diffs[i] = bt.mean() - bh.mean() # 増分の標本分布
point = y_treat.mean() - y_holdout.mean()
lo, hi = np.percentile(diffs, [2.5, 97.5])
return point, (lo, hi)
point, ci = bootstrap_lift(y_treat, y_holdout)
# 例: 増分 +0.025、95%区間 [0.004, 0.046] → 区間が0を含まないので「増分あり」
# 区間が0を含むなら、点推定が正でも「効いた」とは言えない区間の下限が0より大きければ、増分があると言える。下限が0を割っていれば、点推定が正でも結論は保留です。これは因果連載第1回で扱う「不確実性を区間で語る」をそのまま活性化に持ち込んだものです。
点推定が区間に化けても、まだ落とし穴があります。
SOLNA(過去の受託案件をもとに設計した架空のD2Cスキンケア)で休眠掘り起こし配信を検証した想定では、F2転換率がholdout群12〜14%に対し配信群15〜17%、増分はおよそ2〜3ポイント(相対で15〜20%)に収まりました。ただし観測窓を14日から90日へ広げると増分は相対で一桁台後半まで縮みます。新規性効果が剥落した分です。短い窓だけを見て効果を語らないこと——誇張を避けるとは、こういう地味な操作の積み重ねです。
ユーザー単位で分けられないとき
ここまではユーザー単位でholdoutを切れる活性化、つまりメール・LINEのCRM配信を前提にしてきました。けれど活性化の一部は、原理的にユーザー単位では分けられません。
第3回の広告オーディエンス同期と、第4回の成果返し(Conversions API)がそれです。広告プラットフォームの最適化は、同期したオーディエンス全体や入札アルゴリズム全体に対して働きます。ある個人をholdoutに回しても、媒体側の学習は群をまたいで混ざり、配信そのものを止められるわけでもありません。成果返しに至っては、返した瞬間に媒体の最適化が全体として変わるので、「この人だけ返さない」holdoutは媒体が最適化する単位と一致しません。素朴なユーザー単位holdoutは、ここで成立しなくなります。
解は、割り付け単位を上げることです。
- geo holdout: 地域単位で活性化のon/offを分ける。SOLNAは複数地域に接点があるので、treated地域とcontrol地域を作れます。
- switchback: 全体を時間帯ごとにon/off切り替えし、期間で比較する。地域を分けられないときの代替です。
これは結局「比較可能な単位をどう作るか」の問題で、因果編で扱った成立条件に直結します。SOLNAのように複数地域があればgeoが成立しうる一方、単一商圏に集中したB2Bでは対照地域そのものが作れない——同じ手法が構造次第で成立したりしなかったりする、という非対称はここでも繰り返されます。
geoに上げると、集計はユーザー単位から地域×期間に変わります。
-- ユーザー単位で分けられない活性化(広告同期・成果返し)は、
-- region を割付単位にして treated 地域 vs control 地域で比較する
select
c.region,
case when c.region in ('A', 'B', 'C') then 'treated' else 'control' end as arm,
o.order_at >= date '2026-05-01' as post, -- 活性化の開始日
sum(o.amount) as revenue
from orders o
join customers c using (customer_id)
group by 1, 2, 3;
-- 平行トレンドの確認・検出力・switchbackの設計は実験の連載へ本回での到達点は、厳密なgeo実験を組むことではありません。「ユーザー単位の素朴holdoutが効かない活性化がある」と見分け、割付の単位を選べることです。平行トレンドの確認、必要サンプル(検出力)、switchbackの周期設計といった実験そのものの作法は、実験の連載(ユーザー単位で分けられないとき ― 地域実験と switchback)に譲ります。
検証 → 再学習(Kaggle連載へ)
ループを閉じる最後の一区間は、検証結果を次の学習に返すことです。ここを通して初めて輪が一巡します。
増分が出た場合。施策をスケールするのは当然として、それ以上に価値があるのは、holdout付きの配信ログそのものが「誰に効くか」を学ぶ教材になることです。ランダム割付された処置・非処置のログは、uplift modeling(CATE推定)の前提を満たします。配信を「効く人」に寄せれば、次サイクルの活性化が賢くなります。実装は因果連載の因果×ML(CATE・uplift)と呼応します。活性化はupliftのデータ生成装置でもある、という見方です。
増分が出なかった場合。「効かなかった」で止めず、輪のどこで切れたかを切り分けます。スコア自体が外れているのか(モデルの問題)、オーディエンスの定義が粗いのか(第2回のモデル駆動オーディエンス)、配信の中身か(第5回)、成果返しの品質か(第4回)。原因に応じて、再学習・定義の見直し・配信設計の修正へ戻します。
そしてどちらの場合も、「誰が本当に成約したか」という実成果ラベルが新しい教師データになります。これはKaggle連載のモデル再学習の入力です。予測スコアの較正(予測確率と実際の発生率の一致)が活性化を通してずれていれば、ここで直します。
つまり——作る(Kaggle)→ 整える(dbt)→ 動かす(活性化・本連載)→ 測る(効果検証・本回)→ 再学習(Kaggle)——と回り続ける。活性化は終点ではなく、次のサイクルの入口です。連載冒頭の「分析して終わり」も「活性化して終わり」も、この最後の一区間を繋ぐことで初めて閉じます。
章末チェックリスト
- 活性化の増分をholdoutで測れる(媒体CVではなく実成果で)
- 点推定でなく区間で増分を語っている(区間が0を含むかを見る)
- holdoutの汚染・非ランダム性・一時効果・多重比較を確認した
- ユーザー単位で分けられない活性化を見分け、geo/switchbackの必要性を判断できる
- 検証結果を再学習・オーディエンス定義の見直しへ返している
ここまでで活性化のループは一巡します。厳密な実験(geo・switchback・検出力)は実験の連載へ、観測データから因果に迫る判断と「自社の構造でどの手法が成立するか」は因果編へ、「誰に効くか」を学ぶupliftは因果連載の因果×MLへ、実成果ラベルからの再学習はKaggle連載へと送り出します。活性化で動かし、検証で学び、また動かす。輪が回り続けるかぎり、分析は施策に戻り続けます。