Mímisbrunnr知恵の泉

← マーケティングサイエンス 一覧

🎓 レベル:標準 | 重要度:A(必須)

📎 関連:マーケティングミックスモデリング(MMM) | 前提:ファネルとコンバージョンの基本指標

要点(BLUF)

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に至るジャーニーを、接触チャネルの列 (c1,c2,,cL)(c_1, c_2, \dots, c_L) とします(c1c_1 が最初、cLc_L が最後の接触、LL は接触数)。各ルールは、この1件のCV(重み合計 11)を次のように配分します。

wi  =  2(Li)j=1L2(Lj)w_i \;=\; \frac{2^{-(L-i)}}{\displaystyle\sum_{j=1}^{L} 2^{-(L-j)}}

どのルールも「もっともらしい一つの仮定」を置いているだけで、データから正しさを検証する手立てがありません。ラストクリックは「最後が決め手」、線形は「みんな平等」、時間減衰は「近いほど効く」——いずれも分析者の信念です。ここがルールベースの本質的な限界で、次節のデータ駆動はこの恣意性を経路全体の構造から減らそうとします。

3. データ駆動:マルコフ連鎖の除去効果(アルゴリズム)

マルコフ連鎖の除去効果は、全ジャーニーを1つの状態遷移として束ね、「あるチャネルが無かったら CV がどれだけ減るか」で貢献を測ります。手順は次の5つです。

  1. 各ジャーニーを start →(チャネル列)→ conversion または null(非CV) という状態列で表す。状態は {start, 各チャネル, conversion, null}\{\text{start},\ \text{各チャネル},\ \text{conversion},\ \text{null}\}
  2. 全ジャーニーから状態遷移の回数を数え、各状態からの遷移確率行列 PP を推定する。
  3. conversion と null を吸収状態とする吸収マルコフ連鎖で、start から conversion に到達する確率(=ベース CV 率)を計算する。
  4. 各チャネル cc を「除去」する——そのチャネルへ入る遷移をすべて null へ振り替える——と CV 到達確率がどう下がるかを再計算し、除去効果を求める。
  5. 除去効果をチャネル間で正規化して配分する。

数式にします。状態を過渡状態 T={start,c1,,cK}T=\{\text{start}, c_1,\dots,c_K\}吸収状態 A={conv,null}A=\{\text{conv},\text{null}\} に分け、遷移行列をブロックに書きます。

P=(QR0I)P=\begin{pmatrix} Q & R \\ \mathbf{0} & I \end{pmatrix}

ここで QQ は過渡→過渡、RR は過渡→吸収の遷移確率です。基本行列 N=(IQ)1N=(I-Q)^{-1} は「各過渡状態に平均何回滞在するか」を、B=NRB=NR は「各過渡状態から出発してどの吸収状態に落ちるか」の確率を与えます。求めたいベース CV 到達確率は

π  =  Bstart,conv\pi \;=\; B_{\,\text{start},\,\text{conv}}

です。次にチャネル cc を除去した行列で同じ計算をして πc\pi^{-c} を得ると、除去効果

REc  =  ππcπ\mathrm{RE}_c \;=\; \frac{\pi - \pi^{-c}}{\pi}

——「cc を消すと CV 到達確率が相対的に何割落ちるか」です。最後にチャネル間で正規化し、シェアc=REc/jREj\text{シェア}_c = \mathrm{RE}_c \big/ \sum_j \mathrm{RE}_j を配分とします。ラストクリックと違い、除去効果は経路全体のつながりを見ます——入口チャネルを消すと、その先の検索に至る流れごと断たれて CV が大きく落ちるため、入口の「つなぐ貢献」が数値に表れます。

マルコフ連鎖・吸収状態・基本行列 (IQ)1(I-Q)^{-1} の一般論はここでは使う分だけ説明しました。確率過程としての体系は確率論・統計の教材に譲ります。

4. ルールベースとマルコフを比べる(コード)

合成ジャーニーを 20,00020{,}000 本生成します。生成過程は「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 した経路の最後はたいてい検索——ラストクリックは検索に 55.6%55.6\% を与え、入口の SNS(14.1%14.1\%)・ディスプレイ(10.1%10.1\%)を低く見ます。しかし生成過程を思い出すと、CV した顧客の多くはSNS やディスプレイで入ってきて、そこから検索に流れて買っています。マルコフ除去効果は経路全体を見るので、SNS を消すと検索に来る人ごと減って CV が大きく落ちることを検知し、SNS に 26.2%26.2\%・ディスプレイに 20.3%20.3\% を配分します。検索は 55.6%36.3%55.6\%\to36.3\% に下がり、入口2チャネルは合計 24.2%46.5%24.2\%\to46.5\% に上がる——「決め手(検索)」偏重から「育成(SNS/ディスプレイ)」を含む配分へ動いたわけです。

中間の線形・時間減衰は、ルールの強さに応じてこの中間に並びます。線形(検索 38.1%38.1\%)は等分なのでマルコフに近く、時間減衰(検索 44.3%44.3\%)は「近いほど重い」分だけラスト寄りです。どれを採るかで予算判断が変わる——ルールベースの恣意性が一覧で見て取れます。最後に整合性チェックとして、マルコフのベース CV 到達確率 0.3600.360 は生成データの実測 CV 率 0.3600.360 と一致しています。遷移行列をジャーニーから正しく推定できている証拠です。

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・ディスプレイのところでは逆に赤が最も低く、マルコフ(緑)が高い——「決め手」偏重か「育成」考慮かが、チャネルごとの赤と緑の高低として一目で読み取れます。線形・時間減衰はその中間に並びます。

⚠️ よくある誤解

関連ノート