# 教材位置：第10章，第10.6节；图 10-4。
# 三状态用户留存的教学模型；P 为人为设定，不代表真实企业用户。
# 状态顺序：活跃、沉默、流失；流失为吸收态，模型不允许流失后回流。
# P 形状 (3,3)，x0 为 (3,)，distribution 为 (13,3)，含当前第 0 期。
# 时间按期计，比例无量纲；基本矩阵给出流失前非流失期数的期望。
# 学习目标：区分“活跃率”与“尚未流失率”，解释有限期及长期结果。
# 本例无随机抽样；执行 python e40_customer_retention.py 保存两面板图。

import numpy as np
import matplotlib.pyplot as plt
from plot_style import configure_chinese_plot

P = np.array([[0.75, 0.20, 0.05], [0.30, 0.60, 0.10], [0.0, 0.0, 1.0]])
x0 = np.array([0.8, 0.2, 0.0])
steps = 12
assert np.all(P >= 0) and np.allclose(P.sum(axis=1), 1)
assert np.array_equal(P[2], [0, 0, 1])
distribution = np.empty((steps + 1, 3))
distribution[0] = x0
# 行概率向量右乘 P；同一矩阵用于每一步是时间齐次假设。
for step in range(steps):
    distribution[step + 1] = distribution[step] @ P
active_rate = distribution[:, 0]
# 沉默者尚未流失，但不计为活跃者；二者不能用同一个业务指标代替。
retained_rate = distribution[:, :2].sum(axis=1)
# 分布总和是守恒检查；未流失率不增来自本例没有吸收后的回流。
assert np.allclose(distribution.sum(axis=1), 1)
assert np.all(active_rate <= retained_rate)
assert np.all(np.diff(retained_rate) <= 1e-12)
assert np.allclose(distribution[-1], x0 @ np.linalg.matrix_power(P, steps))

# Q 只保留两个暂态：活跃与沉默。其谱半径小于 1，最终必然吸收。
Q = P[:2, :2]
spectral_radius = np.max(np.abs(np.linalg.eigvals(Q)))
assert spectral_radius < 1
# F=I+Q+Q²+…=(I−Q)^(-1)；用求解线性方程避免显式求逆。
fundamental = np.linalg.solve(np.eye(2) - Q, np.eye(2))
expected_periods = fundamental @ np.ones(2)
initial_expected = x0[:2] @ expected_periods
# F_ij 为从暂态 i 出发、吸收前访问暂态 j 的期望次数，含当前期。
# 因每个未流失期后走一步，expected_periods 也等于首次流失的步数期望。
assert np.allclose(fundamental, [[10, 5], [7.5, 6.25]])
assert np.allclose(expected_periods, [15, 13.75])
assert np.isclose(initial_expected, 14.75)
long_run = x0 @ np.linalg.matrix_power(P, 1000)
assert np.allclose(long_run, [0, 0, 1], atol=1e-12)
print("第 12 期三状态比例：", distribution[-1])
print("第 12 期活跃率、未流失率：", active_rate[-1], retained_rate[-1])
print("暂态 Q 的谱半径：", spectral_radius)
print("基本矩阵：\n", fundamental)
print("从活跃、沉默出发的流失前非流失期数期望（含当前期）：", expected_periods)
print("初始混合人群的期望非流失期数：", initial_expected)
print("本吸收模型的长期比例：", long_run)

# 有限期图仍可能有较高留存，不能据此宣称长期有稳定活跃用户。
# 长期全流失是吸收规则的数学后果，不是某企业必然发生的现实预测。
configure_chinese_plot()
fig, axes = plt.subplots(1, 2, figsize=(6.3, 3.2), constrained_layout=True)
times = np.arange(steps + 1)
for j, name in enumerate(["活跃", "沉默", "流失"]):
    axes[0].plot(times, distribution[:, j], marker="o", ms=3, label=name)
axes[0].set(title="三状态每期分布", xlabel="观察周期数", ylabel="人群比例", ylim=(0, 1))
axes[1].plot(times, active_rate, marker="o", ms=3, label="活跃率")
axes[1].plot(times, retained_rate, marker="s", ms=3, label="未流失率")
axes[1].set(title="沉默用户仍属未流失", xlabel="观察周期数",
            ylabel="人群比例", ylim=(0, 1))
for ax in axes:
    ax.legend(fontsize=8)
    ax.grid(alpha=0.2)
fig.savefig("customer_retention.png", dpi=180, bbox_inches="tight")
plt.close(fig)
