12.2 判定经济「收敛」,但收敛是在一组特定参数下成立的。参数改一改还收敛吗?上线之后经济会不会悄悄跑偏?这两个问题一个属于上线前、一个属于上线后,这一节把它们各自做成一门可执行的检查。
通用经济框架(经济平衡长文 )会告诉你「要关注通胀」;这一节给你的是 SLG 大地图口径下的两个可跑的诊断器:一个在上线前扫参数、一个在上线后读曲线。
两个问题,一前一后
- 上线前——谁最危险? 参数不是一个一个孤立调的。把产出基速调高 20% 和把消耗增长调高 20%,对稳态存量的影响可能差两个数量级。敏感性扫描就是先找出「动一下就翻车」的参数。
- 上线后——经济有没有跑偏? 线上真实存量曲线永远带噪声,人眼看不出缓慢漂移。漂移诊断用最小二乘把斜率抠出来,把「感觉有点通胀」变成「每天漂移 0.0378%」。
参数敏感性:谁最危险
做法是单参数扫描:每次只把一个参数改 ±20%,其余不动,各跑一遍 120 天闭环,量稳态存量的摆幅。两个指标:
- 摆幅
(存量(+20%) − 存量(−20%)) / 存量(基准),符号表示方向。 - 弹性
|摆幅| / 参数摆幅,把「参数动 1% 存量动几%」标准化,便于横向比较。
对增长型参数(prodGrowth / consGrowth)还有一个陷阱:改过头会让产出增速超过消耗增速(r_p ≥ r_c),此时根本不存在有限稳态,存量无界增长——摆幅会飙到几万个百分点。
敏感性扫描脚本
下面这段是 S 层敏感性扫描脚本,全程在内存里构造参数副本,不改动 config/ 下的任何配置文件。
# file: sim/ch12_sensitivity.py
# tier: S
# verify: python3 sim/ch12_sensitivity.py --config config/ --out out/
"""参数敏感性(12.3,上半):单参数扫描排出「最危险的三个参数」。
对每个关键参数做 ±20% 的单参数扫描(其余不动),各跑一遍闭环仿真,测稳态存量的
变化幅度。弹性 = 存量摆幅% / 参数摆幅%。growth 型参数还会触发「无稳态」的阈值效应。
产出:out/ch12_sensitivity.json + out/ch12_sensitivity.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch12_stock_flow import load_params # noqa: E402
from ch12_loop_sim import mulberry32, make_players, run_loop # noqa: E402
def build_variant(cfg_dir, param, delta_pct):
"""按参数名构造一份被扰动 (100+delta_pct)% 的 (sf, b, cons, act) 副本,不改 config。"""
sf, b = load_params(cfg_dir)
b, cons = dict(b), dict(sf["consumption"])
f = (100 + delta_pct) / 100.0
act = 100
if param == "prodBase":
b["outputBase"] = round(b["outputBase"] * f)
elif param == "prodGrowth":
b["outputGrowthNum"] = round(b["outputGrowthNum"] * f)
elif param == "consBase":
cons["base"] = round(cons["base"] * f)
elif param == "consGrowth":
cons["growthNum"] = round(cons["growthNum"] * f)
elif param == "activity":
act = 100 + delta_pct
else:
raise ValueError(param)
return sf, b, cons, act
def has_steady_state(b, cons):
"""产出增长不慢于消耗则无有限稳态(rp >= rc),存量无界增长。"""
return (b["outputGrowthNum"] / b["outputGrowthDen"]) < (cons["growthNum"] / cons["growthDen"])
def run_once(cfg_dir, param, delta_pct, days, n, seed, jitter):
sf, b, cons, act = build_variant(cfg_dir, param, delta_pct)
sf = dict(sf, _dailyJitterPct=jitter)
rng = mulberry32(seed)
players = make_players({"players": n, "activityHoursMin": 10, "activityHoursMax": 20,
"pacePctMin": 70, "pacePctMax": 130}, rng)
rows = run_loop(sf, b, players, days, rng, act_scale_pct=act, cons=cons)
return sum(r["stock"] for r in rows if r["day"] >= days - 10) / 11, has_steady_state(b, cons)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
cfg = json.load(open(os.path.join(args.config, "ch12_sensitivity.json"), encoding="utf-8"))
loop = json.load(open(os.path.join(args.config, "ch12_loop.json"), encoding="utf-8"))
sweep, days, n, seed, jitter = cfg["sweepPct"], cfg["days"], cfg["players"], cfg["seed"], loop["dailyJitterPct"]
results = []
for param in cfg["params"]:
base, _ = run_once(args.config, param, 0, days, n, seed, jitter)
up, up_ok = run_once(args.config, param, +sweep, days, n, seed, jitter)
down, down_ok = run_once(args.config, param, -sweep, days, n, seed, jitter)
swing = round(100.0 * (up - down) / base, 2) if base else 0.0
results.append({"param": param, "baseStock": round(base, 2), "stockDown20": round(down, 2),
"stockUp20": round(up, 2), "swingPct": swing,
"elasticity": round(abs(swing) / (2 * sweep) * 100, 3),
"steadyStateUp": up_ok, "steadyStateDown": down_ok,
"thresholdParam": not (up_ok and down_ok)})
ranked = sorted(results, key=lambda r: abs(r["swingPct"]), reverse=True)
for i, r in enumerate(ranked):
r["rank"] = i + 1
report = {"version": 1, "sweepPct": sweep, "days": days, "players": n, "seed": seed,
"params": results, "ranking": [r["param"] for r in ranked],
"top3": [r["param"] for r in ranked[:3]]}
os.makedirs(args.out, exist_ok=True)
json.dump(report, open(os.path.join(args.out, "ch12_sensitivity.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "ch12_sensitivity.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["rank", "param", "baseStock", "stockDown20", "stockUp20", "swingPct",
"elasticity", "steadyStateUp", "steadyStateDown", "thresholdParam"])
for r in ranked:
w.writerow([r["rank"], r["param"], r["baseStock"], r["stockDown20"], r["stockUp20"],
r["swingPct"], r["elasticity"], r["steadyStateUp"], r["steadyStateDown"],
r["thresholdParam"]])
print(f"单参数扫描 ±{sweep}%(稳态存量摆幅):")
for r in ranked:
flag = " ← 阈值参数:一侧已无稳态" if r["thresholdParam"] else ""
print(f" #{r['rank']} {r['param']:<11} {r['stockDown20']:.0f} → {r['baseStock']:.0f} → "
f"{r['stockUp20']:.0f} 摆幅 {r['swingPct']}% 弹性 {r['elasticity']}{flag}")
print(f"最危险的三个参数:{', '.join(report['top3'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出(同机相对量级,绝对数值不可移植):
单参数扫描 ±20%(稳态存量摆幅):
#1 prodGrowth 433184 → 1355375 → 331292659 摆幅 24410.91% 弹性 61027.275 ← 阈值参数:一侧已无稳态
#2 consGrowth 79880386 → 1355375 → 488383 摆幅 -5857.57% 弹性 14643.925 ← 阈值参数:一侧已无稳态
#3 prodBase 695708 → 1355375 → 2510023 摆幅 133.86% 弹性 334.65
#4 consBase 2549082 → 1355375 → 791259 摆幅 -129.69% 弹性 324.225
#5 activity 1084300 → 1355375 → 1626450 摆幅 40.0% 弹性 100.0
最危险的三个参数:prodGrowth, consGrowth, prodBase
(此扫描用 120 天 / 200 玩家,所以基准存量 1355375 与 12.2 的 180 天 / 300 玩家不同——弹性是相对量,不受规模影响。)
最危险的三个参数
| 排名 | 参数 | −20% 存量 | 基准 | +20% 存量 | 摆幅 | 弹性 |
|---|---|---|---|---|---|---|
| #1 | prodGrowth | 433,184 | 1,355,375 | 331,292,659 | 24,410.91% | 61,027.3 |
| #2 | consGrowth | 79,880,386 | 1,355,375 | 488,383 | −5,857.57% | 14,643.9 |
| #3 | prodBase | 695,708 | 1,355,375 | 2,510,023 | 133.86% | 334.7 |
| #4 | consBase | 2,549,082 | 1,355,375 | 791,259 | −129.69% | 324.2 |
| #5 | activity | 1,084,300 | 1,355,375 | 1,626,450 | 40.0% | 100.0 |
读法有三条:
- 前两名是「相变」不是「敏感」。
prodGrowth调高 20% 后r_p ≥ r_c,稳态消失,存量一路涨到 3.3 亿;调低 20% 则增速变慢、存量掉到 43 万。这不是平滑放大,是跨过临界点换了一套物理规律。数值策划要盯的是这条临界线,不是那个百分比。 - 基速参数是「可预期」的敏感。
prodBase弹性 334.7:基速动 1%,稳态存量动约 3.3%。方向直觉(产出↑→存量↑、消耗↑→存量↓)完全成立,可以放心用线性外推估影响。 - 活动强度反而最不敏感。
activity弹性正好 100.0——玩家多在线 20% 同时抬高产出与消耗,两头抵消,净影响只有 40%。双刃参数天然自稳,这也是为什么活动时长适合做运营调节旋钮。
漂移诊断:上线后怎么发现经济跑偏
有了敏感性扫描,上线前你知道该盯哪几个参数;上线后,你要从一条带噪声的存量曲线里判断经济在往哪跑。做法是手写最小二乘拟合:
- 斜率 = 每天的净漂移量(资源/天)。
- 斜率 / 均值 = 每日漂移百分比(%/天)。
- 按 %/天 与阈值比较,判
inflation/deflation/steady。
关键设计是用百分比而不是绝对值当判据:同样的「760/天」在百万级存量上是稳态,在十万级存量上就是通胀。绝对值阈值随规模漂移,百分比口径才可移植。
诊断核心:最小二乘 + 百分比判定
# snippet: sim/ch12_drift.py 的核心(完整文件见工作区)
def least_squares(xs, ys):
"""手写最小二乘:返回 (斜率, 截距, R²)。不依赖 numpy。"""
n = len(xs)
mx, my = sum(xs) / n, sum(ys) / n
sxx = sum((x - mx) ** 2 for x in xs)
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
slope = sxy / sxx if sxx else 0.0
intercept = my - slope * mx
ss_tot = sum((y - my) ** 2 for y in ys)
ss_res = sum((y - (slope * x + intercept)) ** 2 for x, y in zip(xs, ys))
return slope, intercept, (1.0 - ss_res / ss_tot if ss_tot else 0.0)
def classify_pct(pct, threshold_pct):
"""按「每日漂移百分比」判定:绝对值阈值随存量规模漂移,百分比口径更可移植。"""
return "inflation" if pct > threshold_pct else ("deflation" if pct < -threshold_pct else "steady")
def diagnose(name, xs, ys, thr_pct):
slope, intercept, r2 = least_squares(xs, ys)
mean = sum(ys) / len(ys)
pct = 100.0 * slope / mean if mean else 0.0
return {"name": name, "n": len(xs), "driftPerDay": round(slope, 3),
"driftPctPerDay": round(pct, 5), "classificationPct": classify_pct(pct, thr_pct),
"r2": round(r2, 4), "intercept": round(intercept, 2)}
诊断器跑两个案例:阳性对照(注入已知漂移的序列,看能不能反推回来)和真实闭环(读 out/ch12_loop_sim.csv 的第 100 天之后平台段):
阳性对照:注入漂移 320/天 → 反推 326.017/天(误差 6.017)判定 inflation R²=0.9803
真实闭环平台段:漂移 760.449/天(0.03778%/天)判定 steady R²=0.6924
两个数字讲了一个关键区别:阳性对照的绝对漂移是 320/天,被判 inflation;真实闭环平台段的绝对漂移高达 760/天,却被判 steady。原因就在百分比口径——真实平台的存量在 200 万量级,760/天只占 0.0378%/天,远低于 0.1% 的阈值。如果只看绝对值,这个健康的平台会被误报成重度通胀。
B 基准:诊断器的检测基准
一个诊断器不能只在一个注入值上「碰巧对」。扫一组已知漂移(含正、负、零),逐一反推,量两个指标:反推误差、方向判定是否正确。
# file: sim/ch12_drift_bench.py
# tier: B
# verify: python3 sim/ch12_drift_bench.py --config config/
"""漂移诊断的检测基准(B 层,软告警不硬失败)。
诊断器不能只在一个注入值上「碰巧对」。扫一组已知漂移(含正、负、零),逐一反推,
量两个指标:漂移速率的反推误差、方向判定是否与真值一致。任一方向判错、或反推误差
超容差,只告警不硬失败——真实序列还有活动噪声。产出:out/ch12_drift_bench.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ch12_drift import gen_series, diagnose, classify_pct # noqa: E402
INJECTS = [-600, -200, 0, 200, 600]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "config"))
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "out"))
args = ap.parse_args()
cfg = json.load(open(os.path.join(args.config, "ch12_drift.json"), encoding="utf-8"))
thr = cfg["classificationThresholdPctPerDay"]
rows, max_err, mis = [], 0.0, 0
for injected in INJECTS:
xs, ys = gen_series(dict(cfg, injectedDriftPerDay=injected))
d = diagnose("inj", xs, ys, thr)
err = round(d["driftPerDay"] - injected, 3)
exp = classify_pct(100.0 * injected / cfg["startStock"], thr)
ok = d["classificationPct"] == exp
mis += 0 if ok else 1
max_err = max(max_err, abs(err))
rows.append({"injected": injected, "recovered": d["driftPerDay"], "error": err,
"expected": exp, "got": d["classificationPct"], "ok": ok, "r2": d["r2"]})
rep = {"version": 1, "cases": rows, "maxAbsErrorPerDay": round(max_err, 3),
"misclassified": mis, "errorWarnPerDay": 60.0}
os.makedirs(args.out, exist_ok=True)
json.dump(rep, open(os.path.join(args.out, "ch12_drift_bench.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
print("注入 → 反推 → 判定:")
for r in rows:
print(f" {r['injected']:>5}/天 → {r['recovered']:>8.1f}/天 误差 {r['error']:>7.1f} "
f"{r['expected']} / {r['got']} {'OK' if r['ok'] else 'MISS'}")
print(f"最大反推误差 {rep['maxAbsErrorPerDay']}/天 方向判错 {mis} 例")
if mis:
print(f"WARN 有 {mis} 例方向判错:诊断器在阈值附近不稳")
if rep["maxAbsErrorPerDay"] > rep["errorWarnPerDay"]:
print(f"WARN 最大反推误差 {rep['maxAbsErrorPerDay']}/天 超过容差 {rep['errorWarnPerDay']}/天")
return 0
if __name__ == "__main__":
sys.exit(main())
注入 → 反推 → 判定:
-600/天 → -594.0/天 误差 6.0 deflation / deflation OK
-200/天 → -194.0/天 误差 6.0 deflation / deflation OK
0/天 → 6.0/天 误差 6.0 steady / steady OK
200/天 → 206.0/天 误差 6.0 inflation / inflation OK
600/天 → 606.0/天 误差 6.0 inflation / inflation OK
最大反推误差 6.017/天 方向判错 0 例
五例全部方向正确,最大反推误差 6.017/天——这个 6/天的误差就是 noisePct=4% 注入的噪声带来的估计偏差,与漂移真值无关(−600 到 +600 误差都稳定在 6 左右,说明估计是无偏的)。基准的价值:诊断器不是「碰巧对了一个值」,而是在正负两个方向上都能把真值抠出来。
闭环三问的答案
第 12 章到这里形成了一个完整闭环,对应三个产品问题:
| 问题 | 工具 | 交付 |
|---|---|---|
| 经济会不会停? | 12.1 存量/流量模型 | 稳态等级 L*=6 |
| 经济会不会收敛? | 12.2 闭环仿真 | 稳态偏差 0.855%(40 种子 p50 1.272%) |
| 该盯哪、怎么发现跑偏? | 12.3 敏感性 + 漂移 | 最危险参数 prodGrowth;漂移 %/天判定 |
下一章进入通胀专题:当敏感性扫描说「prodGrowth 一改就崩」,通胀在线上具体长什么样、怎么一步步演变。
阅读导航:上一节:闭环仿真与稳态判定 · 下一节:通胀的症状与成因 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。