聊聊我用Suno给推荐算法模型做数据增强的那些骚操作

陈静
2026-07-10 17:58
阅读 733

作者:老陈 | 小红书推荐算法工程师 | 坐标深圳南山

早上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音乐生成工具,你给它一段文字描述,它就能给你生成一首完整的歌,包括人声、编曲、混音,效果相当炸裂。

我们的思路是这样的:

  1. 先用传统CV模型(基于EfficientNet-B3)对视频封面做初步的视觉特征提取
  2. 根据视觉特征,自动生成一段Suno的prompt,比如"lo-fi chill beat, warm tone, rainy day vibe"
  3. 用Suno生成一段15秒的氛围音乐
  4. 标注员在看封面图的同时,听着这段音乐来标注"情绪"维度
  5. 用这批高质量标注数据训练一个多模态的情绪分类模型

你可能会说:这不是脱裤子放屁吗?直接让人标注不就行了?

兄弟,你太天真了。我们之前做过实验,纯视觉标注"情绪"维度,标注员之间的一致性(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(截至我写这篇文章的时候),所以我们用的是非官方的方式。这里不细说了,懂的都懂,不懂的也别问我,我怕被封号(狗头)。

大体流程就是:

  1. 把prompt发给Suno
  2. 等个30秒到1分钟(Suno生成速度还行)
  3. 拿到生成的音频文件
  4. 裁剪到15秒
  5. 存到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张图的时候,标注员已经分不清"治愈"和"孤独"了。

解决方案

  1. 每个标注员每天最多标500条
  2. 每标50条休息10分钟
  3. 插入"锚点样本"(我们已经确定标签的样本),如果锚点标错了,提示标注员集中注意力
  4. 每条样本至少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的调用方式,那个真的不能说,狗头保命)。

最后,愿天下没有难标的注,愿所有模型都能一次收敛。

阿门。


参考资料

声明:本文中Suno相关的调用方式仅供学习交流,请勿用于商业用途。尊重AI生成内容的使用规范。

评论 0

最热最新
暂无评论
陈静Lv.1
0
影响力
0
文章
0
粉丝