1.3 属性膨胀与公式的可扩展性

版本迭代不断往战力里加属性,加法公式会越来越不准。本篇把「队伍强度 = 各乘区之积」当设计假设,把乘积按泰勒展开成一阶、二阶与精确三层,用 240 支采样队伍跑出三种公式的反演对数随属性数 k 的变化,并给出跨语言用 BigInt 复算的 TypeScript 实现。

上一节证明了加法战力在克制环上会判错。这一节问一个更长线的问题:版本一年更新几次,每次加一两个新属性(穿透、暴击、连击、士气……),这个加法公式还站得住吗?

SLG 是持续运营产品,属性膨胀是必然,不是意外。第 8 章会讲跨赛季的数值膨胀怎么控制;本节只关心公式本身——当属性从 2 个涨到 6 个,加法战力公式的排序保真度会发生什么。

属性膨胀从哪来

新属性几乎都带着同一个特征:它们是乘区,不是加项。穿透「忽略对方 30% 防御」、暴击「30% 概率打双倍」、士气「全队攻击 ×1.2」——这些效果在战斗里都是乘法,因为它们是独立发生的。但战力面板是加法:战力 = Σ wᵢ·aᵢ。于是每加一个乘区,策划就得在加法公式里补一项、重标定一次权重,而且越补越不准。

加法战力是乘积的一阶近似

把队伍强度记成各乘区之积(aᵢ 是百分比加成):

truth = Π (100 + aᵢ) / 100^(k-1)

令 xᵢ = aᵢ/100,则 truth = 100 · Π(1 + xᵢ)。把它泰勒展开:

truth = 100 · (1 + Σxᵢ + Σ_{i<j} xᵢxⱼ + Σ_{i<j<l} xᵢxⱼx_l + …)
      = 100 + Σaᵢ + Σ_{i<j} aᵢaⱼ/100 + 高阶项

这行展开解释了全部问题:加法战力 Σaᵢ 只保留了乘积的一阶项,丢掉了所有交叉项。属性越少,交叉项越少,误差越小;属性越多,C(k,2) 个交叉项越多,加法公式漏掉的越多。这就是「属性膨胀」在公式层面的样子。

下面这段是 D 层配置:基础乘区、逐版本新增的乘区、采样规模与种子。随机用自实现 mulberry32,Python 与 TypeScript 两侧同算法同种子。

{
  "version": 1,
  "note": "公式可扩展性实验:随版本增加属性/乘区,比较三种战力公式对真值排序的保真度。真值 = 各乘区之积(设计假设)。随机用自实现 mulberry32,种子固定。",
  "seed": 20261012,
  "teams": 240,
  "attrMaxPct": 200,
  "crossTermDen": 100,
  "baseAttrs": [ { "id": "atkPct", "name": "攻击区" }, { "id": "defPct", "name": "防御区" } ],
  "addedAttrs": [
    { "id": "hpPct", "name": "兵力区", "version": 2 },
    { "id": "penPct", "name": "穿透区", "version": 3 },
    { "id": "critPct", "name": "暴击区", "version": 4 },
    { "id": "moralePct", "name": "士气区", "version": 5 }
  ]
}

下面这段是 S 层仿真脚本:采样 240 支队伍,对每个 k(2→6)算三种公式与真值的 Spearman 秩相关与 Kendall 反演对数。

# file: sim/n01_ext.py
# tier: S
# verify: python3 sim/n01_ext.py --config config/ --out out/
"""公式可扩展性实验:读 config/n01_ext.json,随版本增加属性/乘区,量三种战力公式的排序保真度。

真值(设计假设):队伍强度 = 各乘区之积,truth = Π (100 + a_i) // 100^(k-1)。
把乘积展开(x_i = a_i/100):truth = 100 + Σa_i + Σ_{i<j} a_i·a_j/100 + 高阶项。
- 加法战力(一阶):power_add = Σ a_i——只保留一阶项。
- 加法战力(二阶):power_add2 = Σ a_i + Σ_{i<j} a_i·a_j // 100——补上交叉项。
- 乘法战力(精确):power_mul = truth——与真值同构。

对 N 支采样队伍算三种公式与真值的 Spearman 秩相关与 Kendall 反演对数。
队伍属性用自实现 mulberry32 生成,Python 与 TS 两侧同算法同种子。
纯标准库,图表用 xml.etree 手写 SVG,零依赖。"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def load_config(path: str) -> dict:
    with open(path, encoding="utf-8") as f:
        return json.load(f)


def mulberry32(seed: int):
    """确定性 PRNG:与 proto/src/n01_ext.ts 逐位一致。"""
    state = seed & 0xFFFFFFFF

    def nxt() -> float:
        nonlocal state
        state = (state + 0x6D2B79F5) & 0xFFFFFFFF
        t = ((state ^ (state >> 15)) * (state | 1)) & 0xFFFFFFFF
        t = (t ^ (t + (((t ^ (t >> 7)) * (t | 61)) & 0xFFFFFFFF))) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0

    return nxt


def make_teams(cfg: dict, k: int) -> list[list[int]]:
    rng = mulberry32(cfg["seed"])
    mx = cfg["attrMaxPct"]
    return [[int(rng() * (mx + 1)) for _ in range(k)] for _ in range(cfg["teams"])]


def truth_of(team: list[int]) -> int:
    p = 1
    for a in team:
        p *= (100 + a)
    return p // (100 ** (len(team) - 1))


def add_of(team: list[int]) -> int:
    return sum(team)


def add2_of(team: list[int], den: int) -> int:
    cross = sum(team[i] * team[j] for i in range(len(team)) for j in range(i + 1, len(team)))
    return sum(team) + cross // den


def rankdata(vals: list[int]) -> list[float]:
    order = sorted(range(len(vals)), key=lambda i: vals[i])
    ranks = [0.0] * len(vals)
    i = 0
    while i < len(order):
        j = i
        while j + 1 < len(order) and vals[order[j + 1]] == vals[order[i]]:
            j += 1
        for t in range(i, j + 1):
            ranks[order[t]] = (i + j) / 2 + 1
        i = j + 1
    return ranks


def spearman_permille(xs: list[int], ys: list[int]) -> int:
    rx, ry = rankdata(xs), rankdata(ys)
    n = len(xs)
    mx, my = sum(rx) / n, sum(ry) / n
    num = sum((rx[i] - mx) * (ry[i] - my) for i in range(n))
    dx = sum((rx[i] - mx) ** 2 for i in range(n)) ** 0.5
    dy = sum((ry[i] - my) ** 2 for i in range(n)) ** 0.5
    return int(round(num / (dx * dy) * 1000)) if dx and dy else 0


def inversions(xs: list[int], ys: list[int]) -> int:
    """Kendall 反演对数:两两比较,xs 次序与 ys 次序相反的对数。"""
    return sum(1 for i in range(len(xs)) for j in range(i + 1, len(xs))
               if (xs[i] - xs[j]) * (ys[i] - ys[j]) < 0)


def write_svg(path: str, rows: list[dict]) -> None:
    width, height, pl, pr, pt, pb = 640, 360, 64, 24, 40, 56
    iw, ih = width - pl - pr, height - pt - pb
    vmax = max(max(r[k] for k in ("addInversions", "add2Inversions", "mulInversions")) for r in rows) or 1
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(width),
                             "height": str(height), "viewBox": f"0 0 {width} {height}"})
    ET.SubElement(svg, "rect", {"width": str(width), "height": str(height), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": str(pl), "y": "24", "font-size": "15",
                                "font-family": "sans-serif"}).text = "反演对数随属性数 k 的变化(越小越准)"
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt + ih), "x2": str(pl + iw), "y2": str(pt + ih), "stroke": "#888"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": str(pt), "x2": str(pl), "y2": str(pt + ih), "stroke": "#888"})
    series = (("addInversions", "#d1242f", "一阶加法"), ("add2Inversions", "#e3a008", "二阶加法"),
              ("mulInversions", "#1f6feb", "乘法"))
    for key, color, _ in series:
        pts = [f"{pl + iw * (i / max(len(rows) - 1, 1)):.1f},{pt + ih * (1 - r[key] / vmax):.1f}"
               for i, r in enumerate(rows)]
        ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": color, "stroke-width": "2"})
    for i, r in enumerate(rows):
        ET.SubElement(svg, "text", {"x": f"{pl + iw * (i / max(len(rows) - 1, 1)):.1f}", "y": str(pt + ih + 16),
                                    "font-size": "11", "text-anchor": "middle",
                                    "font-family": "sans-serif"}).text = f"k={r['k']}"
    for t, (_, color, label) in enumerate(series):
        ET.SubElement(svg, "text", {"x": str(pl + t * 110), "y": str(pt + ih + 38), "font-size": "12",
                                    "fill": color, "font-family": "sans-serif"}).text = label
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    cfg = load_config(os.path.join(args.config, "n01_ext.json"))
    base, den = len(cfg["baseAttrs"]), cfg["crossTermDen"]
    rows = []
    for added in range(len(cfg["addedAttrs"]) + 1):
        k = base + added
        teams = make_teams(cfg, k)
        truth = [truth_of(t) for t in teams]
        padd = [add_of(t) for t in teams]
        p2 = [add2_of(t, den) for t in teams]
        rows.append({"version": 1 + added, "k": k,
                     "addPermille": spearman_permille(padd, truth), "add2Permille": spearman_permille(p2, truth),
                     "mulPermille": 1000, "addInversions": inversions(padd, truth),
                     "add2Inversions": inversions(p2, truth), "mulInversions": 0})

    summary = {"version": 1, "teams": cfg["teams"], "attrMaxPct": cfg["attrMaxPct"], "crossTermDen": den,
               "rows": rows, "addInversionGrowth": rows[-1]["addInversions"] - rows[0]["addInversions"],
               "add2InversionGrowth": rows[-1]["add2Inversions"] - rows[0]["add2Inversions"]}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n01_ext.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n01_ext.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["version", "k", "addPermille", "add2Permille", "mulPermille",
                    "addInversions", "add2Inversions", "mulInversions"])
        for r in rows:
            w.writerow([r["version"], r["k"], r["addPermille"], r["add2Permille"], r["mulPermille"],
                        r["addInversions"], r["add2Inversions"], r["mulInversions"]])
    write_svg(os.path.join(args.out, "n01_ext.svg"), rows)
    print(json.dumps(summary, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

本机实跑(240 支队伍,C(240,2)=28680 对;反演对数越小越准):

k=2  add 989‰ inv=982  | add2 1000‰ inv=0   | mul 1000‰ inv=0
k=3  add 988‰ inv=1201 | add2 998‰  inv=434 | mul 1000‰ inv=0
k=4  add 989‰ inv=1217 | add2 996‰  inv=667 | mul 1000‰ inv=0
k=5  add 989‰ inv=1219 | add2 995‰  inv=766 | mul 1000‰ inv=0
k=6  add 989‰ inv=1208 | add2 995‰  inv=834 | mul 1000‰ inv=0
addInversionGrowth: 226   add2InversionGrowth: 834

读结果:一阶公式注定漏掉交叉项

三行结论直接对应泰勒展开:

  • 一阶加法(Σaᵢ)从 k=2 就有 982 处反演,到 k=6 升到 1208。它从一开始就漏掉了交叉项,属性越多漏得越多。
  • 二阶加法(补上 Σaᵢaⱼ/100)在 k=2 时反演为 0——因为 k=2 时真值恰好等于 100 + add2,交叉项只有一项,补上就精确了。k≥3 出现三阶及以上交叉项,反演从 0 涨到 834,但始终远低于一阶公式。
  • 乘法公式反演恒为 0:它与真值同构,所以永远是 0。这条是基准线——不是「乘法公式有多好」,而是「结构对了就不用打补丁」。

注意反演对数(绝对量)比 Spearman(千分比)敏感得多:Spearman 从 989 到 1000 看着差别不大,但反演对数在 k=6 是 1208 对 0——28680 对里有 4.2% 被一阶公式排反了。所以评估战力公式时,别只看相关系数,要看反演率。

可扩展性的结论:加法战力不是「换个权重就能修」的问题,是结构不匹配。要么把战力改成乘法/分乘区结构(与战斗的乘法本质一致),要么至少补上交叉项(二阶公式),让新属性进来时误差涨得慢一些。

P 层:跨语言与 BigInt

乘法真值 Π(100+aᵢ) 在 k=6、属性到 200 时已达 300⁶ ≈ 7.3×10¹⁴,继续加属性就会超 2⁵³(JS 的安全整数上限)。所以原型层必须用 BigInt。下面这段是 P 层原型,与 S 层共用 config/n01_ext.json 和同种子的 mulberry32。

// file: proto/src/n01_ext.ts
// tier: P
// verify: node --test proto/test/n01_ext.test.ts
// bastion 原型层:公式可扩展性。读 config/n01_ext.json,用 BigInt 算乘积真值。
// 与 sim/n01_ext.py 共用配置、同算法同种子的 mulberry32,逐位一致。

import { loadConfig } from "./core.ts";

export interface ExtConfig {
  seed: number; teams: number; attrMaxPct: number; crossTermDen: number;
  baseAttrs: { id: string }[]; addedAttrs: { id: string; version: number }[];
}

export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6D2B79F5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a) >>> 0;
    const t0 = t;
    t = (t0 + Math.imul(t0 ^ (t0 >>> 7), 61 | t0)) >>> 0;
    t = (t ^ t0) >>> 0;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function makeTeams(cfg: ExtConfig, k: number): number[][] {
  const rnd = mulberry32(cfg.seed);
  const out: number[][] = [];
  for (let i = 0; i < cfg.teams; i++) {
    const row: number[] = [];
    for (let j = 0; j < k; j++) row.push(Math.floor(rnd() * (cfg.attrMaxPct + 1)));
    out.push(row);
  }
  return out;
}

// 乘积真值必须用 BigInt:Π(100+a) 在 k 大、属性大时远超 2^53
export function truthOf(team: number[]): bigint {
  let p = 1n;
  for (const a of team) p *= BigInt(100 + a);
  return p / 100n ** BigInt(team.length - 1);
}

export function addOf(team: number[]): number {
  return team.reduce((s, a) => s + a, 0);
}

export function add2Of(team: number[], den: number): number {
  let cross = 0;
  for (let i = 0; i < team.length; i++) for (let j = i + 1; j < team.length; j++) cross += team[i] * team[j];
  return addOf(team) + Math.floor(cross / den);
}

export function loadExt(): ExtConfig {
  return loadConfig<ExtConfig>("n01_ext");
}

node --test proto/test/n01_ext.test.ts 本机全绿,断言 TS 侧与 Python 侧逐位一致:

mulberry32 前 6 个整数: [68, 3, 73, 1, 136, 132]  (两侧相同)
k=6 首支队伍 truth=1655n add=413 add2=1036        (两侧相同)
k=2 首支队伍 add2 + 100 == truth                   (二阶公式精确)

小结

  • 属性膨胀是 SLG 的必然:新属性几乎都是乘区,而战力面板是加法。
  • 加法战力是乘积的一阶近似,漏掉全部交叉项;C(k,2) 个交叉项随属性数平方增长。
  • 仿真结果:一阶公式反演对数 982→1208(k=2→6),二阶 0→834,乘法恒为 0。二阶公式在 k=2 精确,k≥3 起偏离。
  • 评估公式看反演率而非相关系数;乘法真值跨语言要用 BigInt。
  • 下一章离开属性层,进入成长曲线:属性怎么随等级长出来。

阅读导航:上一节:1.2 战力公式设计与校验 · 下一节:2.1 成长曲线的四种家族 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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