这次我们来看一个概率论里的经典反直觉模型:The Ergodicity Game(遍历性游戏)。它不依赖 GPU,不需要安装几十 GB 的模型,也不需要什么高端显卡。它只用一个简单的抛硬币游戏,就能演示一个在经济学、量化和风险决策里很容易被忽略的问题:为什么一个“每局数学期望为正”的游戏,玩家长期玩下去却几乎必然亏钱。
如果你之前接触过“期望值”和“长期复利”这两个概念,但是没想清楚它们什么时候等价、什么时候会分道扬镳,那么这篇文章就是为你准备的。下面我会从数学原理讲起,然后用 Python 完整复现这个游戏,对比“系综平均”和“时间平均”,再做批量蒙特卡洛模拟、参数化测试,最后验证凯利公式给出的最优下注比例。整个过程只需要 numpy 和 matplotlib,普通笔记本就能跑完。
1. 核心概念速览
| 项目类型 | 概率论教学游戏 / 遍历性经济学演示模型 |
|---|---|
| 核心思想 | 用简单随机过程说明时间平均不等于系综平均 |
| 经典玩法 | 抛硬币:50% 概率财富乘以 1.5,50% 概率财富乘以 0.6 |
| 运行环境 | Windows / macOS / Linux,任意支持 Python 的系统 |
| 硬件要求 | 纯 CPU 计算,无 GPU 需求 |
| 核心依赖 | Python 3.8+、numpy、matplotlib |
| 是否支持批量 | 支持,本质是多智能体向量化蒙特卡洛模拟 |
| 是否支持 API | 无固定 API,但可以封装为函数或本地服务 |
| 主要输出 | 系综平均曲线、单轨迹时间曲线、不同下注比例的增长率对比 |
| 适合场景 | 课堂教学、量化入门、经济学讨论、风险教育、凯利公式验证 |
需要说明的是,“The Ergodicity Game”在网络上存在多个教学实现版本,参数细节可能略有不同。本文采用的+50% / -40%抛硬币模型,是遍历性经济学研究中最常见、最经典的一组示例参数,适合作为通用复现基准。
2. 这个游戏到底在讲什么:数学背景与适用边界
2.1 什么是遍历性
一个随机过程如果具有遍历性,那么“对大量样本同时取平均”的结果,等于“让单个样本跑足够长时间取平均”的结果。换句话说,系综平均等于时间平均。
现实中很多物理过程近似具备这个性质,所以用“期望值”去预测长期结果是合理的。但我们的财富增长过程通常不具备这个性质。财富增长是乘性的:上一轮的收益会成为下一轮的本金,一个负收益造成的损失不会在下一轮自动补回来。这就导致了时间平均和系综平均的系统性偏离。
2.2 经典抛硬币游戏
设初始财富为 1 个单位,每一轮抛一枚公平硬币:
- 正面:财富乘以 1.5,即收益 +50%。
- 反面:财富乘以 0.6,即收益 -40%。
先算单轮期望:
E[X] = 0.5 × 1.5 + 0.5 × 0.6 = 1.05所以单轮期望收益是 +5%,看起来是一个值得长期玩下去的游戏。
但注意,财富是乘法累加。如果玩 n 轮,单个玩家的财富路径几乎必然收敛到:
(1.5)^(n/2) × (0.6)^(n/2) = (1.5 × 0.6)^(n/2) = (0.9)^(n/2)这里1.5 × 0.6 = 0.9,开根号得到sqrt(0.9) ≈ 0.9487。也就是说,每一轮的时间平均增长率大约只有 0.9487,小于 1。一个玩家一直玩下去,财富的中位数和众数会越来越小,最终趋近于 0。
这就出现了一个悖论:
- 所有人资产的算术平均:以每轮 5% 的速度指数增长。
- 单个玩家长期财富:以每轮约 -5.13% 的速度指数衰减。
为什么会这样?核心原因是少数极端幸运者把资产拉到了天文数字,导致“平均”被严重抬高。绝大多数玩家的财富却在中位数附近持续缩水。
2.3 适用场景与使用边界
这个游戏最适合三类人:
第一类是概率论和统计学初学者。它提供了一个非常具体的例子,说明“期望值为正”不等于“值得长期参与”,帮助建立对重尾分布、几何平均和随机乘性过程的直觉。
第二类是量化交易和资产配置相关从业者。复利环境下真正重要不是单期期望收益,而是对数收益的期望。这个游戏可以很直观地演示“波动拖累”问题。
第三类是金融教育和投资者教育内容创作者。用它来讲风险、长期投资和仓位管理,比直接抛理论公式更有说服力。
同时需要明确边界:这是一个教学模型,不是真实市场模型。真实的资产收益不是独立同分布,存在自相关、波动率聚集、极端事件和交易成本。因此,不要把这个游戏直接当成投资建议。文章中出现的任何公式和模拟结果都只用于说明数学性质,不构成任何形式的投资决策依据。
3. 环境准备与前置条件
整个复现过程不需要 GPU,也不需要深度学习框架。你需要准备的是:
- Python 3.8 或更高版本。
- numpy,用于向量化随机模拟。
- matplotlib,用于绘制曲线。
- 可选 Jupyter Notebook 或 VS Code,用于交互式运行。
安装依赖的命令如下:
pip install numpy matplotlib如果是在国内网络环境,可以指定国内镜像源加速:
pip install numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple环境检查:
python -c "import numpy; import matplotlib; print(numpy.__version__, matplotlib.__version__)"能正常输出版本号就说明环境没问题。如果提示模块不存在,回到上一步重新安装。
4. 用 Python 复现遍历性游戏
4.1 单玩家轨迹模拟
先写一个最基础的单玩家模拟函数,观察一条财富路径的发展趋势:
import numpy as np import matplotlib.pyplot as plt def simulate_single_path(steps=1000, bet_fraction=1.0, seed=42): rng = np.random.default_rng(seed) wealth = 1.0 history = [wealth] for _ in range(steps): if rng.random() < 0.5: wealth *= (1 + 0.5 * bet_fraction) else: wealth *= (1 - 0.4 * bet_fraction) history.append(wealth) return np.array(history) path = simulate_single_path(steps=1000, seed=42) print("最终财富:", path[-1]) print("时间平均增长率:", (path[-1]) ** (1 / 1000))运行后你会发现,单条路径的最终财富通常小得惊人,比如1e-22这种量级。时间平均增长率在 0.94 到 0.95 附近波动。这就是时间平均小于 1 的直接体现。
4.2 多玩家系综模拟
再看系综平均。模拟大量玩家同时玩 1000 轮,每一轮都使用向量化操作更新所有玩家的财富:
def simulate_ensemble(n_agents=10000, steps=1000, seed=42): rng = np.random.default_rng(seed) wealth = np.ones(n_agents) ensemble_mean = np.zeros(steps + 1) ensemble_median = np.zeros(steps + 1) ensemble_mean[0] = 1.0 ensemble_median[0] = 1.0 for step in range(1, steps + 1): wins = rng.random(n_agents) < 0.5 wealth[wins] *= 1.5 wealth[~wins] *= 0.6 ensemble_mean[step] = wealth.mean() ensemble_median[step] = np.median(wealth) return ensemble_mean, ensemble_median mean_path, median_path = simulate_ensemble(n_agents=10000, steps=1000, seed=42) print("第1000轮系综平均:", mean_path[-1]) print("第1000轮系综中位数:", median_path[-1])这一步是整个游戏最关键的地方。你会在输出中看到两个完全不同的数字:
- 系综平均非常大,理论上是
1.05^1000 ≈ 5.4e21,实际模拟在指数爆炸后可能更大。 - 系综中位数非常小,接近
0.9487^1000 ≈ 1e-23。
4.3 画图对比
把两条曲线画到一张图里,方便直观对比:
plt.figure(figsize=(10, 6)) steps_range = np.arange(len(mean_path)) plt.plot(steps_range, mean_path, label="系综平均 (10000人)", linewidth=1.5) plt.plot(steps_range, median_path, label="系综中位数 (10000人)", linewidth=1.5) plt.yscale("log") plt.xlabel("轮次") plt.ylabel("财富 (对数坐标)") plt.title("遍历性游戏:系综平均 vs 系综中位数") plt.legend() plt.grid(True, which="both", linestyle="--", alpha=0.6) plt.show()注意这里我用了对数坐标,否则均值曲线会直接冲出画布,而中位数曲线会贴在地板上,什么都看不清。从图上可以清楚看到两条线像剪刀一样张开,这就是“期望值为正但个体长期必输”的可视化证据。
5. 功能测试与效果验证
下面把验证拆成几个明确的测试用例。每个用例都给出输入、操作、预期结果和判断标准,方便你照着跑。
5.1 测试一:单轨迹长跑
- 测试目的:验证单个玩家长期玩这个游戏,财富是否会收敛到 0。
- 输入参数:
steps=10000,bet_fraction=1.0,seed=42。 - 操作步骤:运行
simulate_single_path,打印最终财富。 - 预期结果:最终财富在
1e-200甚至更小的量级,时间平均增长率约0.9487。 - 判断标准:最终财富远小于 1,且时间平均增长率小于 1,说明单玩家长期必亏。
- 常见失败原因:如果使用了
float32精度,在指数衰减到极小时可能出现下溢为 0,建议使用默认的float64。
5.2 测试二:系综平均与时间平均分叉
- 测试目的:验证系综平均和时间平均不再相等。
- 输入参数:
n_agents=10000,steps=1000。 - 操作步骤:同时运行
simulate_ensemble,比较均值和中位数。 - 预期结果:均值随轮次单调爆炸式增长,中位数持续衰减。
- 判断标准:两条曲线在对数坐标下呈对称张开趋势,说明过程不具备遍历性。
5.3 测试三:不同下注比例的影响
- 测试目的:验证“把全部资产押上去”并不是唯一玩法,降低下注比例可以改变时间平均增长率。
- 输入参数:
bet_fraction从 0 到 1 依次取 0.1、0.25、0.5、1.0。 - 操作步骤:对每个比例跑多路径模拟,计算对数增长率的理论值:
fractions = np.linspace(0, 1, 101) growth_rates = 0.5 * np.log1p(0.5 * fractions) + 0.5 * np.log1p(-0.4 * fractions) for f, g in zip([0.1, 0.25, 0.5, 1.0], [0.5 * np.log1p(0.5 * x) + 0.5 * np.log1p(-0.4 * x) for x in [0.1, 0.25, 0.5, 1.0]]): print(f"下注比例 {f:.2f}: 单轮对数增长率 {g:.6f}, 等效乘数 {np.exp(g):.4f}")- 预期结果:下注比例在 0.25 附近时对数增长率由负转正。
- 判断标准:增长率先升后降,存在一个最大值点,这个点就是凯利比例。
5.4 测试四:玩家数量对系综平均的影响
- 测试目的:验证增加玩家数量不会消除非遍历性,只会让系综平均更稳定地爆炸。
- 输入参数:
n_agents分别取 100、1000、10000。 - 操作步骤:分别运行
simulate_ensemble,打印最终均值。 - 预期结果:三人均值都在
1e21以上量级,且玩家数量越大,均值越接近理论值1.05^1000。 - 判断标准:均值不随玩家数量下降,中位数始终远小于均值,说明这不是“样本量不够”造成的偏差。
5.5 测试五:改成不利赔率
- 测试目的:验证游戏对赔率参数高度敏感。
- 输入参数:把正面收益从 +50% 改成 +30%,反面损失仍为 -40%。
- 操作步骤:修改乘法因子为
1.3和0.6后重新跑系综模拟。 - 预期结果:单轮期望变为
0.95,系综平均也开始衰减。 - 判断标准:均值和中位数同时下降,原因明确,逻辑自洽。
6. 批量蒙特卡洛模拟与参数化测试
遍历性游戏最有价值的地方在于,它可以作为“批量随机模拟”的教学载体。你可以一次性扫描上百组参数,观察增长率曲面。
6.1 参数扫描模板
def compute_growth_rate(win_prob=0.5, win_gain=0.5, lose_loss=0.4, fractions=None): if fractions is None: fractions = np.linspace(0, 1, 101) with np.errstate(divide='ignore', invalid='ignore'): growth = (win_prob * np.log1p(win_gain * fractions) + (1 - win_prob) * np.log1p(-lose_loss * fractions)) return fractions, growth fractions, growth = compute_growth_rate() best_idx = np.nanargmax(growth) print(f"最大增长率出现在下注比例 {fractions[best_idx]:.2f}") print(f"最大对数增长率 {growth[best_idx]:.6f}")这段代码直接用向量化方式扫描 101 个下注比例,不需要循环,秒出结果。理论输出是 0.25 附近达到最大增长率,对应凯利公式的解。
6.2 批量任务设计
如果你要跑数千组参数,建议把任务设计成可重复、可断点续跑的结构:
import json import time def batch_experiment(params_list, save_path="results.json"): results = [] for params in params_list: start = time.time() _, growth = compute_growth_rate( win_prob=params["win_prob"], win_gain=params["win_gain"], lose_loss=params["lose_loss"] ) best_idx = int(np.nanargmax(growth)) results.append({ "win_prob": params["win_prob"], "win_gain": params["win_gain"], "lose_loss": params["lose_loss"], "best_fraction": float(fractions[best_idx]), "max_log_growth": float(growth[best_idx]), "elapsed_sec": round(time.time() - start, 4) }) with open(save_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) return results批量实验有几个工程化要点:
- 参数列表存放在 JSON 或 YAML 配置文件中,不要硬编码在脚本里。
- 每个实验记录随机种子、参数组合、结果和耗时,方便复现。
- 大任务建议分批执行,把结果追加写盘,避免跑了一半程序崩溃导致所有结果丢失。
- 遇到异常参数(比如负收益导致
log报错),使用np.errstate捕获,不要让单个实验中断整体任务。
6.3 接口化封装
虽然原项目没有固定 API,但你可以把游戏核心封装成函数,再包一层 HTTP 服务,供本地工具调用:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class GameRequest(BaseModel): win_prob: float = 0.5 win_gain: float = 0.5 lose_loss: float = 0.4 n_agents: int = 10000 steps: int = 1000 seed: int = 42 class GameResponse(BaseModel): ensemble_mean_final: float ensemble_median_final: float @app.post("/simulate", response_model=GameResponse) def simulate(req: GameRequest): mean_path, median_path = simulate_ensemble( n_agents=req.n_agents, steps=req.steps, seed=req.seed ) return GameResponse( ensemble_mean_final=float(mean_path[-1]), ensemble_median_final=float(median_path[-1]) )启动命令:
uvicorn main:app --host 127.0.0.1 --port 8000调用方式:
import requests resp = requests.post("http://127.0.0.1:8000/simulate", json={ "win_prob": 0.5, "win_gain": 0.5, "lose_loss": 0.4, "n_agents": 10000, "steps": 1000, "seed": 42 }, timeout=30) print(resp.json())如果你只是学习概念,不必引入 FastAPI;但如果你想把游戏集成到教学平台或内部工具里,这种封装方式很实用。
7. 资源占用与性能观察
这个模型是纯 CPU 计算,资源占用很低,但仍然有一些值得注意的性能点。
7.1 显存与内存
游戏不涉及 GPU,所以不存在显存问题。内存方面,如果你像 4.2 节那样用循环更新所有玩家的财富,内存占用只取决于n_agents这个一维数组的大小。10000 个float64大约只需要 80KB,可以忽略不计。
真正吃内存的是“保存所有人每个时间步的财富路径”。如果在循环里不断把中间结果 append 到列表,10000 人乘 1000 步,就是10000 × 1000个浮点数,约 80MB。如果扩大到 100 万人乘 10000 步,就是 8GB 以上,普通机器很容易卡死。
建议做法是:只在需要画图时保存均值/中位数这类聚合指标,不保存完整路径;如果需要完整路径用于分析分位数,可以周期性抽样保存,或者改用np.memmap写盘。
7.2 计算耗时
对 10000 个玩家跑 1000 轮,使用向量化操作,在普通 CPU 上通常只需要几十毫秒到几百毫秒。瓶颈主要发生在:
- Python 循环次数:如果写成逐玩家、逐轮的双层循环,速度会慢几个数量级。
- 随机数生成:
rng.random(n_agents)是向量化操作,效率很高;不要每次循环里再生成单个随机数。 np.median在每轮调用会有排序开销,玩家数量大时可以改为每 10 轮或 50 轮记录一次中位数。
7.3 如何降低内存与加速
# 推荐:流式计算,不保存完整路径 def simulate_ensemble_streaming(n_agents=100000, steps=10000, seed=42): rng = np.random.default_rng(seed) wealth = np.ones(n_agents) sample_points = np.linspace(0, steps, 20, dtype=int) snapshot = {} for step in range(1, steps + 1): wins = rng.random(n_agents) < 0.5 wealth[wins] *= 1.5 wealth[~wins] *= 0.6 if step in sample_points: snapshot[int(step)] = { "mean": float(wealth.mean()), "median": float(np.median(wealth)), "p10": float(np.percentile(wealth, 10)), "p90": float(np.percentile(wealth, 90)) } return snapshot这种流式写法在 10 万玩家、10000 轮规模下依然稳定。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 每次运行结果都不一样 | 未固定随机种子 | 检查代码里是否调用np.random.default_rng(seed) | 显式传入固定 seed,并记录到结果文件 |
| 均值巨大而中位数极小 | 非遍历性,属正常现象 | 检查是否打印了中位数 | 不要只报告均值,要同时输出中位数和分位数 |
log或log1p报 NaN | 下注比例导致1 - 0.4 * f出现负数 | 检查fractions是否超过 2.5 | 限制下注比例在[0, 1]区间,或使用np.clip |
| 内存占用过高 | 保存了完整财富路径 | 查看代码中是否把所有历史 append 到列表 | 改为只保存聚合指标或抽样快照 |
| 中文标签显示为方框 | matplotlib 字体缺少中文字体 | 查看系统字体列表 | 设置中文字体后再绘图 |
| 图中均值曲线消失 | 未使用对数坐标,均值数量级远超纵轴范围 | 查看plt.yscale设置 | 改为plt.yscale("log") |
| 批量任务中途崩溃,结果丢失 | 没有断点续跑和结果写盘 | 检查批量脚本是否批量写 JSON | 每完成一个实验立即追加写盘,记录进度 |
| 模拟结果和理论值偏差过大 | 随机种子或样本量不足 | 对比理论公式E[X] = 1.05^n与sqrt(1.5*0.6)^n | 增大n_agents到 100000 以上再跑一次 |
9. 最佳实践与使用建议
9.1 教学场景建议
如果你准备用这个游戏给学生或读者讲概率论,建议按“先猜后跑”的顺序设计活动:
- 先让观众口头回答:这个游戏每轮期望是正还是负?你愿意把全部身家押进去吗?
- 再让观众估计:玩 100 轮后,10000 个玩家中大约多少人盈利?
- 最后运行模拟,比较直觉和结果。
这种方式比直接给公式更能冲击认知。大约会有相当比例的观众凭“期望为正”认为值得玩,但模拟结果会告诉他们,绝大多数玩家在 100 轮后已经所剩无几。
9.2 工程化建议
- 所有实验脚本必须固定随机种子,并在结果文件中记录 seed、参数、numpy 版本,保证可复现。
- 模型文件、输入参数、模拟结果分目录管理,比如
configs/、results/、figures/。 - 批量参数扫描使用向量化写法,避免 Python 双层循环。
- 如果封装成接口服务,必须限制访问范围,只绑定
127.0.0.1或内网地址,避免公网裸奔。 - 对输出结果做可视化检查时,优先使用对数坐标,否则均值和中位数会互相掩盖。
9.3 合规与边界提醒
这个游戏用数学方式揭示了一个重要事实:在乘性随机过程中,单期期望收益不能直接等同于长期增长。这个概念在投资教育中有一定价值,但它不等于真实市场模型,更不能作为买卖依据。
如果要把游戏用于投资教育或金融相关内容,需要注意:
- 明确标注这是简化教学模型,不包含交易成本、税费、流动性风险和黑天鹅事件。
- 不要使用“稳赚”“必然”“最佳策略”等绝对化表述。
- 涉及具体金融产品、资产配置或个人投资建议时,应避免给出确定性结论。
- 引用他人的教学代码、图表或论文内容时,注明出处并遵守版权规定。
10. 总结与下一步
这个项目最值得尝试的一点,是它用最简单的方式击穿了“期望值为正就值得做”的常识。你不需要昂贵的计算设备,不需要安装乱七八糟的依赖,只需要一个 Python 环境,就能亲眼看到系综平均和时间平均的剪刀差。
我建议第一次跑通时,先做两件事:第一,跑 4.2 节的系综模拟,同时打印均值和分位数;第二,跑 6.1 节的参数扫描,找到最优下注比例。这两步加起来不超过五分钟,但你对“长期复利”“波动拖累”“期望值陷阱”这三个概念的理解会明显不一样。
接下来可以扩展的方向包括:
- 加入凯利公式的严格推导,比较理论最优值和数值扫描结果。
- 把正反面概率改得不均匀,观察概率偏差对最优仓位的影响。
- 引入每次只下注固定金额而不是固定比例,对比“加法下注”和“乘法下注”的区别。
- 把模型扩展到多资产组合,模拟再平衡对长期增长率的影响。
- 如果面对的是普通读者,可以把模拟结果导出成图表,做成互动式教学页面。
如果你也想在自己的电脑上完整复现一遍,直接复制第 4 节的代码,安装好 numpy 和 matplotlib 就能开始。建议收藏备用,后续研究凯利公式和遍历性经济学时,这份模拟代码可以直接当测试底座。