2.2 走的是正向:给定成本增长率,跑出时间门槛与满级天数。但策划的真实工作常常是反向的:老板说「玩家应该在 45 天左右满级」,或数据说「第 7 天该到 10 级」,然后你要反推出该用多大的增长率。这就是拟合(fitting)要解决的问题——从想要的结果,反推该填的参数。
本节和 2.1、2.2 用同一份 config/n02_curve.json。上一节的成本曲线是「当前方案」,本节的锚点与目标天数是「设计意图」,拟合与基准要回答的正是两者差多远。
反推问题的三种问法
反推可以问得越来越具体:形状问法(更像指数还是幂律)是模型选择,参数问法(每级增长率 r 是多少)是参数估计,达标问法(要让满级 45 天该调到多少)是反向求解。本节三件事全做。
两个模型:指数 vs 幂律
累计成本最常见的两个候选模型:
- 指数
C(L) = A · r^(L-1):每级乘一个固定倍数。对数化后在(L-1, lnC)上是一条直线。 - 幂律
C(L) = A · L^B:随等级按幂次增长。对数化后在(lnL, lnC)上是一条直线。
两个模型都能拟合成一元线性回归,所以用同一套最小二乘就能公平比较。谁的对数残差小、R² 高,谁就更能解释这组锚点。
本机没有装 scipy,也不需要:一元最小二乘就是解正规方程 (XᵀX)·β = Xᵀy,用 numpy 十行写完——把自变量堆成设计矩阵 X = [1, x],β 第一项是截距、第二项是斜率,斜率指数化就是增长率 r = exp(斜率)。
跑一遍:拟合
下面这段是 S 层仿真脚本,它把锚点分别拟合成指数与幂律两个模型,报告 R²、残差均方根与末级预测值,并画一张「锚点 vs 两个模型」的 SVG:
# file: sim/n02_curve_fit.py
# tier: S
# verify: python3 sim/n02_curve_fit.py --config config/ --out out/
"""曲线拟合与反推目标体验(2.3):给一组目标累计成本锚点,用最小二乘反推增长率。
对同一组锚点分别拟合两个模型:
指数模型 C(L) = A * r^(L-1) —— 在 (L-1, lnC) 上做一元最小二乘,r = exp(斜率)
幂律模型 C(L) = A * L^B —— 在 (lnL, lnC) 上做一元最小二乘
最小二乘用 numpy 手写正规方程(不依赖 scipy)。产出 out/n02_fit.json + out/n02_fit.csv + out/n02_fit.svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET
import numpy as np
SVG_NS = "http://www.w3.org/2000/svg"
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def lstsq(x: np.ndarray, y: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""手写最小二乘:y ≈ a + b·x,解正规方程 (XᵀX)·β = Xᵀy。"""
X = np.column_stack([np.ones_like(x), x])
beta = np.linalg.solve(X.T @ X, X.T @ y)
return beta, X @ beta
def r2_rmse(y: np.ndarray, pred: np.ndarray) -> tuple[float, float]:
ss_res = float(np.sum((y - pred) ** 2))
ss_tot = float(np.sum((y - np.mean(y)) ** 2))
return (1 - ss_res / ss_tot if ss_tot else 0.0), math.sqrt(ss_res / len(y))
def render_svg(path: str, levels: list[int], anchors: list[tuple[int, int]],
exp_pred: list[float], pow_pred: list[float]) -> None:
width, height, pl, pr, pt, pb = 700, 420, 72, 130, 28, 44
iw, ih = width - pl - pr, height - pt - pb
n = len(levels)
hi = float(max(max(exp_pred), max(pow_pred), max(c for _, c in anchors))) or 1.0
def xy(i: int, v: float) -> str:
return f"{pl + iw * (i / max(n - 1, 1)):.1f},{pt + ih * (1 - v / hi):.1f}"
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {width} {height}", "width": str(width), "height": str(height)})
ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(height - pb), "stroke": "#333"})
ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(height - pb), "x2": str(width - pr), "y2": str(height - pb), "stroke": "#333"})
ET.SubElement(svg, "polyline", {"points": " ".join(xy(i, exp_pred[i]) for i in range(n)), "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
ET.SubElement(svg, "polyline", {"points": " ".join(xy(i, pow_pred[i]) for i in range(n)), "fill": "none", "stroke": "#e67e22", "stroke-width": "2", "stroke-dasharray": "5 3"})
for lv, c in anchors:
ET.SubElement(svg, "circle", {"cx": f"{pl + iw * ((lv - 1) / max(n - 1, 1)):.1f}", "cy": f"{pt + ih * (1 - c / hi):.1f}", "r": "4", "fill": "#c0392b"})
for i, (txt, col) in enumerate([("exp fit A*r^(L-1)", "#1f6feb"), ("power fit A*L^B", "#e67e22"), ("anchors", "#c0392b")]):
ET.SubElement(svg, "text", {"x": str(width - pr + 10), "y": str(pt + 18 + i * 20), "font-size": "12", "fill": col}).text = txt
ET.SubElement(svg, "text", {"x": str(pl), "y": "16", "font-size": "13", "fill": "#111"}).text = "cumulative cost: anchors vs two fitted models"
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n02_curve")
anchors = cfg["fitting"]["anchors"]
n = cfg["maxLevel"]
lv = np.array([a["level"] for a in anchors], dtype=float)
cum = np.array([a["cumCost"] for a in anchors], dtype=float)
ln_cum = np.log(cum)
# 指数模型:lnC = lnA + b·(L-1)
beta_e, pred_e = lstsq(lv - 1.0, ln_cum)
lnA_e, b_e = float(beta_e[0]), float(beta_e[1])
A_e, r_e = math.exp(lnA_e), math.exp(b_e)
r2_e, rmse_e = r2_rmse(ln_cum, pred_e)
# 幂律模型:lnC = lnA + B·lnL
beta_p, pred_p = lstsq(np.log(lv), ln_cum)
lnA_p, B_p = float(beta_p[0]), float(beta_p[1])
A_p = math.exp(lnA_p)
r2_p, rmse_p = r2_rmse(ln_cum, pred_p)
levels = list(range(1, n + 1))
exp_pred = [A_e * r_e ** (L - 1) for L in levels]
pow_pred = [A_p * L ** B_p for L in levels]
exp_ratio = {"growthNum": round(r_e * 1000), "growthDen": 1000, "asPermille": round(r_e * 1000)}
report = {
"version": 1, "anchorCount": len(anchors), "maxLevel": n,
"exponential": {"model": "A*r^(L-1)", "A": round(A_e, 2), "r": round(r_e, 6),
"ratio": exp_ratio, "r2": round(r2_e, 6), "rmse": round(rmse_e, 4)},
"powerLaw": {"model": "A*L^B", "A": round(A_p, 4), "B": round(B_p, 4),
"r2": round(r2_p, 6), "rmse": round(rmse_p, 4)},
"predExpAtMax": round(exp_pred[-1], 1), "predPowAtMax": round(pow_pred[-1], 1),
}
with open(os.path.join(args.out, "n02_fit.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["level", "anchorCumCost", "expFit", "powFit"])
by_level = {a["level"]: a["cumCost"] for a in anchors}
for i, L in enumerate(levels):
w.writerow([L, by_level.get(L, ""), f"{exp_pred[i]:.1f}", f"{pow_pred[i]:.1f}"])
with open(os.path.join(args.out, "n02_fit.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
render_svg(os.path.join(args.out, "n02_fit.svg"), levels, [(a["level"], a["cumCost"]) for a in anchors], exp_pred, pow_pred)
print(f"exp A={report['exponential']['A']} r={report['exponential']['r']} ratio={exp_ratio['growthNum']}permille "
f"r2={report['exponential']['r2']} rmse={report['exponential']['rmse']} predAtMax={report['predExpAtMax']}")
print(f"pow A={report['powerLaw']['A']} B={report['powerLaw']['B']} "
f"r2={report['powerLaw']['r2']} rmse={report['powerLaw']['rmse']} predAtMax={report['predPowAtMax']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑(Python 3.14.6)输出:
exp A=151.0 r=1.399695 ratio=1400permille r2=0.999999 rmse=0.0027 predAtMax=2593884.0
pow A=0.1552 B=4.5931 r2=0.93632 rmse=0.7246 predAtMax=944753.3
读数:指数完胜幂律
两个模型对同一组锚点的解释力差距巨大:
- 指数:
R² = 0.999999,对数残差rmse = 0.0027,反推增长率r ≈ 1.3997(整数比1400/1000),末级预测 2593884,几乎正好落在锚点 2593000 上。 - 幂律:
R² = 0.93632,rmse = 0.7246,末级预测只有 944753——比锚点低了六成。
结论很清楚:这组目标累计成本是指数形状,不是幂律。反推出的增长率 r ≈ 1.40,比 2.2 当前方案的 1.28 陡得多。这提示了一个矛盾:目标体验想要的曲线,比当前方案更陡。下一段的基准会把这个矛盾量化。
B 基准:锚点残差 + 目标体验
拟合好不好,要拿残差说话;目标能不能达标,要拿天数说话。下面这段是 B 层基准脚本,它做三件事:锚点残差、目标天数残差、以及反推达标参数。残差超阈值只打 WARN,退出码恒为 0(软告警不硬失败):
# file: sim/n02_curve_bench.py
# tier: B
# verify: python3 sim/n02_curve_bench.py --config config/ --out out/
"""B 基准(2.3):把拟合结果与目标体验做残差对照,并反推达标所需的成本增长率。
三件事:
1. 锚点残差:指数拟合的累计成本 vs 目标锚点,算千分比残差。
2. 目标体验:用 2.2 的成本/产出模型算「第几天到 L 级」,与 targetDays 对照。
3. 反推:扫描成本增长率,找到让「满级天数」最接近目标的那一档。
残差超阈值只打 WARN,退出码恒为 0(软告警不硬失败)。
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from n02_curve_fit import load, lstsq # noqa: E402
from n02_cost_curve import cost_at, income_at # noqa: E402
WARN_THRESHOLD_PERMILLE = 100
def cum_hours_to(cost: dict, inc: dict, upto: int) -> int:
"""从 1 级攒到 upto 级(含升 upto 这一级)需要的总小时数。"""
eff = inc["efficiencyPermille"]
total = 0
for lv in range(1, upto + 1):
c = cost_at(cost, lv)
rate = income_at(inc, lv) * eff // 1000
total += (c + rate - 1) // rate
return total
def days_to_reach(cost: dict, inc: dict, level: int) -> int:
hpd = inc["hoursPerDay"]
return (cum_hours_to(cost, inc, level - 1) + hpd - 1) // hpd
def main() -> int:
here = os.path.dirname(os.path.abspath(__file__))
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(here, "..", "config"))
ap.add_argument("--out", default=os.path.join(here, "..", "out"))
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
cfg = load(args.config, "n02_curve")
cost, inc = cfg["cost"], cfg["income"]
n = cost["levels"]
# 1) 锚点残差(用指数拟合复算,避免读中间产物)
import numpy as np # noqa: E402
lv = np.array([a["level"] for a in cfg["fitting"]["anchors"]], dtype=float)
cum = np.array([a["cumCost"] for a in cfg["fitting"]["anchors"]], dtype=float)
beta, _ = lstsq(lv - 1.0, np.log(cum))
A_e, r_e = math.exp(float(beta[0])), math.exp(float(beta[1]))
checks = []
for a in cfg["fitting"]["anchors"]:
pred = A_e * r_e ** (a["level"] - 1)
resid = a["cumCost"] - pred
rp = round(resid * 1000 / a["cumCost"])
checks.append({"level": a["level"], "target": a["cumCost"], "fit": round(pred, 1),
"residualPermille": rp, "warn": abs(rp) > WARN_THRESHOLD_PERMILLE})
# 2) 目标体验:天数残差
day_checks = []
for t in cfg["fitting"]["targetDays"]:
actual = days_to_reach(cost, inc, t["level"])
resid = actual - t["day"]
day_checks.append({"level": t["level"], "targetDay": t["day"], "actualDay": actual,
"residualDays": resid, "warn": abs(resid) > 3})
# 3) 反推:扫描成本增长率,找满级天数最接近目标的一档
goal_days = max(t["day"] for t in cfg["fitting"]["targetDays"])
best = None
for gnum in range(cost["growthNum"], 171):
c2 = dict(cost, growthNum=gnum)
d = (cum_hours_to(c2, inc, n) + inc["hoursPerDay"] - 1) // inc["hoursPerDay"]
if best is None or abs(d - goal_days) < abs(best[1] - goal_days):
best = (gnum, d)
reverse = {"goalDays": goal_days, "fittedGrowthPermille": best[0] * 10,
"fittedDays": best[1], "currentGrowthPermille": cost["growthNum"] * 10}
abs_res = [abs(c["residualPermille"]) for c in checks]
rep = {
"version": 1, "thresholdPermille": WARN_THRESHOLD_PERMILLE,
"fittedRatioPermille": round(r_e * 1000),
"anchorChecks": checks, "dayChecks": day_checks, "reverse": reverse,
"maxAbsResidualPermille": max(abs_res),
"meanAbsResidualPermille": sum(abs_res) // len(abs_res),
"anyWarn": any(c["warn"] for c in checks) or any(d["warn"] for d in day_checks),
}
with open(os.path.join(args.out, "n02_bench.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
for c in checks:
print(f"{'WARN' if c['warn'] else 'OK'} L{c['level']} fit={c['fit']} target={c['target']} resid={c['residualPermille']}permille")
for d in day_checks:
print(f"{'WARN' if d['warn'] else 'OK'} reach L{d['level']} actual=D{d['actualDay']} target=D{d['targetDay']} resid={d['residualDays']}d")
print(json.dumps({"reverse": reverse, "maxAbsResidualPermille": rep["maxAbsResidualPermille"], "anyWarn": rep["anyWarn"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑输出:
OK L5 fit=579.6 target=580 resid=1permille
OK L10 fit=3113.7 target=3100 resid=-4permille
OK L15 fit=16728.2 target=16800 resid=4permille
OK L20 fit=89870.2 target=90000 resid=1permille
OK L25 fit=482817.6 target=482000 resid=-2permille
OK L30 fit=2593884.0 target=2593000 resid=0permille
WARN reach L10 actual=D2 target=D7 resid=-5d
WARN reach L20 actual=D4 target=D21 resid=-17d
WARN reach L30 actual=D8 target=D45 resid=-37d
{"reverse": {"goalDays": 45, "fittedGrowthPermille": 1400, "fittedDays": 47, "currentGrowthPermille": 1280}, "maxAbsResidualPermille": 4, "anyWarn": true}
反推:把满级天数拉回 45 天
- 锚点残差全部落在 ±4‰ 以内:指数模型对这组目标锚点的拟合近乎完美,说明锚点本身是自洽的指数。
- 目标天数全部 WARN:当前方案(增长率 1.28)满级只要 8 天,而目标要 45 天,差了 37 天。
- 反推结果:要让满级天数落到 45 天,成本增长率得从
1280permille(1.28)调到1400permille(1.40),此时满级约 47 天。
注意两个独立方向指向同一个数:拟合锚点得到的 r ≈ 1.40,反推天数得到的也是 1.40。这不是巧合——它说明锚点与天数目标本来就描述了同一条指数曲线,只是当前方案的 1.28 太软。B 基准的价值就在这:它不阻止你上线,但会在「当前方案」与「设计意图」分叉时,把差距和一个可执行的修正值一起亮出来。
拟合不是万能:三个前提
拟合能反推参数,但只在三个前提下成立:锚点要可信(锚点错了,拟合只会把错误精确化);模型要选对(本节幂律 R² 仅 0.936,强行用它会低估末级六成,选错模型比不拟合更危险);拟合的是累计成本而非单级成本(累计成本更平缓,拟合出的增长率会偏小,要反推单级增长率得先做差分)。
小结
反推 = 先写下想要的目标体验,再用最小二乘把参数解出来。本节对同一组锚点比较了指数与幂律两个模型,指数以 R² = 0.999999 完胜;反推出的增长率 r ≈ 1.40 与「满级 45 天」的天数目标互相印证,共同指向当前方案 1.28 偏软。下一章离开成长曲线,进入战斗数值基础——伤害公式、减伤与命中暴击,看一次交锋到底怎么算。
阅读导航:上一节:2.2 等级与养成成本曲线 · 下一节:3.1 伤害公式与减伤模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。