🎓 レベル:標準 | 重要度:A(必須)
📎 関連:顧客生涯価値(LTV) | 前提:リテンションとチャーン
要点(BLUF)
- コホート(cohort) は、獲得時期でグループ化した顧客の集団です。全体平均のリテンションは新規流入の構成に左右されて誤導するので、コホートに分けて見ます。
- コホート × 経過期間 age のリテンション行列を作ると、行(コホート)方向で「最近のコホートは改善したか(vintage 効果)」、列(age)方向で「age とともにどう下がるか(加齢効果)」を分離して読めます。
- 行列は三角形になります。新しいコホートは浅い age までしか観測できず、右側(未来)は欠測。平均を取るときは、age ごとに母数(観測できたコホート数)が違うことに注意します。
1. コホートとは:獲得時期で分けて見る
コホート分析は、顧客を獲得した時期(入会月・初回購入月など)でグループ化し、各グループの振る舞いを「獲得からの経過期間(age)」で揃えて比べる手法です。
なぜグループ化するのか。全体平均のリテンションは、新規流入の構成に簡単に振り回されるからです。獲得直後の顧客はまだ離れていない(age0 のリテンションは 1.0)ので、新規を大量に獲得した月は、全体平均リテンションが見かけ上高く出ます。逆に新規が細った月は低く見える。これは顧客の質や定着とは無関係な、**構成の効果(mix shift)**にすぎません。リテンションとチャーン で見た生存者バイアスと並ぶ、もう一つの「全体平均の罠」です。
そこで、獲得時期(コホート)× 経過期間(age) の2軸に分解します。すると2種類の効果を切り分けられます。
- 加齢効果(age 効果):獲得からの時間が経つとリテンションはどう下がるか(age 方向)。
- vintage 効果(cohort 効果):最近獲得したコホートは、昔のコホートより定着が良く/悪くなっているか(cohort 方向)。
この2つは混同されがちですが、コホート行列なら別々の軸として読めます。
2. コホート・リテンション行列:三角形で読む
コホートを行、経過期間 age を列に置き、各セルに「そのコホートが age 時点で何割残っているか」を入れた表がコホート・リテンション行列です。リテンション率は、獲得時人数 に対する生存割合で定義します。
ここで はコホート の age における生存人数。定義から (age0 列は必ず 1.0)です。
重要なのは、この行列が三角形になること。現在の暦時点を 、コホート の獲得時点を とすると、観測できるのは の範囲だけ。新しいコホートほど観測できる age が浅く、右側(未来)は欠測になります。
flowchart LR Mat["コホート×age リテンション行列(三角形)"] --> Age["age方向に平均:加齢効果(ageとともに低下)"] Mat --> Coh["cohort方向に比較:vintage効果(最近コホートは改善?)"]
行列の読み方は2方向です。age 方向(列)に平均すれば、「平均的なリテンションカーブ」(age とともに低下する曲線)が得られます。ただし age が深いほど観測コホートが減るので、列ごとに母数が違うことに注意。cohort 方向(行)に比較すれば、同じ age での値が新しいコホートほど高い/低いかが分かり、施策やプロダクト改善の効果(vintage 効果)を読めます。
3. 合成コホート行列で確かめる(コード)
概念:6コホート(獲得月 0〜5)について、churn を逐次的にシミュレートしてコホート・リテンション行列を作ります。後のコホートほど期間継続率をわずかに高く設定し(vintage 改善)、三角形の欠測を再現します。
数式:コホート は age から へ、期間継続率 で生き残るとします。生存人数は二項分布で 、リテンションは 。観測できるのは のセルだけです。
次のコードで、(a) 三角行列、(b) age 別平均リテンション、(c) 最近コホートの改善、を出します。
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n_cohorts = 6 # 獲得時期(月)0..5 の6コホート
max_age = 5 # 経過期間 age 0..5
now = 5 # 現在の暦月(コホートcは age<=now-c までしか観測できない)
# 各コホートの初期人数(事業成長で後のコホートほど大きい)
N0 = np.array([4000, 4300, 4600, 5000, 5400, 5800])
# age→age+1 の期間継続率(base)。ageが進むほど高い=生き残りは粘る(異質性)
p_base = np.array([0.55, 0.72, 0.80, 0.85, 0.88])
improve = 0.02 # 後のコホートほど継続率が改善(cohort効果)
def p_period(c, from_age):
return min(p_base[from_age] + improve * c, 0.98)
# コホート×age の生存人数を逐次的にBinomialで生成(churnは後戻りしない)
counts = np.full((n_cohorts, max_age + 1), np.nan)
for c in range(n_cohorts):
obs_max = now - c # このコホートが観測できる最大age
alive = N0[c]
counts[c, 0] = alive
for a in range(obs_max): # age a -> a+1 の生き残りを抽選
alive = rng.binomial(alive, p_period(c, a))
counts[c, a + 1] = alive
# リテンション率=獲得時人数に対する生存割合 counts/N0(age0は必ず1.0)
ret = counts / N0[:, None]
idx = [f"獲得月{c}" for c in range(n_cohorts)]
col = [f"age{a}" for a in range(max_age + 1)]
ret_df = pd.DataFrame(ret, index=idx, columns=col)
print("=== (a) コホート×age リテンション率行列(三角行列・右側は未観測)===")
print(ret_df.to_string(float_format=lambda v: f"{v:.3f}", na_rep=" - "))
# (b) age別の平均リテンション(観測済みコホートだけで平均:母数がageごとに違う)
age_avg = ret_df.mean(axis=0) # skipna=True が既定
age_cnt = ret_df.notna().sum(axis=0) # 平均に使ったコホート数
print("\n=== (b) age別の平均リテンションカーブ(ageとともに低下)===")
for a in range(max_age + 1):
print(f"age{a}: 平均リテンション {age_avg.iloc[a]:.3f} (コホート{age_cnt.iloc[a]}本で平均)")
# (c) 最近コホートほど改善か:age1のリテンションをコホート順に(上昇=改善)
age1 = ret_df["age1"].dropna()
print("\n=== (c) cohort効果:age1リテンションをコホート順に(上昇=改善)===")
for name, v in age1.items():
print(f"{name}: age1リテンション {v:.3f}")
print(f"→ 最古 {age1.index[0]}={age1.iloc[0]:.3f} → 直近 {age1.index[-1]}={age1.iloc[-1]:.3f}"
f"(差 +{age1.iloc[-1] - age1.iloc[0]:.3f})")
出力:
=== (a) コホート×age リテンション率行列(三角行列・右側は未観測)===
age0 age1 age2 age3 age4 age5
獲得月0 1.000 0.564 0.409 0.331 0.278 0.240
獲得月1 1.000 0.573 0.430 0.345 0.296 -
獲得月2 1.000 0.597 0.453 0.377 - -
獲得月3 1.000 0.614 0.482 - - -
獲得月4 1.000 0.633 - - - -
獲得月5 1.000 - - - - -
=== (b) age別の平均リテンションカーブ(ageとともに低下)===
age0: 平均リテンション 1.000 (コホート6本で平均)
age1: 平均リテンション 0.596 (コホート5本で平均)
age2: 平均リテンション 0.444 (コホート4本で平均)
age3: 平均リテンション 0.351 (コホート3本で平均)
age4: 平均リテンション 0.287 (コホート2本で平均)
age5: 平均リテンション 0.240 (コホート1本で平均)
=== (c) cohort効果:age1リテンションをコホート順に(上昇=改善)===
獲得月0: age1リテンション 0.564
獲得月1: age1リテンション 0.573
獲得月2: age1リテンション 0.597
獲得月3: age1リテンション 0.614
獲得月4: age1リテンション 0.633
→ 最古 獲得月0=0.564 → 直近 獲得月4=0.633(差 +0.069)
出力の意味:(a) の行列は三角形で、右上に向かって -(未観測)が広がります。獲得月5 は age0 しか観測できず、獲得月0 だけが age5 まで揃う——新しいコホートほど履歴が浅い、コホートデータの宿命です。(b) を列方向に平均すると、age0:1.000 → age1:0.596 → … → age5:0.240 と、age とともに下がる平均リテンションカーブが出ます。ただし平均に使ったコホート数は 6本 → 1本へ減っており、深い age ほど母数が薄く不安定だと分かります(age5 はコホート1本の値そのもの)。(c) は同じ age1 を行方向に比較したもので、獲得月0 の 0.564 から獲得月4 の 0.633 へ、最近のコホートほどリテンションが高い(+0.069)。これが vintage 効果——プロダクト改善やオンボーディング施策が効いている兆候です。「age とともに下がる(加齢効果)」と「最近コホートが上がる(cohort 効果)」は別軸で、行列に分けたからこそ両方を取り違えずに読めます。
最後に、行列をヒートマップで可視化します。色の濃淡でリテンションの高低、灰色で未観測(三角形の欠測)が一目で分かります。
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib # noqa: F401 日本語ラベル
rng = np.random.default_rng(42)
n_cohorts, max_age, now = 6, 5, 5
N0 = np.array([4000, 4300, 4600, 5000, 5400, 5800])
p_base = np.array([0.55, 0.72, 0.80, 0.85, 0.88])
improve = 0.02
# コホート×age のリテンション率行列を生成(上のコードと同じ設定)
counts = np.full((n_cohorts, max_age + 1), np.nan)
for c in range(n_cohorts):
alive = N0[c]
counts[c, 0] = alive
for a in range(now - c):
alive = rng.binomial(alive, min(p_base[a] + improve * c, 0.98))
counts[c, a + 1] = alive
ret = counts / N0[:, None]
masked = np.ma.masked_invalid(ret) # 未観測(NaN)はマスク
cmap = plt.cm.YlGn.copy()
cmap.set_bad("lightgray") # 未観測セルは灰色で表示
fig, ax = plt.subplots(figsize=(7.5, 4.5))
im = ax.imshow(masked, cmap=cmap, vmin=0, vmax=1, aspect="auto")
ax.set_xticks(range(max_age + 1), [f"age{a}" for a in range(max_age + 1)])
ax.set_yticks(range(n_cohorts), [f"獲得月{c}" for c in range(n_cohorts)])
ax.set_xlabel("経過期間 age")
ax.set_ylabel("コホート(獲得月)")
ax.set_title("コホート・リテンション・ヒートマップ(灰色=未観測)")
for c in range(n_cohorts):
for a in range(max_age + 1):
if not np.isnan(ret[c, a]):
ax.text(a, c, f"{ret[c, a]:.2f}", ha="center", va="center", fontsize=9)
fig.colorbar(im, ax=ax, label="リテンション率")
fig.tight_layout()
plt.show()
ヒートマップでは、右へ行くほど薄くなる(age とともにリテンション低下)、下の行ほど age1 セルが濃い(最近コホートの改善)、そして**右上の灰色(未観測の三角)**という3つの読みが、色で直感的につかめます。実務のコホート表はこの形で、縦に積み上げて改善トレンドを追うのが定石です。
⚠️ よくある誤解
- 全体平均リテンションはコホート構成に依存する:新規を多く獲得した月は age0 の顧客が増え、全体平均が見かけ上高く出ます(mix shift)。定着を語るなら、全体平均ではなくコホート別に見ること。
- 行列は三角形で右下(未来)は欠測:新しいコホートは深い age を観測できません。age 方向に平均するとき、age ごとに母数(コホート数)が違うので、深い age の平均は少数のコホートに引きずられます。
- age 効果と cohort 効果を混同しない:「age とともに下がる」は加齢効果、「最近コホートが上がる」は vintage 効果。別の軸なので、片方の動きをもう片方のせいにしない(暦時間の効果も加えた age–period–cohort 分解は、3者が一次従属で識別が難しい——要最新確認)。
- コホートサイズが小さいと率がブレる:少人数コホートのリテンション率は二項のばらつきが大きく、改善・悪化の判断を誤らせます。母数(人数)を併記し、小さすぎるコホートは束ねるか保留にします。
関連ノート
- リテンションとチャーン(リテンションを獲得時期=コホートごとに観察する)
- 顧客生涯価値(LTV)(コホート別に LTV を出すと、獲得時期ごとの採算が分かる)
- 第2章 顧客価値の測定 目次
- 顧客を行動データでセグメントに分けるクラスタリングは機械学習テキストへ/本書では第6章 セグメンテーションで扱う
- マーケティング・サイエンス 全体目次