让机器读懂 maimai 谱面:配置识别与拟合难度预测
最近在给 Maimai Chart Preview 做谱面分析功能的时候,认真啃了一遍「怎么让机器看懂一张 maimai 谱面」这个问题。这篇就把整套算法讲清楚:先识别谱面里有哪些配置(纵连、转圈、反手……),再预测它的拟合定数,最后自动打出「水谱 / 正常 / 诈称」的标签。
一、问题:显示难度太粗了
maimai DX 每张谱面只有一个显示难度,比如 13+。但同一个 13+ 里,既有轻松愉快的池塘水谱,也有让人痛不欲生的诈称谱:
| 水谱 | 正常谱 | 诈称谱 | |
|---|---|---|---|
| 定数 | 12.7 | 13.5 | 14.2 |
| 显示档 | 13 | 13 | 13 |
| 体感 | 躺着过 | 该什么样什么样 | 痛不欲生 |
把「显示档」和「实际定数」画成两个轴,同一档谱面在纵轴上散开的样子就很直观了——横轴是官方档位,纵轴是玩家拟合出来的真实定数,同一个档位内部能拉开整整 1.5 的差距:
社区(Diving-Fish 查分器)用大量玩家成绩分布拟合出了精确到 0.1 的定数 fit_diff,但那个数字是从玩家表现统计出来的。我们想做的是反过来:只看谱面文本本身(simai 数据),不靠任何玩家成绩,预测出这个定数。
思路不是从零预测,而是以显示难度为基准,学习一个残差:
其中 是显示档位中心(整数级取 ,+ 级取 ,和水鱼的曲线实现对齐)。模型只学「同档内谁偏难、谁偏易」,任务一下子简单很多。
为什么残差这么重要?看数据:
| 方法 | 预测起点 | 分组 CV MAE |
|---|---|---|
| 从零绝对回归 | 1.272 + 特征 | 0.734 |
| 只用显示档位 | 0.207 | |
| 档位基线 + 特征残差 | 档位基线 + 残差 | 0.164 |
误差直接砍掉 77%,而基线本身几乎不花钱:
显示档位本身就是一个非常强的先验,谱面特征只需要做「小幅校正」,不需要「取代官方档位」。
二、前置工作:把谱面变成可分析的数字
2.1 变速谱的时间口径
最容易踩的坑是时长和 NPS(每秒音符数)的计算。老办法拿一个全局 BPM 直接换算,遇到变速谱会系统性算错。比如一段 120→240 BPM 的谱:同样 4 拍,全局口径算出 2.00s,分段积分只有 1.50s(前 2 拍 1.00s + 后 2 拍 0.50s),误差高达 33%。变速前后慢快组合反过来时误差方向还会翻转,所以这不是一个能靠常数修正的偏差:
改进后把任意拍区间 按 BPM 事件切成 段,分段积分:
平均 NPS 用首尾 Note 之间的积分时长;峰值 NPS 则取物量最高的小节、用该小节自己的积分时长换算。相邻重复的 BPM 事件会被合并。
2.2 局部压力:判断「够不够密集」
后面很多配置的判定都依赖一个前提——「这个形态出现在足够密集的上下文里」。我们对任意拍区间定义局部压力:
这个量是后面防误报的关键——同样是「对侧双押」这个几何形态:
- 全谱 60 BPM 悠闲地出现一次 → 压力低 → 不算反手,不误报;
- 出现在 NPS 7 的密集窗口里 → 压力 ≥ 4.5 → 反手候选,能抓到。
压力值本身就把「形态相同但体感不同」的窗口分开了:
那条横线就是反手判定的阈值 4.5——只有第三个窗口能过。
三、15 类配置识别
配置识别是一条三阶段管线:
识别的 15 类标签及核心触发条件:
| 标签 | 主要结构 | 核心触发条件 |
|---|---|---|
| 纵连 | 同键快速重复 | ≥3 次,间隔 ≤0.5 拍 |
| 交互 | 两键 ABAB 交替 | ≥4 Note,间隔 ≤0.5 拍,压力过阈,可跨小节 |
| 散打 | 短窗口四个不同落点 | 未进入扫键/交互,压力 ≥5.5 |
| 扫键 | 相邻键同向连续移动 | ≥4 Note,方向一致,密度校验 |
| 转圈 | 环绕满圈 / 弧线 Slide | 八键覆盖 + 局部压力 / 曲线路径 |
| 大位移 | 短窗口跨键移动 | 距离 ≥3,落点 ≥3,压力 ≥5.5 |
| 定拍 | 稳定等间隔序列 | 连续 6 个物件,浮点容差校验 |
| 拆弹 | 高阶多押 / 触摸组 | ≥4 键;或 3 键且压力 ≥5.5;Touch ≥3 |
| 爆发 | 全谱局部密度峰值 | max(14, 1.6×均值, P90),局部极大 |
| 跳拍 | 非常规细分节奏 | 非 1/4、1/2 落点,或 0.75/0.375/0.1875 拍 |
| 错位 | Slide 等待窗口穿插 Tap | 延迟窗口内 ≥2 时点,持续跨小节合并 |
| 一笔画 | 首尾相接 / 复杂 Slide 路径 | ≥4 连续 Slide,或 S/Z/V 多段路径 |
| 反手 | 对侧键同押 | 环形距离 =4 且局部压力 ≥4.5 |
| 触摸 | 同时触摸组 / Touch 流 | 同时 Touch ≥2,或 ≥4 时点间隔 ≤1 拍 |
| 绝赞段 | 连续 Break 时点 | ≥3 时点间隔 ≤0.75 拍,或 ≥3 重押 |
这 15 类不是散乱的,它们可以放到同一张「密度 × 强度」地图上。横轴是该配置要求的上下文压力(判定门槛),纵轴是命中后的体感强度贡献:
左下角那四类是「到处都有」的常态形态(定拍、跳拍、触摸、绝赞段),右上角是「又密又难」的核心配置(拆弹、转圈、反手、交互)——后面章节会看到,正是这些标签的误报率最需要压。
挑几个有代表性的展开:
扫键与转圈共用环形几何签名。对相邻按键 ,把差值折算到 ,要求步长绝对值为 1 且方向不变:
5 → 6 → 7 → 8 → 1 步长 +1 方向不变 ✅ 扫键候选5 → 6 → 7 → 6 → 5 方向反转 ❌ 不是扫键连续 ≥4 个 Note 且满足速度压力条件标为扫键;步数 ≥8 且覆盖全部八个按键位置则升级为转圈。这样「一整圈星星」和「顺时针扫一段」被统一的一套逻辑区分开。
错位星星:Slide 有一个等待窗口(起始时点 + 1 拍默认延迟 + 最长路径时长),在这个窗口里穿插的非 Slide 按键就是错位候选:
窗口内穿插了至少两个不同时点的 Tap → 错位候选;覆盖三个连续小节则合并为持续错位段。
去噪是最大的一课
初版规则跑完,全库 5,364 张谱面里「反手」命中 4,629 张、「绝赞段」命中 4,829 张——这种命中率显然没有区分度。收紧后:
| 标签 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 反手 | 4,629 | 1,640 | −64.6% |
| 绝赞段 | 4,829 | 809 | −83.2% |
| 拆弹 | 2,457 | 1,412 | −42.5% |
那条横线是全库总量,初版的三个标签几乎贴着天花板(反手命中 86%、绝赞段命中 90%),等于「永远为真」的规则。核心思想就一句话:几何形态 + 局部压力 + 持续性,三个条件都满足才算数。孤立的跨三键双押不再是反手,连续两个 Break 同押也不再是绝赞段。
四、两组雷达:把配置聚合成风格
15 类标签是散点,玩家关心的是「这是一张什么谱」。我们把它们聚合成两组雷达分。
配置雷达对每类标签独立打分,综合三个量:
- 是标记数,用对数缩放防止超长谱面靠「自然多」支配分数;
- 是最高强度;
- 是所有区间并集的覆盖拍数——区分「来了一次强配置」和「全程持续这个配置」。
三个分项的权重形状完全不同,对数项首段涨得最快然后迅速压平,覆盖率项则是缓慢线性累积:
所以一次「来得猛」的配置主要靠强度分项拉到高位,而「全程都有」的配置靠覆盖率线性堆上去,两者不会互相淹没。
评价雷达输出五个轴,构成如下:
| 评价轴 | 主要构成 |
|---|---|
| 体力谱 | 平均 NPS、有效时长×密度、高密小节比例、Hold 占比 |
| 底力谱 | 交互/纵连覆盖、错位与跳拍、大位移/反手、配置多样性 |
| 星星谱 | Slide/Touch 占比、转圈覆盖、一笔画、错位、扫键 |
| 键盘谱 | 非 Slide/Touch 占比(结构前提)、纵连/交互覆盖、定拍、高 NPS |
| 高物量 | 平均 NPS、峰值物量、高密小节比例、BPM |
这一版修掉了一个老问题。旧版「键盘谱」把位置切换率、大跨度和高 BPM 直接相加,导致几乎所有谱都是高分键盘谱——区分度完全没有。用分布看,理想状态下中位分应该落在量程中段,旧版却整体挤在高分区,新版才把曲线拉开:
两条线分别是旧版和新版:旧版从头到尾都有 60 分起步,新版只有尾巴(真·高强度谱)才冲到 95。区分度回来了。
五、定数预测:48 维特征的残差岭回归
到预测这一步,模型输入是 48 个手工特征,分七大类:
| 特征组 | 维度 | 内容 |
|---|---|---|
| NPS 分段铰链 | 7 | , |
| 密度与构成 | 5 | 峰值 NPS、峰均比、滑条/触摸/绝赞占比 |
| 模式标记密度 | 14 | 纵连、转圈、错位……标记次数 ÷ 时长 |
| 评价雷达 | 5 | 体力、底力、星星、键盘、高密度 |
| 曲目属性 | 3 | 对数时长、BPM 偏移、高速惩罚 |
| 难度交互项 | 10 | 、、、…… |
| 标记×雷达交叉 | 4 | 纵连×键盘、转圈×星星…… |
喂给标准化岭回归 ,输出 。
几个设计点:
铰链特征:,节点取 1,2,3,4,5,6,8。因为 NPS 从 3 涨到 4 和从 7 涨到 8,对手指的压力完全不是一回事:
分段线性让模型为每个密度区间学到独立斜率——图上是折线而不是直线,正是这个意思:每过一个节点,斜率就抬一次,代表「同样涨 1 点 NPS,越高密度越贵」。
难度交互项:令 ,加入 、、 等十项。这让系数能随难度档自适应——纵连在 11 级和 14 级对定数的影响显然不同。从特征重要性看,排名靠前的几乎全是这些交互项:
| 排名 | 特征 | 标准化影响 |
|---|---|---|
| 1 | 难度×密度 (lvNps) | 0.505 |
| 2 | 难度区间 (lvCenter) | 0.324 |
| 3 | 难度×体力 (lvStamina) | 0.210 |
| 4 | 难度平方 (lvSquare) | 0.196 |
| 5 | 中密度 (npsHinge3) | 0.152 |
注意第一名 0.505 已经是第二名 1.5 倍以上,而且前四名里有三个带难度符号——说明「同一特征在不同档位权重不同」正是模型捕捉到的核心信号。
交叉项捕捉「组合杀」:纵连×键盘、转圈×星星、滑条占比×星星、交互×技术。纵连本身难,出现在键盘谱里更难;转圈多的星星谱体感明显上调。这种协同效应是单层线性模型本来表达不了的,手工选四个最关键的补上。
岭回归 + 按曲目分组 CV:λ 通过交叉验证从 里选。交叉验证必须按 songId 分组——同一首歌的 BASIC 到 Re
结果
| 指标 | 数值 |
|---|---|
| 训练样本 | 5,364 张谱面 |
| 基线 MAE(直接用显示难度) | 0.182 |
| 分组 CV MAE | 0.164 |
| CV RMSE | 0.209 |
| 误差 ≤ ±0.3 | 88.0% |
| 误差 ≤ ±0.4 | 95.2% |
| 误差 ≤ ±0.5 | 97.4% |
误差随容忍阈值放宽的累积曲线:
曲线在 ±0.2 之后陡升、±0.4 之后基本压平——换句话说,绝大部分剩余误差都落在 ±0.4 这个「档位判别带」内,正好是我们做水谱/诈称三分类需要的那条线。训练 MAE 与 CV MAE 几乎一致,没有明显过拟合。对一个纯线性、完全可解释、前端零依赖的模型来说,这个精度相当够用了。
六、推理:系数硬编码进前端
训练完全离线,运行时不需要 Python 也不需要模型服务:
48 个系数直接硬编码在 TypeScript 里,与 Python 模型的一致性由 verify-model.ts 抽样 200 张校验(最大差 ≤0.02)。
三分类的判定边界就是一条数轴上的三个区间:δ < −0.4 判 💧 水谱(偏易),δ > +0.4 判 🔥 诈称(偏难),其余落在 ✅ 正常区间。
线性模型之外还留了一层配置敌意度修正。设 为拍号变化和 9 种棘手模式的加权雷达分(纵连 0.22、拆弹 0.25、错位 0.18、触摸 0.22、转圈/扫键 0.12……), 为该难度下的「平均恶心程度」基线,则超出基线的部分 按下式换算成定数修正:
先线性、后平方、最后卡在 0.65——保证「恶心配置」能加分,但不会让任何单一因素把定数顶飞。这解释了那些物量不夸张、配置却恶心到爆的谱:线性特征表达不了的「恶心人」,就单独修。
最后推理时还返回贡献最大的 5 个特征,前端能直接告诉玩家:
算法做出来是给人看的,可解释性不是附加品,是需求。
七、局限
- 线性残差模型捕捉不了高阶交互,四个交叉项是手工选的,覆盖有限;
- 模式检测的阈值是人工调的,标签来自规则而非监督数据——后续计划把社区的「配置纠错」审核结果转化为监督信号,真正算 precision / recall / F1;
fit_diff本身是玩家表现的统计量,游玩人数少的谱面噪声大;- 更远的方向是序数回归 / 时序模型,把逐 Note 事件序列编码进模型,同时输出「群体拟合难度」和「个人预期难度」两套指标。
这套实现的价值不在精度天花板,而在于可解释、推理快、确定性、前端零依赖之间取了一个务实的平衡。分析器和训练管线都是我们自己维护的,等到打磨稳定了再考虑开源喵。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!



