12.3 参数敏感性与漂移诊断

上线前先问「哪个参数最危险」,上线后再问「经济有没有跑偏」。这一节对五个关键参数做 ±20% 单参数扫描,按稳态存量摆幅排出最危险的三个参数;再用最小二乘从存量时序反推漂移速率,判定通胀/通缩/稳态,并以一组已知漂移的对照序列做检测基准验证。

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% 存量摆幅弹性
#1prodGrowth433,1841,355,375331,292,65924,410.91%61,027.3
#2consGrowth79,880,3861,355,375488,383−5,857.57%14,643.9
#3prodBase695,7081,355,3752,510,023133.86%334.7
#4consBase2,549,0821,355,375791,259−129.69%324.2
#5activity1,084,3001,355,3751,626,45040.0%100.0

读法有三条:

  1. 前两名是「相变」不是「敏感」。prodGrowth 调高 20% 后 r_p ≥ r_c,稳态消失,存量一路涨到 3.3 亿;调低 20% 则增速变慢、存量掉到 43 万。这不是平滑放大,是跨过临界点换了一套物理规律。数值策划要盯的是这条临界线,不是那个百分比。
  2. 基速参数是「可预期」的敏感。prodBase 弹性 334.7:基速动 1%,稳态存量动约 3.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 一改就崩」,通胀在线上具体长什么样、怎么一步步演变。

阅读导航:上一节:闭环仿真与稳态判定 · 下一节:通胀的症状与成因 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引