🎓 レベル:標準 | 重要度:A(必須)
📎 関連:マーケティングミックスモデリング(MMM) | 前提:ファネルとコンバージョンの基本指標
要点(BLUF)
- アトリビューション分析は、1件のコンバージョン(CV=購入)への貢献を、その顧客がたどった経路上の各接触点(チャネル)に配分する手法です。マーケティングミックスモデリング(MMM) が集計時系列を上から分解するトップダウンだったのに対し、アトリビューションは個人の経路(ジャーニー)を下から積み上げるボトムアップです。
- 配分の決め方には2系統あります。ルールベース(ラストクリック=最後の接触に100%、ファースト=最初に100%、線形=等分、時間減衰=CVに近いほど重い)は単純ですが恣意的で、どれが正しいかはデータからは決まりません。データ駆動の代表がマルコフ連鎖の除去効果(removal effect)——各チャネルを経路から「除去」したときにCV到達確率がどれだけ落ちるかで貢献を測ります。
- 本ノートの合成データでは、ラストクリックは購入直前の「検索」を と過大評価し、入口の「SNS」・「ディスプレイ」 を過小評価します。いっぽうマルコフ除去効果は 検索 ・SNS ・ディスプレイ と、入口チャネルが「検索につなぐ」育成貢献を拾います。ただしアトリビューションは観測経路の相関配分であって、施策の増分(因果)とは別物です(厳密な増分は第7章 実験)。
1. アトリビューションとは:CVへの貢献を経路上の接触点に配分する
ある顧客は、いきなり買うわけではありません。SNS で知り → ディスプレイ広告で思い出し → 検索して比較し → 購入、というように複数のチャネルに触れて(接触点=タッチポイント)からCVします。この一連をジャーニーと呼びます。アトリビューションの問いは単純です——この1件のCVは、経路上のどのチャネルの「おかげ」か。貢献をどう山分けするか。
flowchart LR S["start"] --> SNS["SNS(入口・認知)"] S --> DP["ディスプレイ(入口・想起)"] SNS --> SE["検索(購入直前)"] DP --> SE SNS --> NL["null(離脱・非CV)"] SE --> CV["conversion(購入)"] SE --> NL
素朴にやると、計測タグの仕様上いちばん拾いやすい最後の接触(ラストクリック)に全部の手柄を与えがちです。すると上図で言えば、購入直前の検索ばかりが評価され、そもそも顧客を連れてきたSNS・ディスプレイは0点になります。これは「検索に予算を寄せ、認知広告を削る」という意思決定を生みますが、認知を削れば検索に来る人自体が減る——入口を過小評価するのがラストクリックの構造的な弱点です。アトリビューションの手法群は、この山分けをどう公平にするかの工夫だと捉えてください。
なお マーケティングミックスモデリング(MMM) との関係を整理すると、MMM はチャネル別の出稿額と集計売上の回帰(トップダウン・個人ログ不要)、アトリビューションは個人のCV経路を接触点に配る(ボトムアップ・個人ログ必須)。粒度も必要データも違い、両者は補完関係です。
2. ルールベース配分の定義(数式)
1件のCVに至るジャーニーを、接触チャネルの列 とします( が最初、 が最後の接触、 は接触数)。各ルールは、この1件のCV(重み合計 )を次のように配分します。
- ラストクリック:最後の接触 に全部。
- ファーストクリック:最初の接触 に全部。
- 線形:すべての接触に等分。各接触の重みは 。
- 時間減衰:CV に近い接触ほど重く。位置 の接触の「CV までの距離」を とし、重み を正規化します( は距離 で 、 は距離 で 、…)。
どのルールも「もっともらしい一つの仮定」を置いているだけで、データから正しさを検証する手立てがありません。ラストクリックは「最後が決め手」、線形は「みんな平等」、時間減衰は「近いほど効く」——いずれも分析者の信念です。ここがルールベースの本質的な限界で、次節のデータ駆動はこの恣意性を経路全体の構造から減らそうとします。
3. データ駆動:マルコフ連鎖の除去効果(アルゴリズム)
マルコフ連鎖の除去効果は、全ジャーニーを1つの状態遷移として束ね、「あるチャネルが無かったら CV がどれだけ減るか」で貢献を測ります。手順は次の5つです。
- 各ジャーニーを start →(チャネル列)→ conversion または null(非CV) という状態列で表す。状態は 。
- 全ジャーニーから状態遷移の回数を数え、各状態からの遷移確率行列 を推定する。
- conversion と null を吸収状態とする吸収マルコフ連鎖で、start から conversion に到達する確率(=ベース CV 率)を計算する。
- 各チャネル を「除去」する——そのチャネルへ入る遷移をすべて null へ振り替える——と CV 到達確率がどう下がるかを再計算し、除去効果を求める。
- 除去効果をチャネル間で正規化して配分する。
数式にします。状態を過渡状態 と吸収状態 に分け、遷移行列をブロックに書きます。
ここで は過渡→過渡、 は過渡→吸収の遷移確率です。基本行列 は「各過渡状態に平均何回滞在するか」を、 は「各過渡状態から出発してどの吸収状態に落ちるか」の確率を与えます。求めたいベース CV 到達確率は
です。次にチャネル を除去した行列で同じ計算をして を得ると、除去効果は
——「 を消すと CV 到達確率が相対的に何割落ちるか」です。最後にチャネル間で正規化し、 を配分とします。ラストクリックと違い、除去効果は経路全体のつながりを見ます——入口チャネルを消すと、その先の検索に至る流れごと断たれて CV が大きく落ちるため、入口の「つなぐ貢献」が数値に表れます。
マルコフ連鎖・吸収状態・基本行列 の一般論はここでは使う分だけ説明しました。確率過程としての体系は確率論・統計の教材に譲ります。
4. ルールベースとマルコフを比べる(コード)
合成ジャーニーを 本生成します。生成過程は「SNS・ディスプレイが入口になりやすく直接CVは低い/検索は購入直前の高インテントでCV率が高い/メールは中程度」という、現実によくある構造にしました。この経路群に対し、ラストクリック・線形・時間減衰(ルールベース3種)とマルコフ除去効果を計算し、チャネル別の配分シェアを対比します。
import numpy as np
import pandas as pd
# ---- 合成ジャーニーの生成 ----------------------------------------------------
# 状態:start, 検索, SNS, ディスプレイ, メール, conversion, null
# 生成過程(真の遷移):SNS/ディスプレイが入口になりやすく直接CVは低い、
# 検索は購入直前の高インテント(CV率高い)、メールは中程度。
rng = np.random.default_rng(7)
channels = ["検索", "SNS", "ディスプレイ", "メール"]
states = ["start"] + channels + ["conversion", "null"]
idx = {s: i for i, s in enumerate(states)}
K = len(channels)
# 生成用の遷移確率(行=from, 列=to)。検索=高CV closer、SNS/ディスプレイ=入口。
gen = {
"start": {"検索":0.15, "SNS":0.40, "ディスプレイ":0.35, "メール":0.10},
"検索": {"検索":0.05, "SNS":0.05, "ディスプレイ":0.05, "メール":0.10, "conversion":0.45, "null":0.30},
"SNS": {"検索":0.25, "SNS":0.05, "ディスプレイ":0.15, "メール":0.10, "conversion":0.10, "null":0.35},
"ディスプレイ":{"検索":0.20, "SNS":0.10, "ディスプレイ":0.05, "メール":0.10, "conversion":0.08, "null":0.47},
"メール": {"検索":0.20, "SNS":0.05, "ディスプレイ":0.05, "メール":0.05, "conversion":0.30, "null":0.35},
}
def sample_next(state):
d = gen[state]
outs = list(d.keys())
probs = np.array([d[o] for o in outs])
return outs[rng.choice(len(outs), p=probs)]
N = 20000
journeys = []
for _ in range(N):
path, s = [], "start"
s = sample_next("start")
while s not in ("conversion", "null"):
path.append(s)
s = sample_next(s)
if len(path) >= 20: # 安全のための打ち切り
s = "null"; break
journeys.append((path, s == "conversion"))
total_conv = sum(conv for _, conv in journeys)
print(f"生成ジャーニー数 {N}、うちCV {total_conv}(実測CV率 {total_conv/N:.3f})")
# ---- ルールベース配分 --------------------------------------------------------
last = {c: 0.0 for c in channels}
linear= {c: 0.0 for c in channels}
decay = {c: 0.0 for c in channels}
for path, conv in journeys:
if not conv or not path:
continue
last[path[-1]] += 1.0 # ラスト:最後の接触に100%
for c in path: # 線形:接触数で等分(位置ごと)
linear[c] += 1.0 / len(path)
w = np.array([2.0 ** (-(len(path) - 1 - i)) for i in range(len(path))])
w = w / w.sum() # 時間減衰:CVに近いほど重い
for c, wi in zip(path, w):
decay[c] += wi
# ---- データ駆動:マルコフ除去効果 -------------------------------------------
# (2) ジャーニーから遷移回数を数え、確率行列 P を推定
C = np.zeros((len(states), len(states)))
for path, conv in journeys:
seq = ["start"] + path + (["conversion"] if conv else ["null"])
for a, b in zip(seq[:-1], seq[1:]):
C[idx[a], idx[b]] += 1.0
P = np.zeros_like(C)
trans_states = ["start"] + channels # 過渡状態
abs_states = ["conversion", "null"] # 吸収状態
for s in trans_states:
row = C[idx[s]]
P[idx[s]] = row / row.sum()
P[idx["conversion"], idx["conversion"]] = 1.0
P[idx["null"], idx["null"]] = 1.0
tr = [idx[s] for s in trans_states]
ab = [idx[s] for s in abs_states]
def conv_prob(Pmat):
# (3) 吸収マルコフ連鎖:N=(I-Q)^{-1}, B=N R, start→conversion 到達確率
Q = Pmat[np.ix_(tr, tr)]
R = Pmat[np.ix_(tr, ab)]
Nfund = np.linalg.inv(np.eye(len(tr)) - Q)
B = Nfund @ R
return B[trans_states.index("start"), abs_states.index("conversion")]
base = conv_prob(P) # ベースCV率(到達確率)
# (4) 各チャネルを「除去」:そのチャネルへの遷移を null へ振替 → 再計算
removal = {}
for c in channels:
Pr = P.copy()
ci = idx[c]
for s in trans_states: # 入ってくる遷移を null へ
Pr[idx[s], idx["null"]] += Pr[idx[s], ci]
Pr[idx[s], ci] = 0.0
removal[c] = (base - conv_prob(Pr)) / base # 除去効果 =(ベース−除去後)/ベース
# (5) 除去効果をチャネル間で正規化して配分
rsum = sum(removal.values())
markov = {c: removal[c] / rsum * total_conv for c in channels}
# ---- 配分の対比表 ------------------------------------------------------------
def share(d):
tot = sum(d.values())
return {c: d[c] / tot for c in channels}
tbl = pd.DataFrame({
"ラスト配分": [last[c] for c in channels],
"ラスト%": [share(last)[c] for c in channels],
"線形%": [share(linear)[c] for c in channels],
"時間減衰%": [share(decay)[c] for c in channels],
"除去効果": [removal[c] for c in channels],
"マルコフ%": [share(markov)[c] for c in channels],
}, index=channels)
print("\n=== チャネル別の貢献配分(CV {} 件を配る)===".format(total_conv))
print(tbl.to_string(formatters={
"ラスト配分": "{:.0f}".format,
"ラスト%": "{:.1%}".format,
"線形%": "{:.1%}".format,
"時間減衰%": "{:.1%}".format,
"除去効果": "{:.3f}".format,
"マルコフ%": "{:.1%}".format}))
print(f"\nベースCV到達確率(マルコフ)= {base:.3f} (実測CV率 {total_conv/N:.3f} とほぼ一致)")
ls, ms = share(last), share(markov)
print(f"検索 :ラスト {ls['検索']:.1%} → マルコフ {ms['検索']:.1%}"
f"(ラストが {ls['検索']-ms['検索']:+.1%} 過大評価)")
print(f"SNS :ラスト {ls['SNS']:.1%} → マルコフ {ms['SNS']:.1%}"
f"(ラストが {ls['SNS']-ms['SNS']:+.1%} 過小評価)")
print(f"ディスプレイ:ラスト {ls['ディスプレイ']:.1%} → マルコフ {ms['ディスプレイ']:.1%}"
f"(ラストが {ls['ディスプレイ']-ms['ディスプレイ']:+.1%} 過小評価)")
出力:
生成ジャーニー数 20000、うちCV 7209(実測CV率 0.360)
=== チャネル別の貢献配分(CV 7209 件を配る)===
ラスト配分 ラスト% 線形% 時間減衰% 除去効果 マルコフ%
検索 4005 55.6% 38.1% 44.3% 0.610 36.3%
SNS 1015 14.1% 26.0% 21.6% 0.439 26.2%
ディスプレイ 728 10.1% 18.8% 15.6% 0.340 20.3%
メール 1461 20.3% 17.1% 18.5% 0.290 17.3%
ベースCV到達確率(マルコフ)= 0.360 (実測CV率 0.360 とほぼ一致)
検索 :ラスト 55.6% → マルコフ 36.3%(ラストが +19.2% 過大評価)
SNS :ラスト 14.1% → マルコフ 26.2%(ラストが -12.1% 過小評価)
ディスプレイ:ラスト 10.1% → マルコフ 20.3%(ラストが -10.2% 過小評価)
出力の意味:まずラストクリックの偏りがはっきり出ています。検索は購入直前の高インテント・チャネルなので、CV した経路の最後はたいてい検索——ラストクリックは検索に を与え、入口の SNS()・ディスプレイ()を低く見ます。しかし生成過程を思い出すと、CV した顧客の多くはSNS やディスプレイで入ってきて、そこから検索に流れて買っています。マルコフ除去効果は経路全体を見るので、SNS を消すと検索に来る人ごと減って CV が大きく落ちることを検知し、SNS に ・ディスプレイに を配分します。検索は に下がり、入口2チャネルは合計 に上がる——「決め手(検索)」偏重から「育成(SNS/ディスプレイ)」を含む配分へ動いたわけです。
中間の線形・時間減衰は、ルールの強さに応じてこの中間に並びます。線形(検索 )は等分なのでマルコフに近く、時間減衰(検索 )は「近いほど重い」分だけラスト寄りです。どれを採るかで予算判断が変わる——ルールベースの恣意性が一覧で見て取れます。最後に整合性チェックとして、マルコフのベース CV 到達確率 は生成データの実測 CV 率 と一致しています。遷移行列をジャーニーから正しく推定できている証拠です。
4手法の配分シェアを1枚の棒グラフで並べます(再現のため生成からまとめて載せます)。
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
# 同じ生成過程でジャーニーを作り、4手法の配分シェアを棒で対比する
rng = np.random.default_rng(7)
channels = ["検索", "SNS", "ディスプレイ", "メール"]
states = ["start"] + channels + ["conversion", "null"]
idx = {s: i for i, s in enumerate(states)}
gen = {
"start": {"検索":0.15, "SNS":0.40, "ディスプレイ":0.35, "メール":0.10},
"検索": {"検索":0.05, "SNS":0.05, "ディスプレイ":0.05, "メール":0.10, "conversion":0.45, "null":0.30},
"SNS": {"検索":0.25, "SNS":0.05, "ディスプレイ":0.15, "メール":0.10, "conversion":0.10, "null":0.35},
"ディスプレイ":{"検索":0.20, "SNS":0.10, "ディスプレイ":0.05, "メール":0.10, "conversion":0.08, "null":0.47},
"メール": {"検索":0.20, "SNS":0.05, "ディスプレイ":0.05, "メール":0.05, "conversion":0.30, "null":0.35},
}
def sample_next(s):
d = gen[s]; outs = list(d.keys()); p = np.array([d[o] for o in outs])
return outs[rng.choice(len(outs), p=p)]
journeys = []
for _ in range(20000):
path, s = [], sample_next("start")
while s not in ("conversion", "null"):
path.append(s); s = sample_next(s)
if len(path) >= 20: s = "null"; break
journeys.append((path, s == "conversion"))
last = {c:0.0 for c in channels}; linear = {c:0.0 for c in channels}; decay = {c:0.0 for c in channels}
for path, conv in journeys:
if not conv or not path: continue
last[path[-1]] += 1.0
for c in path: linear[c] += 1.0/len(path)
w = np.array([2.0**(-(len(path)-1-i)) for i in range(len(path))]); w /= w.sum()
for c, wi in zip(path, w): decay[c] += wi
trans_states = ["start"]+channels; abs_states = ["conversion","null"]
tr = [idx[s] for s in trans_states]; ab = [idx[s] for s in abs_states]
C = np.zeros((len(states), len(states)))
for path, conv in journeys:
seq = ["start"]+path+(["conversion"] if conv else ["null"])
for a,b in zip(seq[:-1], seq[1:]): C[idx[a], idx[b]] += 1.0
P = np.zeros_like(C)
for s in trans_states: P[idx[s]] = C[idx[s]]/C[idx[s]].sum()
P[idx["conversion"], idx["conversion"]] = 1.0; P[idx["null"], idx["null"]] = 1.0
def conv_prob(M):
Q = M[np.ix_(tr,tr)]; R = M[np.ix_(tr,ab)]
B = np.linalg.inv(np.eye(len(tr))-Q) @ R
return B[trans_states.index("start"), abs_states.index("conversion")]
base = conv_prob(P); removal = {}
for c in channels:
Pr = P.copy()
for s in trans_states:
Pr[idx[s], idx["null"]] += Pr[idx[s], idx[c]]; Pr[idx[s], idx[c]] = 0.0
removal[c] = (base - conv_prob(Pr))/base
rsum = sum(removal.values()); markov = {c: removal[c]/rsum for c in channels}
def share(d):
tot = sum(d.values()); return np.array([d[c]/tot for c in channels])
methods = {"ラストクリック": share(last), "線形": share(linear),
"時間減衰": share(decay), "マルコフ除去効果": np.array([markov[c] for c in channels])}
fig, ax = plt.subplots(figsize=(8.8, 4.6))
x = np.arange(len(channels)); width = 0.2
cols = ["#d62728", "#7f7f7f", "#ff7f0e", "#2ca02c"]
for i, (name, vals) in enumerate(methods.items()):
ax.bar(x + (i-1.5)*width, vals*100, width, label=name, color=cols[i])
ax.set_xticks(x); ax.set_xticklabels(channels)
ax.set_ylabel("配分シェア(%)")
ax.set_title("アトリビューション:ラストクリックは検索を過大・SNS/ディスプレイを過小")
ax.legend(loc="upper right", fontsize=9)
ax.axhline(0, color="black", lw=0.6)
fig.tight_layout()
plt.show()
棒グラフでは、検索のところでラストクリック(赤)が突出し、SNS・ディスプレイのところでは逆に赤が最も低く、マルコフ(緑)が高い——「決め手」偏重か「育成」考慮かが、チャネルごとの赤と緑の高低として一目で読み取れます。線形・時間減衰はその中間に並びます。
⚠️ よくある誤解
- ラストクリックは入口・育成チャネルを過小評価する:最後の接触だけに手柄を与えるので、認知・興味を作った上流チャネル(本ノートの SNS・ディスプレイ)が構造的に0点付近になります。これを根拠に認知広告を削ると、検索に来る母数そのものが減って CV が落ちる——「決め手」と「お膳立て」を取り違える典型です。
- ルールベースはどれも恣意的で、正しさをデータで決められない:ラスト・ファースト・線形・時間減衰は、どれも分析者が置いた1つの仮定にすぎません。同じ経路データから手法を変えるだけで配分が動く(本ノートで検索 〜)ので、「うちはこのモデル」という選択自体が結論を左右します。マルコフ除去効果は経路構造から配分を導く点でマシですが、それでも観測した経路の説明であって絶対の真値ではありません。
- アトリビューションは相関配分であって、増分(因果)ではない:除去効果も含め、アトリビューションは「観測されたCV経路をどう山分けするか」の問題です。「そのチャネルを止めたら本当にCVが何件減るか」という増分(インクリメンタル)=因果効果とは別物で、配分シェアが高い=止めると同じ割合だけ減る、とは限りません(自然検索やリターゲティングは、無くても別経路で買われやすい)。真の増分は経路の観察ではなく実験(ジオ実験・広告のホールドアウト)で測るのが筋で、これは第7章 実験と因果推論の領域です。アトリビューションは仮説出しと配分の出発点、実験が答え合わせ、という役割分担で使います。
- 計測の穴で経路がそもそも欠ける:ビュースルー(広告を見たがクリックしていない接触)、跨デバイス(スマホで認知しPCで購入)、Cookie 制限やアプリ/Web 横断で、ジャーニーの一部が記録から落ちます。欠けた経路の上では、どんな配分ルールも歪みます。集計だけで全体を見る マーケティングミックスモデリング(MMM) が再評価されているのは、まさにこの個人追跡の限界が背景です。
関連ノート
- マーケティングミックスモデリング(MMM)(トップダウンの集計分解。アトリビューションのボトムアップと補完関係)
- 残存効果(adstock)と飽和(同じ第4章。効果の時間的繰越と頭打ちを MMM に入れる)
- 第4章 市場反応モデル 目次
- ファネルとコンバージョンの基本指標(前提・CVに至る経路と各段階の指標設計)
- 配分シェアと「止めたら何件減るか(増分・因果)」は別物——厳密な増分はランダム化した実験で測る第7章 実験と因果推論へ/吸収マルコフ連鎖・基本行列の一般論は確率論・統計の教材へ
- マーケティング・サイエンス 全体目次