聊聊我用Suno给推荐算法模型做数据增强的那些骚操作
作者:老陈 | 小红书推荐算法工程师 | 坐标深圳南山
早上8点,咖啡还没喝完,先给大伙整点硬核的。
0x00 写在前面
先自我介绍下,免得你们以为又是哪个AI生成的水文。
我是老陈,在小红书做了两年推荐算法,主要负责用户增长方向,说白了就是怎么让更多人来、来了别走、走了还能回来。坐标深圳南山,对,就是那个外卖都卷到20分钟送达的地方。
平时除了搞推荐策略,我对云原生和K8s也比较熟,毕竟现在算法工程师不会点工程化的活儿,都不好意思跟人说自己是全栈(笑)。每天早上8点准时到工位,不是因为我多勤奋,是因为8点公司食堂的肠粉最好吃,去晚了就没了。
今天这篇文章,说实话,跟我日常工作没有半毛钱关系。
事情是这样的——上周五晚上,我们组在赶一个多模态推荐的deadline,产品经理(对,就是那个永远觉得"这个需求很简单"的产品经理)突然跑过来说:"老陈啊,我们能不能给短视频加个'氛围感'标签?就是那种,用户一看封面就知道这个视频是'治愈系'还是'燃系'的。"
我当时第一反应是:你搁这搁这呢?
但冷静下来一想,这需求其实挺有意思的。传统的CV分类模型,什么ResNet、EfficientNet,分类的都是具体物体——猫、狗、汽车、美食。但"氛围感"这个东西,它不是一个具体的物体,它是一种整体的视觉感受。颜色基调、光影、构图、运动模糊……这些东西混在一起,才构成了所谓的"氛围"。
更骚的是,我们当时正好在调研Suno这个AI音乐生成工具。等等,你可能会问——Suno不是搞音乐的吗?跟计算机视觉有毛关系?
别急,这就是今天这篇文章的核心:我怎么用Suno生成的音乐,反过来辅助CV模型做视频氛围感分类的数据增强。
听起来很离谱对吧?但听我慢慢道来。
0x01 问题拆解:氛围感到底是个啥
先说业务背景。我们小红书的推荐流里,短视频占比越来越高。用户刷推荐页的时候,封面图是第一印象。我们发现一个现象:封面图的"氛围感"和用户点击率有很强的相关性。
举个例子:
- 同样是拍咖啡,暖色调+浅景深+木质桌面的封面,CTR比白底+全焦的封面高23%
- 同样是拍穿搭,胶片质感的封面比手机直出的封面,完播率高18%
所以产品想做的事情是:在推荐排序阶段,给视频打上一个"氛围感"标签,然后做个性化的氛围推荐。 比如深夜时段,给情绪低落的用户多推"治愈系"氛围的视频。
那问题来了,怎么用算法来定义和识别"氛围感"?
我们内部讨论了几轮,最后把氛围感拆成了5个维度:
| 维度 | 描述 | 示例标签 |
|---|---|---|
| 色调 | 画面的整体色彩倾向 | 暖调、冷调、莫兰迪、赛博朋克 |
| 光影 | 光线的质感和方向 | 柔光、硬光、逆光、霓虹 |
| 构图 | 画面的空间布局 | 居中、三分法、留白、对称 |
| 质感 | 画面的纹理和颗粒感 | 胶片、数码、水彩、油画 |
| 情绪 | 画面传递的情感倾向 | 治愈、燃、孤独、浪漫 |
前四个维度,传统的CV算法其实能搞定一部分。比如色调可以用颜色直方图+聚类,构图可以用显著性检测。但第五个维度——"情绪",这个就玄学了。
你让一个CNN去判断一张图是"治愈"还是"燃",它大概率会懵逼。因为"治愈"和"燃"不是视觉特征,而是人类的主观感受。
这就引出了我们的核心思路:用多模态的方式来解决。
既然纯视觉不好判断"情绪",那如果我们给视频配上合适的音乐,让人类标注员在"有音乐"的情况下标注情绪,标注准确率会不会更高?
答案是:会。而且高得离谱。
0x02 Suno登场:AI音乐当标注辅助
这里要解释一下Suno。如果你还没听过Suno,简单说就是一个AI音乐生成工具,你给它一段文字描述,它就能给你生成一首完整的歌,包括人声、编曲、混音,效果相当炸裂。
我们的思路是这样的:
- 先用传统CV模型(基于EfficientNet-B3)对视频封面做初步的视觉特征提取
- 根据视觉特征,自动生成一段Suno的prompt,比如"lo-fi chill beat, warm tone, rainy day vibe"
- 用Suno生成一段15秒的氛围音乐
- 标注员在看封面图的同时,听着这段音乐来标注"情绪"维度
- 用这批高质量标注数据训练一个多模态的情绪分类模型
你可能会说:这不是脱裤子放屁吗?直接让人标注不就行了?
兄弟,你太天真了。我们之前做过实验,纯视觉标注"情绪"维度,标注员之间的一致性(Kappa系数)只有0.41,基本就是各标各的。但加了音乐辅助之后,Kappa系数直接飙到了0.78。
音乐给了标注员一个"情绪锚点",让他们对"这个画面是什么感觉"有了统一的参考系。
这就是Suno在我们项目里的角色——它不是主角,但它是一个极其关键的"工具人"。
0x03 技术实现:从CV到Suno的Pipeline
好,说了这么多背景,来点硬核的。
整个Pipeline我分成了四个模块:
模块一:视觉特征提取
这部分比较常规,用的是EfficientNet-B3做backbone,在ImageNet上预训练,然后在我们的内部数据集上微调。
import torch
import torch.nn as nn
from efficientnet_pytorch import EfficientNet
class VibeFeatureExtractor(nn.Module):
def __init__(self, num_atmosphere_dims=4):
super().__init__()
# 用EfficientNet-B3提取视觉特征
self.backbone = EfficientNet.from_pretrained('efficientnet-b3')
# 冻结前面的层,只微调最后几层
# 别问为什么,问就是显卡不够用(苦笑)
for param in self.backbone._blocks[:15].parameters():
param.requires_grad = False
# 输出4个维度的视觉特征(色调、光影、构图、质感)
# 情绪维度不在这一步做,后面单独搞
self.head = nn.Sequential(
nn.Linear(1536, 512),
nn.BatchNorm1d(512),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(512, num_atmosphere_dims * 10) # 每个维度10个特征
)
def forward(self, x):
# x: [B, 3, 224, 224]
features = self.backbone.extract_features(x)
features = self.backbone._avg_pooling(features)
features = features.flatten(1)
out = self.head(features)
return out # [B, 40]
这里有个小坑说一下。一开始我用的是ResNet-50,结果在"质感"这个维度上效果很差。后来分析发现,ResNet的下采样太猛了,胶片的颗粒感、水彩的纹理这些高频信息全被pooling掉了。换成EfficientNet之后,因为它用了更细粒度的特征金字塔,这些纹理信息保留得更好。
教训:做CV的时候,别无脑上大模型,先想想你的任务到底需要什么级别的信息。
模块二:Suno Prompt自动生成
这是整个Pipeline里最有意思的部分。我们需要根据视觉特征,自动生成Suno能理解的prompt。
class SunoPromptGenerator:
"""
根据CV模型提取的视觉特征,生成Suno的prompt
这部分逻辑是我们调了两周才调出来的,全是经验值
"""
# 色调到音乐风格的映射表
TONE_TO_STYLE = {
'warm': 'lo-fi, warm, cozy, acoustic guitar',
'cool': 'ambient, ethereal, reverb, spacious',
'morandi': 'minimalist, soft piano, gentle, muted',
'cyberpunk': 'synthwave, neon, electronic, bass-heavy',
}
# 光影到音乐情绪的映射
LIGHT_TO_MOOD = {
'soft': 'gentle, calming, peaceful',
'hard': 'intense, dramatic, powerful',
'backlit': 'mysterious, dreamy, atmospheric',
'neon': 'futuristic, energetic, pulsing',
}
def __init__(self, cv_model):
self.cv_model = cv_model
self.cv_model.eval()
def generate_prompt(self, image_tensor):
"""
输入:图片tensor
输出:Suno prompt字符串
"""
with torch.no_grad():
features = self.cv_model(image_tensor.unsqueeze(0))
# features: [1, 40],前10个是色调,10-20是光影,以此类推
tone_features = features[0, :10]
light_features = features[0, 10:20]
# 用聚类中心匹配最近的风格标签
tone_label = self._match_cluster(tone_features, 'tone')
light_label = self._match_cluster(light_features, 'light')
# 拼接prompt
style = self.TONE_TO_STYLE.get(tone_label, 'indie, alternative')
mood = self.LIGHT_TO_MOOD.get(light_label, 'chill, relaxed')
prompt = f"{style}, {mood}, instrumental, 15 seconds"
return prompt
def _match_cluster(self, features, dim_type):
"""
用预计算的聚类中心做最近邻匹配
别用KNN,太慢了,我们这是批量处理
"""
# 省略具体实现,核心就是cosine similarity
pass
这里有个细节值得说。Suno的prompt其实很讲究,你写"lo-fi beat"和写"lo-fi hip hop beat with vinyl crackle",出来的效果差很多。我们当时试了上百种prompt组合,最后沉淀了一套模板库。
做算法的都知道,很多时候效果好不好,不取决于模型多牛,取决于你的数据预处理和特征工程有多细。 这话虽然老套,但真的是真理。
模块三:Suno API调用与音乐生成
Suno目前没有官方API(截至我写这篇文章的时候),所以我们用的是非官方的方式。这里不细说了,懂的都懂,不懂的也别问我,我怕被封号(狗头)。
大体流程就是:
- 把prompt发给Suno
- 等个30秒到1分钟(Suno生成速度还行)
- 拿到生成的音频文件
- 裁剪到15秒
- 存到OSS上
# 伪代码,实际调用方式请自行研究
# 批量生成脚本,配合K8s Job跑
apiVersion: batch/v1
kind: Job
metadata:
name: suno-batch-generation
namespace: algorithm-dev
spec:
completions: 50 # 并发50个worker
parallelism: 10
template:
spec:
containers:
- name: suno-worker
image: registry.xxx.com/algorithm/suno-worker:v2.1
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"
env:
- name: SUNO_PROMPT_FILE
value: "/data/prompts/batch_20240315.txt"
- name: OUTPUT_OSS_PATH
value: "oss://xxx-algorithm/suno-outputs/20240315/"
volumeMounts:
- name: data-volume
mountPath: /data
restartPolicy: Never
对,你没看错,我把Suno的批量生成任务跑在K8s上了。没办法,谁让我对K8s比较熟呢。而且这样好监控,哪个worker挂了直接重启,不用人盯着。
这里踩了个坑:Suno生成音乐的时候,偶尔会生成带人声的,但我们要求的是纯音乐(instrumental)。一开始没在prompt里强调"instrumental",结果生成了一堆带歌词的,标注员听着听着就开始跟着唱了,标注效率直接腰斩。
教训:跟AI打交道,prompt里该写的限制条件一个都不能少。你以为它懂,其实它不懂。
模块四:多模态情绪分类模型
最后一步,用标注好的数据训练情绪分类模型。
这里我们用的是一个双流架构:一处理视觉特征,一处理音频特征(对,虽然最终上线的时候只用视觉,但训练的时候把音频也加进来了,做知识蒸馏的teacher)。
class MultiModalVibeClassifier(nn.Module):
"""
多模态氛围感分类模型
训练时用视觉+音频双模态
推理时只用视觉(音频分支作为teacher做蒸馏)
"""
def __init__(self, num_emotion_classes=5):
super().__init__()
# 视觉分支
self.visual_encoder = EfficientNet.from_pretrained('efficientnet-b3')
self.visual_head = nn.Sequential(
nn.Linear(1536, 256),
nn.ReLU(),
nn.Dropout(0.2)
)
# 音频分支(只在训练时用)
# 用的是PANNs的预训练模型,Cnn14架构
self.audio_encoder = Cnn14(embedding_dim=128)
self.audio_head = nn.Sequential(
nn.Linear(128, 256),
nn.ReLU(),
nn.Dropout(0.2)
)
# 融合层
self.fusion = nn.Sequential(
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, num_emotion_classes)
)
# 蒸馏用的投影头
self.visual_projection = nn.Linear(256, 128)
self.audio_projection = nn.Linear(256, 128)
def forward(self, visual_input, audio_input=None, is_training=True):
# 视觉特征
v_feat = self.visual_encoder.extract_features(visual_input)
v_feat = self.visual_encoder._avg_pooling(v_feat).flatten(1)
v_emb = self.visual_head(v_feat)
if is_training and audio_input is not None:
# 训练模式:双模态融合
a_emb = self.audio_head(self.audio_encoder(audio_input)['embedding'])
fused = torch.cat([v_emb, a_emb], dim=-1)
logits = self.fusion(fused)
# 知识蒸馏loss
v_proj = F.normalize(self.visual_projection(v_emb), dim=-1)
a_proj = F.normalize(self.audio_projection(a_emb), dim=-1)
distill_loss = 1 - (v_proj * a_proj).sum(dim=-1).mean()
return logits, distill_loss
else:
# 推理模式:只用视觉
# 这时候visual_head的输出直接接一个分类头
logits = self.fusion(torch.cat([v_emb, torch.zeros_like(v_emb)], dim=-1))
return logits
这里的设计思路是:训练的时候,音频分支充当"情绪理解"的teacher,通过知识蒸馏把音频中对情绪的理解能力迁移到视觉分支上。 推理的时候,音频分支扔掉,只用视觉,保证线上推理速度。
这个思路其实不新,多模态知识蒸馏在学术界已经有很多工作了。但在实际业务中用起来,效果确实不错。我们的情绪分类准确率(5分类)从纯视觉的67%提升到了79%,而且推理速度没有增加,因为音频分支在推理时根本不存在。
0x04 踩坑记录与性能优化
说到这,必须聊聊踩过的坑。不踩坑的项目是不完整的(自我安慰)。
坑一:Suno生成的音乐质量不稳定
Suno偶尔会生成一些很奇怪的音乐,比如突然来一段死亡金属,或者诡异的静音。我们一开始没做质量过滤,导致标注数据里混进了一些"垃圾"音乐,标注员的情绪判断被带偏了。
解决方案:加了一个音频质量过滤模块,用librosa提取音频的频谱特征,过滤掉异常样本。
import librosa
import numpy as np
def filter_abnormal_audio(audio_path, sr=22050):
"""
过滤Suno生成的异常音频
判断标准:
1. 频谱质心过低(可能是静音或噪声)
2. 过零率过高(可能是噪声)
3. RMS能量过低(可能是静音)
"""
y, sr = librosa.load(audio_path, sr=sr)
# 频谱质心
spectral_centroid = np.mean(librosa.feature.spectral_centroid(y=y, sr=sr))
# 过零率
zero_crossing_rate = np.mean(librosa.feature.zero_crossing_rate(y))
# RMS能量
rms = np.mean(librosa.feature.rms(y=y))
# 阈值是我们统计了1000条正常样本后定的
if spectral_centroid < 500 or zero_crossing_rate > 0.15 or rms < 0.01:
return False # 异常,过滤掉
return True # 正常
坑二:标注员疲劳
你以为标注是个轻松的活儿?连续标8小时,看到第2000张图的时候,标注员已经分不清"治愈"和"孤独"了。
解决方案:
- 每个标注员每天最多标500条
- 每标50条休息10分钟
- 插入"锚点样本"(我们已经确定标签的样本),如果锚点标错了,提示标注员集中注意力
- 每条样本至少3个人标,取多数投票结果
坑三:线上推理延迟
我们线上要求推理延迟P99 < 50ms。EfficientNet-B3在T4 GPU上单张推理大概要12ms,看起来够用。但实际上了Triton Inference Server之后,加上前后处理,P99飙到了68ms。
优化手段:
| 优化项 | 优化前P99 | 优化后P99 | 备注 |
|---|---|---|---|
| 原始模型 | 68ms | 68ms | baseline |
| TensorRT加速 | 68ms | 31ms | FP16模式 |
| 动态batch | 31ms | 22ms | batch_size=8 |
| 图片预处理GPU化 | 22ms | 18ms | 用DALI替代OpenCV |
| 模型剪枝 | 18ms | 15ms | 结构化剪枝30% |
最后P99稳定在15ms左右,完全满足要求。
这里必须夸一下TensorRT,加速效果真的猛。但坑也不少,主要是EfficientNet里的一些算子TensorRT不支持,需要自己写plugin。我当时为了一个Swish激活函数的plugin,debug了整整两天,差点把键盘砸了。
0x05 效果与反思
项目上线一个月后,我们看了下数据:
- 带"氛围感"标签的视频推荐,CTR提升了8.3%
- 用户平均刷推荐页的时长增加了1.2分钟
- 深夜时段"治愈系"氛围推荐的点击率比白天高34%(果然深夜emo的人多)
数据还不错,至少没被产品diss(难得)。
回过头来反思这个项目,有几点心得:
1. 不要迷信单一模态
纯视觉做"氛围感"分类,天花板很明显。引入音频辅助标注,本质上是用另一个模态的信息来弥补视觉模态的不足。这个思路在很多场景都适用。
2. 工具的价值在于怎么用
Suno本身是个音乐生成工具,跟CV八竿子打不着。但换个思路,它就成了我们数据标注流程中的关键一环。技术的价值不在于它本身多酷,在于你能不能把它用在正确的地方。
3. 工程能力是算法落地的基础
从K8s上跑批量任务,到Triton上做模型服务,到TensorRT加速,这些工程化的东西看起来不"算法",但没有它们,你的模型就只是个Jupyter Notebook里的玩具。
4. 标注质量决定上限
我们花了将近两周时间在标注流程的优化上,比模型调优的时间还长。但事实证明这是值得的。标注质量上去了,模型效果自然就上去了。Garbage in, garbage out,这话永远不会过时。
0x06 一些碎碎念
写到这里,已经凌晨1点了。本来8点就开始写的,结果中间被拉去开了个需求评审会,又被测试拉去看了个线上bug,折腾到现在。
说实话,做推荐算法这两年,最大的感受就是:算法本身只占30%的工作量,剩下70%是数据、工程、沟通、和无尽的debug。 但正是这70%的"脏活累活",决定了一个算法项目能不能真正落地产生价值。
很多人觉得算法工程师就是调参侠,天天在Jupyter Notebook里跑模型。但实际上,一个合格的算法工程师,你得懂数据、懂工程、懂业务、甚至懂点产品。不然你模型跑得再好,落不了地,白搭。
好了,不扯了,明天还得早起抢食堂的肠粉呢。
如果你对这个项目有什么疑问,或者想聊聊推荐算法、CV、K8s相关的东西,欢迎在评论区留言。我看到都会回的(只要不是问我Suno的调用方式,那个真的不能说,狗头保命)。
最后,愿天下没有难标的注,愿所有模型都能一次收敛。
阿门。
参考资料
- EfficientNet论文:https://arxiv.org/abs/1905.11946
- PANNs(音频分类预训练模型):https://arxiv.org/abs/1912.10211
- TensorRT文档:https://docs.nvidia.com/deeplearning/tensorrt/
- Triton Inference Server:https://github.com/triton-inference-server/server
声明:本文中Suno相关的调用方式仅供学习交流,请勿用于商业用途。尊重AI生成内容的使用规范。


评论 0