用一台 16GB 显卡,把「我喜欢什么样的视频」教给 4B 视觉模型
这篇是一份实验记录。目标不是做通用内容审核,也不是训练一个公开分类器,而是:在自己的片库里,用已经打过的 1–5 分,让一个小视觉语言模型学会我的口味——看完几张关键帧之后,能给出结构化观察,顺便再补一个分。
先交代清楚:机器是 RTX 4060 Ti 16GB,整次监督微调跑下来大约一个半小时。这个规模能出什么、出不了什么,下面全是真实数据和结论。
背景
我这边有一套本地片库流水线:抽关键帧、用冻结的视觉编码器做向量,再在上面接一个很小的回归头去拟合人工分数。这条路对「整体像不像我喜欢的东西」有用,但对为什么喜欢 / 为什么不喜欢几乎是黑盒。
后来想加一层更可读的判断:模型先把画面写成结构化观察(人物、构图、场景、时长线索、和口味卡的吻合/偏离),再给出 1–5 分。分数仍然以人工标注为准,观察文字只当作推理过程,而不是让模型自己发明标签。也就是让模型「开口说人话」,而不是直接甩一个黑盒分数。
数据规模不大:全库大约 3700 条已评分样本。5 分很少,1/2 分很多,直接全量训练会被低分短片淹没。所以这次先做 SFT,样本是刻意配过的。
为什么选 Qwen3-VL-4B-Instruct
候选其实不少:更小的 2B、更大的 8B/9B、Thinking 系列,以及各种「去拒绝」改版。最后锁的是官方 Qwen3-VL-4B-Instruct。理由不太玄学,都是踩点之前想清楚的:
- 4B 能在 16GB 上用 QLoRA 跑多图。 8B 多图会很紧,2B 又容易把细的构图差写糊。
- Instruct 而不是 Thinking / Base。 这次要的是稳定的 JSON 观察,不是长篇思维链;Base 还要再做指令对齐。
- 原生多图,而不是拼贴。 官方 VL 可以直接吃 6 张按时间排列的帧,并带上时间戳,不必把视频压成一张拼图。
- 不用第三方 Uncensored / abliterated 权重当底模。 这类权重往往先伤视觉指令跟随。片库本身偏私人向,观察需要写得具体,但这是数据和提示词的事,不该靠来路不明的拒答消融来解决。官方 Instruct 加上写清楚的观察样本,足够让模型按格式输出。
训练加载用的是 Unsloth 的 4bit 路径;权重本身来自官方 Instruct。
数据怎么做
标签
分数是我自己打的 1–5,不另请模型当评委。分布大致是:低分很多、中高分少、5 分最少。这很像真实片库:随手划走的短片远多于反复看的长片。
一个很稳的信号是时长。低分里大量是几十秒的切片,高分中位往往是完整过程、业余机位、同一空间拍很久。模型如果只看文件名或封面,会学歪,所以训练时关键帧是主输入,文件名和专辑名会随机丢掉。
抽样
没有把 3700 条全送进 SFT。策略是:
- 4 分、5 分:全要(约 871 + 258)
- 1 / 2 / 3 分:各抽 258 条,和 5 分数量对齐,避免模型只会说「低分」
- 合计 1903 条观察样本
划分沿用已有的 train / val / test,缺划分的用固定种子补上。最终大约是 train 1363、val 271、test 269。test 全程不碰。
关键帧
每个视频最多取 6 帧,按时间均匀抽,带时间戳。短片可能只有 1–2 帧,这本身就是特征:信息不够、过程不完整。图像在训练里把长边收到 384,主要为了 16GB 显存。
不用拼贴图。提示词里会写「图1=12.3s,图2=…」,让模型和跨帧变化对齐。
观察文本
观察不是「好看 / 不好看」。每条样本是一份固定字段的 JSON,大致包括:人数、脸是否可见、体型与发型、服装、场景、镜头、动作、画质、跨帧发生了什么、匹配点、偏离点、倾向(high / mid / low)、一段 90–150 字的叙述,最后才是人工分数。
写观察时有几条硬规则:
- 只写画面里看得到的东西,文件名最多当弱提示
- 4 分不要写成 5 分:对得上口味但缺脸、偏短、棚感更重,倾向用 mid
- 低分要把「为什么不像我喜欢的」写具体,而不是一句「不符合」
- 文本侧做 dropout:大约一半样本不给文件名,三成左右不给专辑名,逼模型看图
这样 SFT 学的是「看帧 → 写观察 → 对齐我的分数」,而不是背标签词。
训练怎么做
环境在 WSL2 里,框架是 Unsloth + TRL SFTTrainer,模型接口是 FastVisionModel。视觉塔冻住,只在语言侧挂 LoRA。多图数据用 Unsloth 的 vision collator。
主要设置如下。
| 项 | 值 |
|---|---|
| 底模 | Qwen3-VL-4B-Instruct(4bit QLoRA) |
| 显卡 | RTX 4060 Ti 16GB |
| LoRA rank / alpha | 16 / 16 |
| 视觉层 | 冻住 |
| 可训参数 | 约 39.3M / 4.48B(0.88%) |
| 每设备 batch | 1 |
| 梯度累积 | 8(有效 batch = 8) |
| 学习率 | 1e-4,cosine |
| epoch | 2 |
| 最大序列长度 | 4096 |
| 图像长边 | 384 |
| 最多帧数 | 6 |
| 优化器 | adamw_8bit |
| 精度 | bf16 |
2 个 epoch 一共 342 step,墙钟大约 1 小时 26 分。
没有上全量微调,也没有一开始就做 DPO。先确认小模型能稳定吐出观察 JSON,再考虑偏好对。
结果
训练过程
这是一轮小样本 SFT,先看收敛:
- 训练 loss:约 3.64 → 0.75(全程均值约 1.02)
- 验证 loss:epoch 1 0.805,epoch 2 0.764
- 后半段训练 loss 在 0.74–0.78 附近晃,没有散掉
loss 能降,说明格式和「观察 + 分数」模型接住了。真正要看的是没进训练的 test。
测试集(269 条,训练时完全留出)
协议:用和训练相同的多图提示,按帧数分组做 batch 解码(默认 4 条一批),最长生成 256 token。墙上大约 61 分钟,合下来约 16 秒/条,比逐条 generate 的 40–80 秒快不少。
一个实现上的坑:观察 JSON 比较长,score 又在最后一个字段。256 token 经常在写完 lean 之后被截断,于是 269 条里只有 136 条解析出了完整分数(133 条 JSON 不完整)。lean 几乎都能读到(268/269),所以下面分数指标只在 136 条上算,倾向在全量上统计。
打分(136 条完整 JSON)
| 指标 | 数值 |
|---|---|
| MAE | 1.08 |
| RMSE | 1.42 |
| 完全一致 | 30.9% |
| 误差 ≤ 1 分 | 67.7% |
| 「喜欢」≥4 的精确率 / 召回 | 0.74 / 0.38 |
按真实分数拆开:
| 真实分 | 条数 | 完全一致 | MAE |
|---|---|---|---|
| 1 | 25 | 16% | 1.36 |
| 2 | 17 | 53% | 0.53 |
| 3 | 25 | 36% | 0.76 |
| 4 | 54 | 37% | 1.07 |
| 5 | 15 | 0% | 1.80 |
预测分布明显往中间靠:完整样本里预测 5 分只有 1 次,真实 5 分 15 条全部被打低——模型不敢轻易给满分配。混淆矩阵(行 = 真实 1–5,列 = 预测 1–5):
pred 1 2 3 4 5
true 1 4 12 5 4 0
2 3 9 4 1 0
3 2 10 9 3 1
4 2 20 12 20 0
5 1 1 7 6 0
倾向 lean(268 条)
模型更爱说 mid(166),low 70,high 32。真实 5 分里 high 只有 9 条,mid 20 条;真实 4 分也是 mid 占大多数。它能把「不太像」和「有点像」分开一点,但几乎不敢给满分配。
可以这么读:SFT 之后模型会按格式写观察,分数是粗排序(差 1 分大约七成),不是精确打分器。高分段偏保守,中间分(2–4)相对稳,5 分几乎没学会。截断也说明生成预算要加长,或者把 score 挪到 JSON 最前面,下次评测会完整得多。
另外做了一个很小的本地推理服务:加载 4bit 底模 + LoRA,提供 /score 和一份 OpenAI 风格的 /v1/chat/completions。第一次推理会慢一些(编译),后面会好一点。若要进 LM Studio 这类客户端,还需要把 LoRA 合并后再导出 GGUF,并配上官方的视觉投影文件;adapter 本身不能直接丢进去。
几点体会
- 先配数据,再谈模型。 低分短片如果按原比例灌进去,模型会变成「见短片就打低分」的机器。把 4/5 分保全、低分等量抽样,比换 8B 更重要。
- 观察要比形容词有用。 「匹配 / 偏离」写成可核对的画面事实,后面才好做 DPO 或人工抽查。
- 16GB 够用,前提是冻视觉、限分辨率、限帧数。 这次没有动视觉塔,多图仍然能训。
- 别把去拒答改版当捷径。 对 VL 来说,干净的官方 Instruct 加上写明白的样本,比来路不明的消融权重更可控。
下一步:加长生成或把分数放到 JSON 最前,把 269 条分数补全;抽一批观察做人工核对;再考虑很轻的 DPO。SFT 只是让模型学会按我的标准开口,真正贴口味,还得拿成对样本一点点打磨。



