2.3 曲线拟合与反推目标体验

2.2 是从参数推结果,本节反过来:先写下想要的目标体验(累计成本锚点、第几天到几级),再用最小二乘反推曲线参数。我们用 numpy 手写最小二乘比较指数与幂律两个模型,跑出拟合优度与锚点残差,最后反推出达标所需的成本增长率。整个过程只需一份整数配置,全程可复现。

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 伤害公式与减伤模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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