3.3 品类选择加权评分与敏感度分析

给 8 条商业特征维度配上可调权重,把 8 个品类算成加权总分并排名,再做单权重 ±20% 敏感度分析与排名稳定性基准,回答哪些品类的名次对权重最敏感、哪些结论可以放心采信,是本章核心一节,也是卷一里第一个把 S 层仿真与 B 层基准并排给出的地方。

3.2 节的均值能排序,却排不出「你更看重什么」。这一节把决策偏好显式地写进权重,让排名可调、可争、可验证。定位再说一次:站点里 小游戏创作平台专题 那 24 篇是做小游戏创作平台(SaaS 口径,编辑器与平台商业),本节则是为单款游戏做品类选型决策——用一套可调参、可断言、能回答「排名稳不稳」的评分模型,替立项人做取舍。这一节是本章的核心,也是卷一里第一个把 S 层仿真与 B 层基准并排给出的地方。

从分数到决策:引入权重

加权评分模型只有一行数学:

total = sum(score[d] * weight[d]) // 100      (整数地板除,权重合计 100)

它比均值多了一个东西:权重。均值等价于「8 条维度各占 12.5%」,是一种隐含的、你从没明说过的偏好;加权则把这个偏好摆到桌面上。权重怎么定,取决于你的团队与目标——一个擅长快速出原型的小团队,会把 devCost 压重;一个已经跑通买量的团队,会把 uaDifficulty 压重。

关键工程约束有三条,都在 config 与脚本里落实了:

  • 权重是整数百分比,合计 100,config 里不出现任何浮点;
  • 除法一律整数地板除,保证 Python / TS / 浏览器三处消费同一份 config 得到同一组数字;
  • 权重可调:改 config/ch03_genres.json 的 weights 即可,分类与分数都不用动。

加权评分模型(S 层)

下面这段是 S 层仿真脚本,读 config/ch03_genres.json(品类 / 维度 / 权重)与 config/ch03_matrix.json(分数),算加权总分与排名,再对每个维度单独把权重 ±20% 重算,观察排名变动,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/ch03_scoring.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ch03_scoring.py --config config/ --out out/
"""品类选择加权评分模型 + 单权重 ±20% 敏感度分析。

读 config/ch03_genres.json(品类 / 维度 / 权重)与 config/ch03_matrix.json(分数):
    total = sum(score[d] * weight[d]) // 100     (整数地板除,权重合计 100)
对每个维度单独把它的权重 ±20%(w*120//100 / w*80//100),其余不变,重算总分与排名,
观察哪些品类的名次会变。产出 JSON + CSV + SVG。
排名规则:(总分降序, 品类 id 升序)。全整数,无浮点;权重可调,模型完全由 config 驱动。
"""
from __future__ import annotations

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


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


def totals(gs: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
    return {g: sum(sc[g][d] * wt[d] for d in ds) // 100 for g in gs}


def ranks(t: dict) -> dict:
    return {g: i + 1 for i, (g, _) in enumerate(sorted(t.items(), key=lambda kv: (-kv[1], kv[0])))}


def svg(path: str, base: dict, lo: dict, hi: dict, order: list[str], cn: dict) -> int:
    """基线点 + 扰动区间(min..max)水平条。"""
    W, H, PAD, gap = 680, 420, 130, 12
    n = len(order)
    vmin, vmax = min(lo.values()), max(hi.values())
    span = max(vmax - vmin, 1)
    pw, ph = W - PAD - 60, H - 80
    bh = (ph - gap * (n + 1)) / n
    sx = lambda v: PAD + (v - vmin) / span * pw
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "40", "y": "26", "font-size": "15",
                                 "font-family": "sans-serif"}).text = (
        "加权总分基线(圆点)与单权重 ±20% 扰动区间(横条),按基线降序")
    for i, g in enumerate(order):
        y = 40 + gap + i * (bh + gap)
        cy = y + bh / 2
        x0, x1 = sx(lo[g]), sx(hi[g])
        ET.SubElement(root, "rect", {"x": f"{x0:.1f}", "y": f"{y:.1f}",
                                     "width": f"{max(x1 - x0, 1):.1f}", "height": f"{bh:.1f}",
                                     "fill": "#bae6fd"})
        ET.SubElement(root, "circle", {"cx": f"{sx(base[g]):.1f}", "cy": f"{cy:.1f}", "r": "5",
                                       "fill": "#0369a1"})
        ET.SubElement(root, "text", {"x": str(PAD - 10), "y": f"{cy + 4:.1f}", "font-size": "12",
                                     "text-anchor": "end", "font-family": "sans-serif"}).text = cn[g]
        ET.SubElement(root, "text", {"x": f"{x1 + 8:.1f}", "y": f"{cy + 4:.1f}",
                                     "font-size": "11", "font-family": "sans-serif"}).text = str(base[g])
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)
    gc = load(os.path.join(args.config, "ch03_genres.json"))
    mc = load(os.path.join(args.config, "ch03_matrix.json"))
    gs = [g["id"] for g in gc["genres"]]
    cn = {g["id"]: g["cn"] for g in gc["genres"]}
    ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])

    base = totals(gs, ds, sc, wt)
    br = ranks(base)
    order = sorted(gs, key=lambda g: (-base[g], g))

    rows, lo, hi, chg, top1 = [], {g: base[g] for g in gs}, {g: base[g] for g in gs}, {g: 0 for g in gs}, set()
    for d in ds:
        for pct, tag in ((120, "+20%"), (80, "-20%")):
            w2 = dict(wt)
            w2[d] = (wt[d] * pct) // 100
            t = totals(gs, ds, sc, w2)
            rk = ranks(t)
            top1.add(min(rk, key=lambda g: rk[g]))
            for g in gs:
                chg[g] += 1 if rk[g] != br[g] else 0
                lo[g], hi[g] = min(lo[g], t[g]), max(hi[g], t[g])
                rows.append({"scenario": f"{d}{tag}", "dim": d, "pct": pct, "genre": g,
                             "total": t[g], "rank": rk[g]})

    with open(os.path.join(args.out, "ch03_scoring.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["scenario", "dim", "pct", "genre", "total", "rank"])
        for r in rows:
            w.writerow([r["scenario"], r["dim"], r["pct"], r["genre"], r["total"], r["rank"]])
    svgb = svg(os.path.join(args.out, "ch03_scoring.svg"), base, lo, hi, order, cn)

    mx = max(chg.values())
    report = {
        "version": 1, "source": "sim/ch03_scoring.py", "weights": wt,
        "baseline": [{"genre": g, "cn": cn[g], "total": base[g], "rank": br[g]} for g in order],
        "scenarioCount": len(rows) // len(gs), "top1Set": sorted(top1), "rankChanges": chg,
        "mostSensitive": sorted(g for g in gs if chg[g] == mx),
        "range": {g: {"lo": lo[g], "hi": hi[g], "span": hi[g] - lo[g]} for g in order},
        "csv": os.path.join(args.out, "ch03_scoring.csv"),
        "svg": os.path.join(args.out, "ch03_scoring.svg"), "svgBytes": svgb,
    }
    with open(os.path.join(args.out, "ch03_scoring.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({
        "baselineRank": [(cn[g], base[g]) for g in order], "top1Set": sorted(top1),
        "rankChanges": {cn[g]: chg[g] for g in order},
        "mostSensitive": [cn[g] for g in report["mostSensitive"]],
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

脚本的敏感度设计是单变量扰动:一次只动一条维度的权重,其余保持不变。这比「同时随机扰动所有权重」更容易归因——排名变了,你能直接说是哪条维度造成的。

实跑结果:基线排名

执行 /opt/homebrew/bin/python3 sim/ch03_scoring.py --config config/ --out out/,得到基线排名(降序,同机相对量级,绝对数值不可移植):

放置 67 · 合成 66 · 塔防 62 · 闯关 61 · 益智 60 · IO 59 · 模拟经营 58 · 超休闲 54

和 3.2 的均值排名相比,「放置 / 合成」仍居前两位,但中段次序变了:均值里塔防(63)排在闯关(62)之前,加权后依然是塔防(62)压闯关(61),但两者的差距被权重放大了——这正是权重的意义。基线下 Top1 唯一,是「放置」。

敏感度结论:谁对权重最敏感

16 个扰动场景(8 维度 × ±20%)里,各品类的排名变动次数如下:

塔防 9 · 闯关 9 · 益智 2 · IO 2 · 放置 0 · 合成 0 · 模拟经营 0 · 超休闲 0

结论可以直接读出来:

  • 最敏感的是塔防与闯关,在 16 个场景里有 9 个名次发生变化,占了一半以上。它们的总分(62 / 61)只差 1 分,任何一条维度的权重小幅变动,都足以让二者互换——这是典型的近并列敏感区。
  • 最稳的是放置、合成、模拟经营、超休闲,16 个场景里名次一次没变。放置与合成领先优势明显(67 / 66),模拟经营与超休闲垫底(58 / 54),离邻居都足够远。
  • 榜首始终是放置(top1Set 只有一项)。这说明在本套权重下,「选放置」这个结论对权重不敏感,可以放心。

换句话说:排名稳不稳,取决于该品类与邻居的分差,而不是它的绝对分。绝对分高的品类,如果与邻居贴得近,一样会敏感。

排名稳定性基准(B 层)

敏感度分析只看 ±20% 一档。为了把「稳不稳」做成可复用的基准,下面这段是 B 层脚本:它把扰动网格加密到每维度 ±10% 与 ±20%(共 8×4 = 32 场景 + 1 基线),统计每个品类的排名跨度,并给出软告警——注意它是基准,无论多少告警退出码恒为 0。

#!/usr/bin/env python3
# file: sim/ch03_stability.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/ch03_stability.py --config config/ --out out/
"""品类评分「排名稳定性」基准(软告警,不硬失败)。

在 ch03_scoring.py 的单权重 ±20% 基础上加密扰动网格:每个维度单独 ±10% 与 ±20%,
共 8×4 = 32 个场景 + 1 个基线。对每个品类统计它在所有场景里出现过的排名集合、
排名跨度(max-min);再统计 Top1 / Top3 的稳定情况。

判据(软):
- 排名跨度 >= 1(扰动下换过名次)记为「对权重敏感」并告警;
- Top1 出现多于 1 个品类记为「榜首不稳」告警。
无论多少告警,退出码恒为 0——这是基准,不是门禁。全整数,无浮点。
"""
from __future__ import annotations

import argparse
import json
import os

SPAN_WARN = 1


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


def totals(gs: list[str], ds: list[str], sc: dict, wt: dict) -> dict:
    return {g: sum(sc[g][d] * wt[d] for d in ds) // 100 for g in gs}


def ranks(t: dict) -> dict:
    return {g: i + 1 for i, (g, _) in enumerate(sorted(t.items(), key=lambda kv: (-kv[1], kv[0])))}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)
    gc = load(os.path.join(args.config, "ch03_genres.json"))
    mc = load(os.path.join(args.config, "ch03_matrix.json"))
    gs = [g["id"] for g in gc["genres"]]
    cn = {g["id"]: g["cn"] for g in gc["genres"]}
    ds, sc, wt = mc["dimensions"], mc["scores"], dict(gc["weights"])

    base = totals(gs, ds, sc, wt)
    br = ranks(base)
    seen = {g: {br[g]} for g in gs}
    top1, top3, n = set(), set(), 1
    for d in ds:
        for pct in (110, 90, 120, 80):
            w2 = dict(wt)
            w2[d] = (wt[d] * pct) // 100
            rk = ranks(totals(gs, ds, sc, w2))
            for g in gs:
                seen[g].add(rk[g])
            top1.add(min(rk, key=lambda g: rk[g]))
            top3 |= {g for g, r in rk.items() if r <= 3}
            n += 1

    per, unstable = {}, []
    for g in gs:
        lo, hi = min(seen[g]), max(seen[g])
        per[g] = {"cn": cn[g], "baseRank": br[g], "minRank": lo, "maxRank": hi,
                  "span": hi - lo, "ranks": sorted(seen[g])}
        if hi - lo >= SPAN_WARN:
            unstable.append(g)

    warnings = []
    if len(top1) > 1:
        warnings.append(f"榜首不稳:扰动下 Top1 在 {sorted(cn[g] for g in top1)} 之间切换")
    for g in unstable:
        warnings.append(f"{cn[g]} 排名跨度 {per[g]['span']}({per[g]['minRank']}–{per[g]['maxRank']}),对权重敏感")

    report = {
        "version": 1, "source": "sim/ch03_stability.py", "scenarioCount": n,
        "spanWarnThreshold": SPAN_WARN,
        "baseline": [{"genre": g, "cn": cn[g], "total": base[g], "rank": br[g]}
                     for g in sorted(gs, key=lambda x: br[x])],
        "top1Set": sorted(top1), "top3Set": sorted(top3), "perGenre": per,
        "unstable": unstable, "warnings": warnings,
        "verdict": "PASS(soft)" if not warnings else f"WARN(soft) {len(warnings)} 条",
    }
    with open(os.path.join(args.out, "ch03_stability.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps({
        "scenarioCount": n, "top1Set": report["top1Set"], "top3Set": report["top3Set"],
        "spanByGenre": {cn[g]: per[g]["span"] for g in gs},
        "verdict": report["verdict"], "warnings": warnings,
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

稳定性结论与决策含义

执行 /opt/homebrew/bin/python3 sim/ch03_stability.py --config config/ --out out/,结果如下(退出码 0):

scenarioCount: 33
top1Set:  ["idle"]
top3Set:  ["idle", "level", "merge", "tower-defense"]
spanByGenre: {超休闲 0, 合成 0, 放置 0, 闯关 1, IO 1, 模拟经营 0, 塔防 1, 益智 1}
verdict: "WARN(soft) 4 条"

三条可操作的结论:

  • 榜首锁定,Top3 稳定:33 个场景里 Top1 始终是放置;Top3 只在 {放置、合成、塔防、闯关} 四个里浮动。要「稳选」,就在这四个里挑。
  • 中段会小幅摇摆:闯关、IO、塔防、益智的排名跨度都是 1——它们会与紧邻的品类互换,但不会跳到很远。跨度 1 意味着「选谁都行,但别指望排名给你背书」。
  • 两端完全不动:放置、合成、模拟经营、超休闲的跨度都是 0。领先的领先、垫底的垫底,权重怎么调都改不了。

这正是「软告警」的用法:4 条告警不是说模型坏了,而是说「有四个品类的名次不值得当决策依据」。模型给出的排名,稳的部分可以采信,抖的部分要回到业务判断。

小结

本节把 3.2 的中性分数升级成可调权重的加权评分,并用敏感度分析与排名稳定性基准回答了本章最实用的问题:哪些结论可以信、哪些不能。在本套权重下,「放置最优」稳如泰山,「塔防 / 闯关 / 益智 / IO 的精确次序」则随权重浮动。模型完全由 config/ 驱动、全整数、可复现,换个团队改一次权重就能重跑一遍。下一章转入平台规则与设计约束,看包体与首包如何反向决定玩法裁剪。

阅读导航:上一节:品类的商业特征矩阵 · 下一节:主包与分包体积预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制