20. マルコフ連鎖
テーマ: 顧客状態遷移(RFMセグメント)の定常分布・吸収確率・顧客生涯価値
学習点: 行列の冪乗, 固有値問題, 線形方程式の求解, NumPy の線形代数
依存: NumPy / 難易度: 中級
実行方法
uv run 20_markov_chain.py
スクリプト冒頭の PEP 723 メタデータ(# /// script)により、必要なライブラリは
uv が自動的に仮想環境へ導入します。事前の pip install は不要です。
解説
何をするプログラムか
顧客を「優良・一般・休眠・離反」の 4 セグメントに分け、毎月の状態遷移をマルコフ連鎖でモデル化するプログラムです。CRM(顧客関係管理)で使う RFM 分析の発展形で、「離反」は一度入ると出られない吸収状態として扱います。
遷移行列 1 つから、(1) 顧客構成と月次利益が時間とともにどう変わるか、(2) 各セグメントの顧客が離反するまで平均何ヶ月とどまるか、(3) 割引現在価値で測った顧客生涯価値(CLV)はいくらか、(4) 休眠客の復活率を高める施策が CLV をどれだけ押し上げるか、を線形代数だけで導きます。マーケティング投資の採算判断に直結する計算です。
コードの読みどころ
- 遷移行列は
np.arrayで定義し、check()がnp.allclose(P.sum(axis=1), 1)で「各行の和は 1」という確率行列の条件をアサーションで検証します。データの前提をコードで明文化する作法です。 - 状態分布の推移はループ内の
x = x @ Pの 1 行です。行ベクトルに遷移行列を繰り返し掛けることが、実質的に行列の冪乗 P^n の計算になっています。 stationary_of()は定常分布 πP=π を「P の転置の固有値 1 に対応する固有ベクトル」としてnp.linalg.eig(P.T)で求め、pi / pi.sum()で確率に正規化します。固有値問題の実用例です。- 吸収状態の解析では、離反以外の 3×3 部分
Q = P[:3, :3]を切り出し、基本行列をN = np.linalg.inv(np.eye(3) - Q)で計算します。N.sum(axis=1)が離反までの期待月数です。 - CLV は割引率を組み込んだ
np.linalg.inv(np.eye(3) - Q / (1 + r_m)) @ MONTHLY_PROFIT[:3]で求めます。無限等比級数 Σ(Q/(1+r))^k の和を逆行列 1 回で計算しているのがポイントです。
理論的背景
既約かつ非周期的なマルコフ連鎖は一意の定常分布 π(πP=π, Σπ=1)を持ちます(Perron-Frobenius の定理)。本例の P は離反という吸収状態を含むため定常分布は「全員離反」に退化しますが、離反しない条件付きの連鎖に直せば意味のある定常分布が得られます。吸収マルコフ連鎖では、基本行列 N=(I-Q)^{-1} の (i,j) 成分が「状態 i から出発して j に滞在する期待回数」、行和 t=N·1 が吸収までの期待ステップ数を与えます。CLV の式はこの N に割引を加えた自然な拡張です。
実行結果の見方
状態分布の推移では、月次利益が 2 ヶ月目の 7,965 円をピークにその後減少し、36 ヶ月後には顧客の 81.4% が離反に吸収されることを確認してください。「長期的には全員離反」という構造が数字で見えます。
基本行列の対角成分(例: 優良客は優良状態に延べ 13.36 ヶ月滞在)と、離反までの期待月数(優良 25.3 ヶ月、休眠 16.8 ヶ月)は、セグメントごとの顧客維持力を表します。CLV は優良 27.5 万円、休眠 13.9 万円で、最後の施策分析では休眠→一般の復活率を 0.22→0.32 に上げると休眠客の CLV が +24.7% 増えます。この増分と施策コストを比べれば投資判断ができる、という実務への橋渡しで締めくくられています。
ソースコード
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "numpy",
# ]
# ///
"""20: マルコフ連鎖 -------------------------------------------------------
テーマ: 顧客状態遷移(RFMセグメント)の定常分布・吸収確率・顧客生涯価値
学習点: 行列の冪乗, 固有値問題, 線形方程式の求解, NumPy の線形代数
根拠: 既約かつ非周期的なマルコフ連鎖は一意の定常分布 π(πP=π, Σπ=1)を持つ
(Perron-Frobenius の定理)。吸収マルコフ連鎖では基本行列
N=(I-Q)^{-1} の (i,j) 成分が状態 i から出発して j に滞在する期待回数、
行和 t=N·1 が吸収までの期待ステップ数を与える。
"""
import numpy as np
np.set_printoptions(precision=4, suppress=True)
STATES = ["優良", "一般", "休眠", "離反"]
# 月次の遷移確率(行=現在, 列=翌月)。「離反」は吸収状態
P = np.array([
[0.80, 0.15, 0.04, 0.01],
[0.20, 0.62, 0.15, 0.03],
[0.03, 0.22, 0.60, 0.15],
[0.00, 0.00, 0.00, 1.00],
])
MONTHLY_PROFIT = np.array([18000.0, 6000.0, 500.0, 0.0])
def check(P):
assert np.allclose(P.sum(axis=1), 1), "各行の和は1でなければならない"
def stationary_of(P):
"""πP=π を固有値1の左固有ベクトルとして求める。"""
w, v = np.linalg.eig(P.T)
i = np.argmin(np.abs(w - 1))
pi = np.real(v[:, i])
return pi / pi.sum()
def main() -> None:
check(P)
print("[遷移行列 P](行=今月の状態, 列=翌月の状態)")
print(f"{'':<6}" + "".join(f"{s:>9}" for s in STATES))
for s, row in zip(STATES, P):
print(f"{s:<6}" + "".join(f"{x:>9.2f}" for x in row))
x0 = np.array([0.25, 0.45, 0.30, 0.0])
print(f"\n[状態分布の推移] 初期 {dict(zip(STATES, x0))}")
print(f"{'月':>4}" + "".join(f"{s:>9}" for s in STATES) + f"{'月次利益':>12}")
x = x0.copy()
for m in range(0, 37):
if m in (0, 1, 2, 3, 6, 12, 24, 36):
print(f"{m:>4}" + "".join(f"{v:>9.4f}" for v in x)
+ f"{x @ MONTHLY_PROFIT:>12,.0f}")
x = x @ P
print(" ※ 離反が吸収状態なので、長期的には全員が離反へ収束する。")
# 離反を除いた部分(既約な連鎖)の定常分布
Q = P[:3, :3]
P_sub = Q / Q.sum(axis=1, keepdims=True) # 離反しない条件付き遷移
pi = stationary_of(P_sub)
print(f"\n[離反しない場合の条件付き定常分布] "
f"{dict(zip(STATES[:3], np.round(pi, 4)))}")
print(f" 検算 πP = {np.round(pi @ P_sub, 4)}")
print("\n[吸収マルコフ連鎖の解析]")
N = np.linalg.inv(np.eye(3) - Q) # 基本行列
print(" 基本行列 N=(I-Q)^-1 : 状態 i から出発して j に滞在する期待月数")
print(f"{'':<6}" + "".join(f"{s:>9}" for s in STATES[:3]))
for s, row in zip(STATES[:3], N):
print(f"{s:<6}" + "".join(f"{x:>9.3f}" for x in row))
t = N.sum(axis=1)
print(f"\n 離反までの期待月数: "
+ ", ".join(f"{s}={v:.1f}ヶ月" for s, v in zip(STATES[:3], t)))
print("\n[顧客生涯価値 (CLV)] 割引率 年3% (月次換算)")
r_m = 1.03 ** (1 / 12) - 1
# 割引付き CLV: v = (I - Q/(1+r))^-1 · profit
Nd = np.linalg.inv(np.eye(3) - Q / (1 + r_m))
clv = Nd @ MONTHLY_PROFIT[:3]
clv_nodisc = N @ MONTHLY_PROFIT[:3]
for s, a, b in zip(STATES[:3], clv, clv_nodisc):
print(f" {s:<4} CLV(割引後) = {a:>12,.0f}円 / 割引なし {b:>12,.0f}円")
print("\n[施策の効果] 休眠→一般の復活率を 0.22 -> 0.32 に改善した場合")
P2 = P.copy()
P2[2, 1] += 0.10
P2[2, 2] -= 0.10
check(P2)
Q2 = P2[:3, :3]
clv2 = np.linalg.inv(np.eye(3) - Q2 / (1 + r_m)) @ MONTHLY_PROFIT[:3]
for s, a, b in zip(STATES[:3], clv, clv2):
print(f" {s:<4} {a:>12,.0f} -> {b:>12,.0f}円 ({b/a - 1:+.1%})")
print(" ※ 施策1ヶ月あたりのコストがこの増分を下回るかで投資判断ができる。")
if __name__ == "__main__":
main()
実行結果
[遷移行列 P](行=今月の状態, 列=翌月の状態)
優良 一般 休眠 離反
優良 0.80 0.15 0.04 0.01
一般 0.20 0.62 0.15 0.03
休眠 0.03 0.22 0.60 0.15
離反 0.00 0.00 0.00 1.00
[状態分布の推移] 初期 {'優良': np.float64(0.25), '一般': np.float64(0.45), '休眠': np.float64(0.3), '離反': np.float64(0.0)}
月 優良 一般 休眠 離反 月次利益
0 0.2500 0.4500 0.3000 0.0000 7,350
1 0.2990 0.3825 0.2575 0.0610 7,806
2 0.3234 0.3387 0.2238 0.1141 7,965
3 0.3332 0.3077 0.1980 0.1611 7,943
6 0.3216 0.2500 0.1508 0.2777 7,363
12 0.2555 0.1850 0.1074 0.4521 5,763
24 0.1497 0.1075 0.0621 0.6808 3,370
36 0.0873 0.0626 0.0362 0.8139 1,965
※ 離反が吸収状態なので、長期的には全員が離反へ収束する。
[離反しない場合の条件付き定常分布] {'優良': np.float64(0.4154), '一般': np.float64(0.3458), '休眠': np.float64(0.2389)}
検算 πP = [0.4154 0.3458 0.2389]
[吸収マルコフ連鎖の解析]
基本行列 N=(I-Q)^-1 : 状態 i から出発して j に滞在する期待月数
優良 一般 休眠
優良 13.357 7.723 4.232
一般 9.485 8.845 4.265
休眠 6.218 5.444 5.163
離反までの期待月数: 優良=25.3ヶ月, 一般=22.6ヶ月, 休眠=16.8ヶ月
[顧客生涯価値 (CLV)] 割引率 年3% (月次換算)
優良 CLV(割引後) = 275,013円 / 割引なし 288,882円
一般 CLV(割引後) = 213,927円 / 割引なし 225,929円
休眠 CLV(割引後) = 138,684円 / 割引なし 147,177円
[施策の効果] 休眠→一般の復活率を 0.22 -> 0.32 に改善した場合
優良 275,013 -> 302,300円 (+9.9%)
一般 213,927 -> 241,627円 (+12.9%)
休眠 138,684 -> 172,929円 (+24.7%)
※ 施策1ヶ月あたりのコストがこの増分を下回るかで投資判断ができる。