VOCALOID歌曲的小样本偏好筛选
写在前面
简单说就是想给自己的 V 家收藏做点什么嘛。
大概半年前?有过一次脱离音乐平台自建曲库的尝试,当时想的是把所有歌都存本地,然后用某种神奇的方式管理起来。结果……失败了。具体怎么失败的我已经忘了,大概率是因为懒(划掉),也可能是发现维护成本太高,总之那 1000 多首从网易云批量下载下来的 VOCALOID 就一直躺在硬盘里吃灰。
直到前几天闲来无事翻文件夹,看到那个 VOCALOID/ 目录,突然又来了兴致。
大体思路
嘛,既然要重建曲库,总得有个核心功能对吧。我其实有个具体的目标:从这 1000 多首歌里,用算法帮我筛选出”真正喜欢”的那些。
思路是这样的——先问 ChatGPT:”音乐领域的 BERT 是什么?”它告诉我叫 MERT。诶,名字都差不多嘛,BERT 是 Bidirectional Encoder Representations from Transformers,MERT 是 Musical Encoder Representations from Transformers,挺对仗的。
技术方案很简单:
用 MERT 把每首歌编码成一个向量(song-level embedding)。具体做法是均匀抽取 8 个 12 秒的片段,分别过模型拿到 hidden states,对时间维度做 mean pooling,再把 8 个片段的向量平均,最后 L2 归一化就拿到代表一首歌的一个向量拉。
喜欢/不喜欢的偏好数据有些低效,想用 pairwise comparison——随机抽两首歌 A 和 B,我听一下告诉模型”我更喜欢 A”。
等偏好模型训练好了,直接给所有 1000 多首歌打分,就能听自己最喜欢的歌曲了。
技术上选的是 m-a-p/MERT-v1-330M,3.3 亿参数,HuggingFace 上直接 from_pretrained 就可以。在本地跑,不用 API,毕竟 1000 多首歌呢,调用成本太高。
于是开搞。
预处理
第一次踩坑
一开始直接用 GPT 给我的脚本,大概逻辑是:
- 扫描目录
- 对每首歌:
- 抽取 8 个片段
- 逐个片段丢进模型推理
- 保存 embedding
在我的 5070 笔记本上跑了一下……坏哦,这也太慢了吧?
总感觉是串行处理的锅——8 个片段要循环 8 次……
于是开始优化。
和 Kimi 讨论了一下,确定了几个优化点:
| 优化项 | 原理 | 预期效果 |
|---|---|---|
| 片段批处理 | 把一首歌的 8 个片段打包成 batch,一次性送进模型 | GPU 利用率拉满 |
| 自动混合精度 (AMP) | torch.cuda.amp.autocast,用 FP16 计算 | 提速 20-30%,省显存 |
| 多进程数据加载 | DataLoader + num_workers | CPU 预处理不阻塞 GPU |
| 模型编译 | torch.compile (PyTorch 2.0+) | 再快 10-20% |
改完代码跑了一下,诶!真的快了很多!从原来那种”看着进度条发呆”变成”唰唰唰”的感觉。
第二次踩坑
正当我美滋滋地看着进度条往前冲时,powershell 窗口突然没了。鼠标也卡死,过了一会才好。
……哈?
看了眼任务管理器,发现是刚才内存占满了,所以卡死。但奇怪啊,5070 有 8GB 显存,我的电脑有 32GB 内存。现在正在排查是哪里的内存泄漏,或者是不是 DataLoader 的 worker 进程没释放。
困,所以先记到这里,等找到原因再继续写。
第三次踩坑与解决
小睡一会。
醒来继续排查,让 GPT 帮我看了下代码,发现是 DataLoader 的 num_workers 设置的问题!
简单说就是,我之前设置了 num_workers=4,但 DataLoader 的 worker 进程在处理完数据后没有正确释放,导致内存越积越多,最后把 32GB 内存撑爆了。Poweshell 窗口消失就是因为系统杀掉了占用内存过多的进程。
解决思路也很简单——干脆不用 DataLoader 了!反正音频文件列表已经在内存里了,直接用 Python 的 tqdm 迭代器配合手动 batching 就行。改完后的脚本就是 extract_mert_embeddings_final.py。
另外还做了几个小优化:
- 分段读取音频:不再把整个音频文件读进内存,而是用
soundfile的 seek 功能只读需要的片段 - 及时清理显存:每处理一定数量就调用
torch.cuda.empty_cache() - 更保守的批处理:batch_size 设为 8,避免一下子塞太多给 GPU
重新跑了一下……二十分钟就全部处理完了! 做对了诶!
这次生成了两个关键文件:
manifest.jsonl:记录每首歌的元数据和 embedding 路径embeddings/*.npy:1199 个 1024 维的向量文件
偏好训练
从 MLP 到 XGBoost
有了 embedding,下一步就是训练偏好模型。
一开始 GPT 给我写的方案是用 MLP(多层感知机),就是那种很普通的神经网络,输入 embedding 输出一个分数。但我试了一下发现效果不太行——小样本下神经网络太容易过拟合了。
然后 GPT 提到了一个算法 XGBoost。
简单说,XGBoost 是一种决策树集成算法,特别擅长处理表格数据。它在小样本下的表现比神经网络稳定得多,而且训练速度快,还能输出特征重要性,看看到底 embedding 的哪些维度对偏好判断更重要。
核心思路是 pairwise ranking:
- 每次选两首歌 A 和 B
- 我听一下,告诉模型”我更喜欢 A”
- 模型把 (A, B) 作为一个训练样本,学习”在相似的歌曲中,什么样的特征组合代表’更好’”
- 用 XGBoost 的
rank:pairwise目标函数来优化
GPT 写了 preference_pairwise_webui.py,用 Gradio 做了一个简单的 Web 界面:
python preference_pairwise_webui.py --server_port 7860 --inbrowser界面长这样:
- 上面是搜索框,可以搜索歌名找种子歌曲
- 中间是两首待比较的歌曲,可以直接播放
- 下面是”更喜欢 A”、”更喜欢 B”、”跳过”三个按钮
- 右侧实时显示当前 Top 30 推荐
标注 30 个就烦了……
计划是标 100 对以上,让模型充分学习我的偏好。但事实是——标了大概 30 个我就烦了(?)
Pairwise 标注真的很累人!要一直做二选一,听着听着就开始怀疑人生,我感觉我就像那个数据标注女工。
于是我干脆 摆烂了——直接下载 XGBoost 生成的 ranking.csv,取前 100 名复制到 favorites/ 文件夹里。
结果……莫名的好听!
我随机播了几首,发现排名靠前的确实都是我喜欢的风格:MIMI 和 Deco*27……甚至还有几首我忘记名字但一听前奏就“哦哦哦这首!”的老歌。
看来虽然只标了 30 对,但种子歌曲选得好(我挑了大概 15 首确定喜欢的作为种子),加上 XGBoost 本身在小样本下的稳定性,效果已经很不错了。
总结与下一步
做到这里,整个流程是:
| 步骤 | 工具/脚本 | 耗时 | 产出 |
|---|---|---|---|
| 提取 embedding | extract_mert_embeddings_final.py | ~20 分钟 | 1199 个向量 |
| 偏好训练 | preference_pairwise_webui.py | ~30 分钟(标注) | ranking.csv |
| 生成歌单 | 用 OpenCode Agent 复制前 100 名 歌曲 | 2 分钟 | favorites/ 文件夹 |
总共不到一小时,就从 1199 首歌里筛选出了 100 首”最可能喜欢”的。
进一步做的话可以……
虽然现在的结果已经让我很满意了,但还有很多可以优化的地方:
现在的 pairwise 选择策略是随机挑排名相近的歌。可以改成不确定性采样——选模型最”纠结”的 pair(也就是模型给出的分数差距最小的),这样每次标注都能带来最大信息增益。
MERT 只编码了音频信息,但可以结合歌词文本(用 BERT 编码)和元数据(艺术家、专辑、年代等),做多模态融合。毕竟有时候喜欢一首歌不只是因为旋律,还因为歌词戳中了某个点。
不过嘛……先让我把这 100 首歌听完再说!
写在最后
这个项目让我重新捡起了那堆吃灰的 VOCALOID 歌曲。算法帮我筛选出来的歌单确实质量很高,甚至有几首我以前经常单曲循环但忘记了名字的宝藏曲目。
技术上最意外的收获是——小样本下 XGBoost 真的比神经网络好用太多。以前总觉得深度学习万能,但这次实践证明,有时候简单的决策树集成反而更靠谱。
还有就是 pairwise 标注比我想象的累多了。如果不是特别闲,可能还是直接用种子歌曲的相似度+10-20个偏好排序就够了(效果已经很不错)。
总之,歌单建好了,耳机戴上,开始循环播放!
代码仓库:Coming sooooooooooooon