# 通用赛季预测与 S17 结算复盘

方法版本：`season-scale-damped-transfer-v1`。这是在 S17 结算后提出、用于下一期验证的候选方法。原有 `s17-threshold-transfer-v1/v2` 冻结记录保留不变；不能把新方法回放结果说成当时实际发布的预测。

## 1. 先看实际结算与旧预测表现

### 本次评分目标

伤害单位为亿；底层保留截图玩家的完整整数伤害。

| 区服 | 3% | 1% | 0.5% | Top50 | 第一名 |
| --- | ---: | ---: | ---: | ---: | ---: |
| QQ | 151.31889571 | 208.59939478 | 225.64981691 | 272.76792383 | 353.06239475 |
| 微信 | 待补 | 207.22191920 | 222.64459051 | 260.53708737 | 343.41630854 |

五条百分位线来自站长采纳的结算截图：玩家的显示百分比分别为 3.00%、1.00%、0.50%。整数伤害是该玩家的精确伤害，百分位显示只有两位小数，因此它们属于**结算边界附近的观测代理值**，不能宣称理论分位数阈值精确到个位。

名为“微信百三”的图片实际显示 **3.08% / 142.42638473 亿**。保留该近线观测，但不把它当作微信 3% 训练标签，不据此填出一个精确 3% 结算线。双服 Top200 没有本次最终证据，继续留空。

### 原样评分当时已发布的记录

以下 MAPE 为 QQ 三个、微信两个可核对百分位档位的平均绝对百分比误差。每批只有五个相关档位，全部来自同一个目标赛季；不是五个独立赛季。

| 北京时间批次 | 当时版本 | A：同阶段倍率 MAPE | B：门槛增长参照 MAPE |
| --- | --- | ---: | ---: |
| 10月17日 16:00 | v1 | 13.827% | 17.454% |
| 10月17日 20:00 | v2 | 1.098% | 2.354% |
| 10月18日 14:00 | v2 | 2.295% | 2.295% |
| 10月19日 14:00 | v2 | 2.879% | 3.904% |
| 10月20日 15:00 | v2 | 4.247% | 5.328% |
| 10月21日 18:00 | v2 | 4.895% | 5.331% |

10月21日晚上，另外取得百分位玩家观测后发布的校正版本，五档 MAPE 为 **1.576%**。它使用了比双服 Top50 更多的额外信息，所以与首日只输入 Top50 的方法分开呈现。

这次实际结果说明：首版把头部倍率全部传给百分位，确实明显偏高；首日晚间改为 0.8 弹性后，已经非常接近结算。后续更新反而渐渐偏低，说明“头部玩家剩余提分”和“更广玩家群体的分位线增长”并不总同步。不能只展示赛末校正，也不能假定晚一天的数据必然更准。

## 2. 数据能支持哪些判断

- S15、S16、S17 有部分最终百分位标签与双服最终 Top50。
- S16、S17 有赛中多批 Top50；只有两个独立赛季的轨迹。S16 QQ 有 14 批完整 Top50，微信有 11 批完整 Top50；另外两批微信只到第49名，保留但不补造第50名。
- S1—S14 的一些最终排名可继续展示，但没有相应完整赛中轨迹或百分位标签时，不能当作本模型的完整训练样本。
- S15 与 S17 的 Boss 弱点为燃烧代码；S16 弱点为风压代码。这支持“本期阵容与属性会改变总尺度”的建模方向，但只有这些样本，无法分离属性、角色池、Boss 机制、练度、氪金和参赛者变化各自的因果贡献。

Top50 是分布最前端的有选择样本。即使前50完全一样，后面玩家的分布仍然可以很不一样。因此仅靠 Top50，数学上无法唯一还原全体 3%、1%、0.5% 档位。新模型必须明确使用历史迁移假设，而不能把缺少的数据包装成已经观测到的结果。

## 3. 新模型：把强弱尺度、剩余增长和分位映射分开

### 3.1 首批就能确定的赛季强弱尺度

令 `D_s(r,t)` 为当前赛季 s、时刻 t、第 r 名伤害。用第 6—45 名中段降低单个冠军和第50名压线波动的影响。

```text
H_s(t) = median(D_s(r,t)), r = 6…45

u = (t - 本期挑战开始) / (本期挑战结束 - 本期挑战开始)

q_s,h(t) = median(D_s(r,t) / D_h(r,u)), r = 6…45
```

h 是已经结束的较早赛季。按照挑战进度 u 对齐历史，历史相邻快照之间采用对数插值；超出历史可观测范围则采用最近快照并扩大情景压力。未来赛季活动时长变化时，不会误套 S17 的具体日期。

`q_s,h(t)` 是随本期实际数据变化的尺度，没有固定的“火属性乘 1.5、风属性乘 0.7”。强力火队、铁队、练度变化与 Boss 适配性如果提升了实测伤害，就直接体现在这个尺度里。

### 3.2 次批开始估计本期增长是否快于历史

对近期48小时的 `log(q_s,h(t))` 计算所有相隔至少3小时的两点斜率，取中位数。这个斜率描述本期相对历史的增长变化，避免把历史正常成熟增长又算一次。

数据少时先向零收缩：

```text
w = 时间跨度 / (时间跨度 + 24小时) × min(1, (批次数-1)/3)
b = clip(w × 斜率中位数, -log(1.2)/24, +log(1.2)/24)

有效外推小时 = 24 × [1 - exp(-剩余小时/24)]
G_s,h(t) = exp(b × 有效外推小时)
```

首批没有可识别的增长斜率，`G=1`。第二批会产生小幅更新，批次数和时间跨度增加后再提高趋势权重。阻尼让短期增长的总影响逐渐饱和，不把首日快速抬升线性延伸到全部剩余天数。

上述 48小时、24小时、20% 等是公开的工程候选参数，**不是从 S17 最终答案优化得到的最优参数**，也没有被少量数据证明为最佳。后续应按整季划分训练与测试来比较参数，不能用每个截图当独立样本反复调参。

### 3.3 预测本期最终头部尺度

```text
本期最终头部候选_h = H_h(结算) × q_s,h(t) × G_s,h(t)
本期最终头部 H_hat_s = 各历史候选的对数中位数
```

结果不低于当前有效头部成绩。这个约束以“已审核成绩不会因处罚或勘误撤销”为条件。

Top50 和第一名属于确实观察到的名次，分别使用相同历史名次的剩余增长，并设置当前有效成绩下限。百分位与 Top200 没有本期实测边界时，不设置虚构的当前下限。

### 3.4 每个百分位有自己的弹性，但少样本时收缩

历史已结束相邻赛季的弹性为：

```text
β_(a→b,k) = log(F_b,k / F_a,k) / log(H_b,final / H_a,final)
```

只有头部尺度变化达到10%，才使用该关系，避免分母接近0时制造巨大弹性。每条跨季转移的两个区服先合并为一个代表值，再计为一个转移样本。

```text
β_k = clip((n × median(历史转移弹性_k) + 2 × 0.8) / (n + 2), 0.2, 1.5)

本期档位候选_h,k = F_h,k × (H_hat_s / H_h,final)^β_k
本期最终档位估计 = 各可用历史候选的对数中位数
```

0.8 是已有方法的共同先验，权重2表示少量证据不足以完全推翻它；不是“多造两季训练数据”。S17 结算后，供未来赛季使用的参数约为：

| 目标 | 新候选 β | 可识别跨季转移数 |
| --- | ---: | ---: |
| 3% | 0.7747 | 2 |
| 1% | 0.8123 | 2 |
| 0.5% | 0.8400 | 2 |
| Top200 | 0.8172 | 1 |
| Top50、第一名 | 1 | 直接名次增长 |

这些小数仅用于记录计算结果，不意味着参数估计达到了小数点后四位的统计精度。S17 的训练标签不能用于计算“S17 当时会如何预测”的回放；回放每次只传入目标赛季之前的数据。

### 3.5 未来怎样引入属性信息

当某种弱点至少有3个已结束赛季，才允许学习该弱点的残差：

```text
残差 = log(最终档位) - β_k × log(最终头部尺度)
```

使用同弱点残差相对全部历史的偏移，再按样本量收缩。这是在已经扣除实际强弱尺度后判断“该属性的普通玩家能否跟上头部”，避免先乘一次火属性倍率，又被 Top50 的高伤害再乘一次。

当前同属性样本不足，该修正为1。系统已预留计算能力，但不会用两三个季的数据硬造出一张貌似精确的元素倍率表。

### 3.6 一次性事件

商店刷新、新角色上线等事件记录在赛季配置的 `forecastEvents` 中，必须有稳定事件ID、开始时间和获知时间。已进入当前观测窗口的事件视为已经包含在成绩中，不再次相乘。无法量化的“多了十几个石头”只作说明，不直接换算伤害。

尚未发生且有来源支持的额外头部倍率，只影响敏感性情景上侧，不暗中抬高主估计。事件ID去重，避免重复录入后把相同加成乘多遍。

## 4. 新方法的历史回放：结果并没有全面胜过旧 v2

每个时点只使用当时已审核的本期快照、较早季的结算值与已结束历史轨迹。S17 最终值只用于评分，不参与回放训练。由于方法是在结算后提出，这仍是**事后时间切分回放**，不是当时实际发行的新预测。

时间口径要分清：`asOf` 是站长指定的批次标签，用于对齐挑战进度；`informationAvailableBy` 才是本次回放的实际信息可用截止。例如10月17日20:00批次的截图持续到20:12，资料审核时间为21:05:24，因此回放用21:05:24作为知识截止，不能宣称20:00整已经看到了20:12的图。每份输入的批次、截图结束及审核时间均记录在回放JSON中；晚于实际信息截止的资料不会被使用。

| 北京时间批次 | 旧A真实事前记录 MAPE | 新候选事后回放 MAPE |
| --- | ---: | ---: |
| 10月17日 16:00 | 13.827%（v1） | 3.194% |
| 10月17日 20:00 | 1.098%（v2） | 2.167% |
| 10月18日 14:00 | 2.295% | 4.291% |
| 10月19日 14:00 | 2.879% | 5.818% |
| 10月20日 15:00 | 4.247% | 5.400% |
| 10月21日 18:00 | 4.895% | 5.911% |

新候选改掉了首版过度传递头部膨胀的问题，但没有全面超过已修正的 v2；负向相对趋势甚至放大了中后期低估。**本次不能据此宣布新方法更准。** 保留 v2 作为基准，新候选作为可复用、可审计的下一期对照。继续积累 S18 的独立结果后，才能判断逐档弹性、趋势更新或属性残差哪部分真的有效。

S16 回放明确返回 `missing_preceding_season_trajectory`：S15 缺少赛中曲线，不能用 S17 的后期资料假装“当时只用首日就准确预测了 S16”。也不把 S16 参与过校准后的开发期重构称为样本外预测。

## 5. 计算、发布与后续录入接口

以下命令在 `standalone/nikke-cn-solo-raid` 目录运行。

### 结算后：训练资料与所有旧记录评分

```sh
node scripts/build-season-forecast-model.mjs
node tests/season-forecast-model.test.mjs
```

输出：

- `data/season-forecast-model.json`：方法、公开参数、训练覆盖、逐档弹性、最终训练标签与限制。
- `data/season-forecast-evaluation.json`：每个旧冻结版本、额外百分位校正版本、新候选回放的逐档误差。
- `data/season-forecast-current.json`：固定模板读取的当前赛季预测状态；已结算、资料不足和可展示预测明确区分。

旧冻结文件仍在 `data/threshold-forecast-runs/`，不覆盖、不回填。已经结算的 S17 构建器只读取该文件，避免因为真实发布时间超过截止而发布“新的旧预测”。

### 新一期：先登记赛季，再导入已审核快照

`data/season-registry.json` 中配置 `season`、`status`、`challengeStart`、`endsAt`、`weakness` 等字段。新赛季不再写死在预测代码里。

正常更新只需登记 `registry.currentSeason`、将新赛季设为 `provisional`、录入已审核快照，再运行统一构建。默认的 `build-season-forecast-model.mjs` **会自动判断当前赛季并冻结预测**，无需每期增加一条专属命令或修改模板。至少一服具备完整已审核Top50和较早历史轨迹时可生成；缺失区服/档位仍留空。资料不足只输出状态说明，不制造预测；挑战结束后停止发行，正式结算后只展示实际成绩与回测。

下面的显式命令仅用于人工指定赛季或数据批次，不是正常构建必需步骤：

```sh
node scripts/build-season-forecast-model.mjs --forecast --season 18
```

可用 `--as-of ISO时间` 指定已经存在的批次，但真实 `issuedAt` 始终为执行时刻，不能在结算后倒填。没有完整当前 Top50、没有较早历史轨迹、缺少某档历史标签时，分别明确返回原因，不能编造缺失值。

新预测存入 `data/season-forecast-runs/`，当前展示副本为 `data/s18-season-forecast.json`。新模型不会覆盖原有 S17 v1/v2 文件。配置与方法版本改变也必须保留旧运行记录。

新赛季结算后，默认构建会自动扫描这些新冻结记录，在 `season-forecast-evaluation.json` 的 `prospective.runs` 中按对应赛季实际结果评分，并分别保留候选与固定弹性基准的误差。尚未结算的记录进入 `prospective.unscored`。这些真实事前发行结果与 `replay.runs` 的事后回放分开计数。

每个可计算档位还返回 `baseline`：以最近一个已有完整赛中轨迹的历史季为参照，固定0.8弹性的通用基准。候选和基准同时记录，不把看过S17后的选择伪装成预先设定的胜者。新聚合评估文件的 `signedPercentageError` 与 `absolutePercentageError` 统一以百分数表示，例如 `-5` 代表低估5%；旧冻结记录原结构不变。

### 固定临时页面的读取契约

模板读 `data/season-forecast-current.json`，仅当 `season` 与页面赛季匹配且 `status` 为 `forecast_ready` 时展示预测。正式页、已结算临时归档和历史单批快照不得误展示最新预测。

| 字段 | 含义 |
| --- | --- |
| `season` | 当前登记赛季编号 |
| `status` | `forecast_ready`、`missing_evidence`、`settled`、`not_started`、`ended_awaiting_settlement` 或 `configuration_missing` |
| `reason` / `methodNotice` | 当前状态和候选限制的纯文本说明 |
| `asOf` | 站长批次标签 |
| `issuedAt` | 冻结记录真实发行时间；重建复用原值 |
| `informationAvailableBy` | 实际资料可用截止 |
| `forecastPath` | `/data/s{N}-season-forecast.json` |
| `frozenPath` | `/data/season-forecast-runs/{id}.json` |
| `servers.qq` / `servers.wx` | 分服状态、输入时间、估计与解释 |
| `servers.*.thresholds[].key` | `p3`、`p1`、`p0_5`、`top200`、`top50`、`top1` |
| `estimate` / `lower` / `upper` | 候选整数伤害与未校准情景范围；缺失为 `null` |
| `baseline.estimate` | 固定0.8基准整数伤害；该历史档位缺失时为 `null` |

构建输出需复制该固定状态JSON、被引用的当前预测JSON和对应不可变运行文件到相同的公开 `/data/` 路径。页面全部数字仍写入HTML文本，JSON用于溯源及Agent读取。

### 关键验证

测试覆盖：目标季/未来最终值不泄漏、晚审核数据不提前使用、缺失百分位不跨服抄值、首批不编造趋势、阻尼限制短期外推、事件不重复叠加、结算后不能重发预测、官方可见名次下限，以及所有旧冻结批次都纳入评分。

## 6. 方法参考与本项目推论

- [Hyndman 与 Athanasopoulos：Forecasting: Principles and Practice，评估与时间序列交叉验证](https://otexts.com/fpp3/toolbox.html)。依据是训练集与测试集分离、不能用拟合误差替代预测误差；具体到本项目的“按整季而非截图划分”是针对数据相关性的应用。
- [同书：指数平滑与阻尼趋势](https://otexts.com/fpp3/exponential-smoothing.html)。借鉴限制长期趋势外推的思想；本项目的稳健比率趋势不是标准 Holt 模型，不宣称直接拟合了一个经过验证的 ETS 模型。
- [Stan User’s Guide：Truncated or Censored Data](https://mc-stan.org/docs/stan-users-guide/truncation-censoring.html)。本项目只观测头部属于受选择数据；“相同Top50可以对应不同更低分位线”是本任务的可识别性推论，不是来源给出的本游戏经验结论。

本候选的阈值、窗口、先验权重、压力范围均在代码与JSON中公开。情景范围不是80%/95%概率区间，也不承诺下期必然命中。
