做计算机视觉的同行应该都有这种体会:数据标注太贵,训练样本太少,每次接到一个新的图像分类需求,第一反应就是去翻ImageNet预训练权重。以前我常用ResNet50的预训练模型,然后微调最后几层,效果凑合但总觉得上限不高。后来换上了DINOv2,情况有了质的改变。这篇文章记录的是我在自定义数据集上用DINOv2做图片分类预测的完整过程,包括环境准备、数据组织、特征提取、分类器训练,以及那些常规文档里不会写的坑。适合正在做图像分类、少样本识别、图像检索的工程师参考,也适合刚接触自监督视觉模型、想找个靠谱方案入门的同学。
DINOv2是Meta AI在2023年发布的自监督视觉模型系列。所谓自监督,简单说就是模型在预训练阶段根本不需要人工标注的类别标签,只靠图片本身就能学到通用的视觉特征。这听着有点像玄学,但实际效果非常能打:它学出来的特征向量可以直接拿来训一个简单的线性分类器,在很多下游任务上就能超过传统“预训练+微调”的路线。最让我喜欢的一点是,它提取的特征是通用的,也就是说,你在自己的数据集上不需要大规模重新训练,只要把图片过一遍模型,得到特征向量,再训练一个轻量分类头就够了。
我这次拿一个二分类的猫狗数据集来演示整个流程。选这个数据集没有别的原因,就是大家熟悉、复现方便,把代码里的类别数改一下就适用于任何自定义任务。本质上的方法论是通用的,你换成工业零件缺陷分类、遥感图像场景分类、医学影像筛查,核心流程完全一样。
1. DINOv2核心原理与选择理由
1.1 没有标签,DINOv2是怎么学会特征的
DINOv2的前身是DINO和iBOT,核心思路是做自蒸馏。你可以把它理解成让一个“老师模型”和一个“学生模型”同时看同一张图片的两个不同视角(也就是经过不同裁剪、颜色扰动后的版本),然后让学生模型的输出尽量去匹配老师模型的输出。老师模型的参数不是单独训练的,而是对学生模型做指数移动平均得来的。这套机制很像一个学生不断去模仿一个“更好的自己”,在模仿过程中,模型被迫学会抓住图片中最本质、最稳定的内容,而不是那些容易被扰动干扰的表面信息。
在ViT(Vision Transformer)架构下,图片会被切成一个个固定大小的patch,比如224x224的图按14x14的patch切,就能得到16x16共256个patch。每个patch映射成一个token,前面再加一个特殊的class token(CLS token)。训练完之后,这个CLS token对应的输出向量,就可以当作整张图片的全局特征表示。DINOv2的特征之所以通用性强,是因为它在海量无标注图片上见过太多视觉模式,学出来的特征层级非常丰富,从边缘纹理到物体部件再到整体语义都有覆盖。
1.2 相比传统迁移学习,DINOv2赢在哪里
传统做法是在ImageNet标注数据上训练一个分类模型,然后拿到自己的任务上微调。这套路本身没问题,但有两个隐性痛点:一是学到的特征高度跟ImageNet的1000个类别绑定,迁移到跟你数据分布差异很大的场景时,需要微调的参数量很大;二是如果自己的数据集很小,微调很容易过拟合。DINOv2因为训练时不依赖标签,学到的特征更偏向通用的视觉结构,而不是特定类别的表面模式,所以迁移到新任务时往往只需要一个线性分类头就能出很好的效果。
| 方案 | 需要标注数据 | 自定义数据集上所需训练量 | 特征通用性 | 上手复杂度 |
|---|---|---|---|---|
| ResNet + ImageNet微调 | 需要 | 需要训练整个网络或多层 | 中等 | 中等 |
| CLIP零样本/少样本 | 需要文本描述 | 基本不需要训练 | 强,但有文本依赖 | 低 |
| DINOv2 + 线性探针 | 需要少量标注 | 只训练一个线性层 | 极强 | 低 |
CLIP也是个很强悍的模型,但它依赖图文配对数据,如果你的任务类别很难用一两句话描述清楚,或者你的图片风格跟CLIP训练数据差得很远,效果反而不好。DINOv2只需要图片本身,在纯视觉任务上更纯粹。这也是我在这个项目里选择DINOv2的直接原因。
1.3 四个规格怎么选
DINOv2官方提供四个规格的模型,分别是ViT-S/14、ViT-B/14、ViT-L/14和ViT-g/14。字母后面的14表示patch size是14像素。从使用体验来说,小模型速度快、显存占用少,大模型精度上限更高但资源消耗也上去了。我建议普通用户先从ViT-B/14开始,它在精度和开销之间最均衡。如果你的数据量不大、特征维度要求不高,ViT-S/14完全够用。显存充裕且追求极致效果再上ViT-L/14。ViT-g/14参数量超过10亿,一般个人项目没必要碰。
2. 环境准备与自定义数据集组织
2.1 软硬件环境
这次实验我用的是一张RTX 3090,24GB显存,但其实DINOv2 ViT-B/14的显存占用并不会让你有压力,提取特征时即使把batch size设到64,显存占用也就几个GB。如果你是8GB显存的卡,把batch size调到16或者32也完全跑得动。CPU推理我也试过,一张224x224的图大概要几百毫秒到一秒多,做离线特征提取勉强能忍,但实时预测就不建议了。
软件方面,Python 3.9以上,PyTorch 2.x,torchvision,再加上scikit-learn、matplotlib、Pillow这几个常用库就行。DINOv2的模型是通过torch.hub加载的,所以需要能访问GitHub和下载权重文件。首次加载会自动下载预训练权重,权重文件比较大(ViT-B/14大概300多MB),建议提前找个网络好的时间点装好,后面实验会顺畅很多。安装依赖的命令很简单,我直接用requirements.txt管理:
torch>=2.0.0 torchvision>=0.15.0 scikit-learn>=1.2.0 matplotlib>=3.7.0 pillow>=9.5.0 tqdm>=4.65.0 numpy>=1.24.02.2 数据集目录结构
自定义数据集的组织方式非常自由,DINOv2本身不关心你图片存哪,只关心你喂给它的张量长什么样。但为了让代码逻辑清晰,我建议用ImageFolder风格的组织方式:
data/ ├── train/ │ ├── cat/ │ │ ├── cat_001.jpg │ │ ├── cat_002.jpg │ │ └── ... │ └── dog/ │ ├── dog_001.jpg │ ├── dog_002.jpg │ └── ... └── val/ ├── cat/ │ └── ... └── dog/ └── ...如果你自己的数据是一堆乱放在文件夹里的图片,但有一份CSV标注文件(比如文件名和类别两列),也是可以的。后面的Dataset类只需要按你的标注文件去读取就行。我这次演示先按目录结构来,因为torchvision的ImageFolder直接支持这种格式,代码最省事。我自己手工收集数据的时候,习惯写个小脚本按类别把图片移动到对应目录,顺便检查一下有没有损坏的文件,否则训练到一半发现某张图解码失败很浪费感情。
2.3 图像预处理细节
DINOv2在预训练时,输入图像会被缩放到224x224,并且做了标准化的ImageNet均值和方差。这个预处理跟你自己的数据是什么风格关系不大,建议直接照搬:
from torchvision import transforms transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])一个常见的疑惑是:我的图片是灰度图怎么办?最简单的办法是把单通道复制成三通道。更好的做法是先把灰度图做适当对比度增强或伪彩色映射,但这些属于数据本身的预处理策略,跟DINOv2无关。需要注意的是,DINOv2的patch size是14,224刚好是14的整数倍,这是设计好的分辨率,不建议随意改成其他尺寸。如果你强行用更大的图,比如448x448,虽然也能跑,但位置编码是预训练时固定好的,对不齐的位置编码会严重影响效果。所以除非你打算重新训练位置编码,否则老老实实用224x224。
2.4 自定义Dataset类
写一个Dataset类其实不复杂,核心就是返回图像张量和它的标签。如果直接用ImageFolder,代码更短:
from torchvision.datasets import ImageFolder from torch.utils.data import DataLoader train_dataset = ImageFolder("data/train", transform=transform) val_dataset = ImageFolder("data/val", transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=False, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4)这里要注意,shuffle=False是因为我们要按顺序提取特征,顺序在后面跟标签做对齐时非常关键。一旦shuffle了,特征跟图片的对应关系就全乱了,这是新手特别容易踩的坑。num_workers可以根据你的CPU核数适当调大,数据读取的瓶颈在磁盘IO时能明显提速。
3. 整体方案设计:冻结模型还是微调
3.1 三种主流的实现路线
拿到DINOv2之后,你的选择不是唯一的。根据你的数据量和任务难度,大致有三条路。
第一条路是“冻结backbone + 训练线性分类头”,也就是线性探针(linear probing)。做法是把DINOv2的所有参数冻结住,对每张图片提取特征向量,然后只训练一个逻辑回归层或单层全连接。这条路速度最快、最不容易过拟合,在数据量只有几百张的情况下也能有不错的效果。它是我在这个项目里的首选。
第二条路是“冻结backbone + 训练MLP分类头”。跟第一条的区别是分类头不再是简单的线性层,而是一个两层或三层的全连接网络,中间加ReLU激活和Dropout。这能多学一些特征组合的模式,适合线性分类头效果差一点点、但你又不希望动backbone的场景。
第三条路是“全量微调”。把DINOv2的全部参数或者后面几层解冻,用你的数据端到端地训练。这条路上限最高,但是对你的数据量要求也最高,而且训练时间会长很多。如果只有千把张图,全量微调很容易过拟合。我的建议是,先走第一条路,把baseline打出来,如果准确率不够,再考虑微调后面几层。
3.2 为什么线性探针往往就够了
DINOv2学出来的特征有一个很强的特性:线性可分性。也就是说,不同类别的特征在向量空间里已经天然分得比较开了,只是需要一层线性变换把坐标轴旋转到合适的角度。线性探针本质上就是在学这个变换。我在多个自定义数据集上测过,只要你的类别不是特别抽象(比如“开心”和“难过”这种情绪),10000张训练图下线性探针的准确率往往能到95%以上。即便只有几百张图,效果也远好于传统方法。这背后的原因就是DINOv2在无标注数据上见过海量的视觉模式,它的特征空间已经非常平滑和规整,线性分类头加在这上面就是顺水推舟。
3.3 特征维度与模型参数速查
| 模型 | 参数量 | 特征维度 | 适用场景 |
|---|---|---|---|
| ViT-S/14 | 2100万 | 384 | 快速验证、资源受限 |
| ViT-B/14 | 8600万 | 768 | 通用推荐 |
| ViT-L/14 | 3亿 | 1024 | 高精度需求、显存充足 |
| ViT-g/14 | 11亿 | 1536 | 极致效果、大规模任务 |
特征维度直接影响后面线性层的输入大小和训练速度。768维的特征去训一个二分类逻辑回归,计算量完全可以忽略不计。这个维度对于后续做图像检索、聚类等任务也都很合适。
4. 核心实操:加载DINOv2模型与提取特征
4.1 用torch.hub加载预训练模型
DINOv2的官方加载方式在facebookresearch/dinov2仓库里写得非常清楚。核心代码就一行:
import torch device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = torch.hub.load('facebookresearch/dinov2', 'dinov2_vitb14') model.eval() model.to(device)这里有个细节必须强调:加载后一定记得调用model.eval()。因为模型内部有Dropout和LayerNorm等模块,训练和推理模式下的行为不一样。不切换成eval模式,提取的特征会有随机性,导致同一个输入两次特征不一致,后面训练分类器就会莫名其妙地不稳定。我第一版代码就吃了这个亏,还以为是模型随机初始化的问题,排查了半天才发现是忘了切换模式。
torch.hub第一次加载时会去下载权重和代码,如果网络环境不好,下载会卡住。遇到这种情况,可以手动把权重文件下载下来放到~/.cache/torch/hub/checkpoints/目录下,文件名要跟它期望的一致。ViT-B/14的权重文件名是dinov2_vitb14_pretrain.pth。放到对应位置后,torch.hub加载时会直接读缓存,不会再走网络。
4.2 特征提取的完整代码
提取特征最推荐的做法是把图片整体过一遍模型,取CLS token的输出。代码如下:
import torch import numpy as np from tqdm import tqdm def extract_features(model, dataloader, device): model.eval() features = [] labels = [] with torch.no_grad(): for images, targets in tqdm(dataloader): images = images.to(device) outputs = model(images) # shape: (batch_size, dim) features.append(outputs.cpu().numpy()) labels.append(targets.cpu().numpy()) features = np.concatenate(features, axis=0) labels = np.concatenate(labels, axis=0) return features, labels train_features, train_labels = extract_features(model, train_loader, device) val_features, val_labels = extract_features(model, val_loader, device) np.save("train_features.npy", train_features) np.save("train_labels.npy", train_labels) np.save("val_features.npy", val_features) np.save("val_labels.npy", val_labels)关于特征向量怎么取,DINOv2的forward默认返回CLS token对应的向量。有些文章会建议把最后一层所有patch token做均值池化,再跟CLS token拼接。我实测过,在分类任务上,直接用CLS token已经非常好了,拼接其他token收益很小,但特征维度变大了,后续分类器训练和存储成本都会上升。所以常规场景下直接用CLS token就好。
提取出来的特征建议先保存成npy文件。这样的好处是,你后续尝试不同的分类头方案时,不需要再重新过一遍模型,直接在特征上做实验,省时间也省显存。我通常会把这个特征提取脚本单独保存,后续做特征可视化、聚类、检索都会再次用到它。
4.3 先看特征有没有区分度
训练分类器之前,强烈建议先对特征做一个可视化,看看不同类别的点在特征空间里是否真的能分开。这能帮你提前发现数据本身的问题。用t-SNE把768维的特征降到2维,然后按标签染色:
from sklearn.manifold import TSNE import matplotlib.pyplot as plt tsne = TSNE(n_components=2, random_state=42, perplexity=30) features_2d = tsne.fit_transform(train_features) plt.figure(figsize=(8, 8)) colors = ['#e74c3c', '#3498db'] for label in np.unique(train_labels): idx = train_labels == label plt.scatter(features_2d[idx, 0], features_2d[idx, 1], s=5, c=colors[label]) plt.savefig("tsne_features.png", dpi=150)如果t-SNE图上两个类别明显聚成两团,恭喜你,后面分类器的效果基本不会差。如果两个类别完全混在一起,那问题多半出在数据上,比如标注错误、图片质量太差、类别本身难以区分等等。这时候不要急着调模型,先回去看数据才是最明智的。我经常跟朋友说,特征可视化是你和你的数据之间的照妖镜,它比任何指标都能更直观地告诉你数据的真实情况。
5. 核心实操:训练分类头并评估效果
5.1 线性探针:最简单的分类器
特征拿到手之后,训练分类器就变成一个非常经典的小问题。最简单、最不容易翻车的方案是逻辑回归(LogisticRegression)。这里用scikit-learn的实现,多分类和软标签都支持得很好:
from sklearn.linear_model import LogisticRegression from sklearn.metrics import accuracy_score, classification_report clf = LogisticRegression(max_iter=1000, C=1.0) clf.fit(train_features, train_labels) val_pred = clf.predict(val_features) val_acc = accuracy_score(val_labels, val_pred) print(f"Validation accuracy: {val_acc:.4f}") print(classification_report(val_labels, val_pred, target_names=val_dataset.classes))重点说一下LogisticRegression的几个关键参数。max_iter设大一点是因为特征维度768、样本量可能上万,默认的100次迭代有时候不够收敛。C是正则化强度的倒数,C越小正则化越强。如果训练集不大,可以适当把C调小一点防止过拟合,比如C=0.1。你也可以用GridSearchCV快速搜索一个合适的C值,不过一般0.1到10这个范围内效果差异不是特别大。
训练时间上,10000个样本、768维特征,逻辑回归几乎是一眨眼就训完。这也是DINOv2方案让我觉得特别爽的地方,前面重活累活模型都干完了,后面的分类任务轻得跟羽毛一样。
5.2 如果需要更高精度:加一层MLP
如果线性分类头在验证集上的表现差强人意,你可以升级成一层带隐藏层的MLP。思路是把特征先映射到一个中间维度,经过ReLU激活后再映射到类别数。我用PyTorch写了一个简单的版本:
import torch.nn as nn class MLPClassifier(nn.Module): def __init__(self, in_dim, hidden_dim=512, num_classes=2): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, hidden_dim), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(hidden_dim, num_classes) ) def forward(self, x): return self.net(x) model_cls = MLPClassifier(in_dim=768, num_classes=2).to(device) optimizer = torch.optim.AdamW(model_cls.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss()训练时我自己习惯把特征张量转成PyTorch的Dataset,然后用DataLoader按batch迭代。隐藏层维度取512在768维输入下是一个比较稳妥的选择。Dropout取0.3到0.5之间,能有效缓解过拟合。MLP训练几十个epoch就够了,千万别训太久,因为特征本身已经很强了,分类头参数又少,过拟合很快会来敲门的。
有朋友问我为什么不用SVM,其实也可以用,SVM在小样本下表现也挺好。但SVM的预测阶段不如逻辑回归那样方便输出概率,在需要不确定性估计的场景下会比较别扭。逻辑回归在大多数情况下已经够用,所以我默认先推它。
5.3 评估指标不能只看准确率
准确率是最直观的指标,但如果你的类别不平衡,准确率会骗人。比如95%的样本都是猫,那模型把狗全部识别成猫也能有95%的准确率,看起来挺好的,实际上一无是处。所以我强烈建议在二分类场景下同时看混淆矩阵(Confusion Matrix)和每个类别的精确率、召回率和F1值。上面的classification_report已经把这些指标打出来了。混淆矩阵可以用下面的代码画出来:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm = confusion_matrix(val_labels, val_pred) disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=val_dataset.classes) disp.plot(cmap="Blues") plt.savefig("confusion_matrix.png", dpi=150)查看混淆矩阵能让你一眼发现模型到底在哪些类别上容易混淆。比如猫狗分类,假如模型经常把白狗识别成猫,可能是因为数据集中白猫太多,或者白狗的样例太少。这时候针对性地补充对应类别的训练样本,往往比调模型参数有用得多。
5.4 我这次实验的实际效果
用大约8000张训练图片、2000张验证图片做猫狗二分类,ViT-B/14 + 逻辑回归的验证准确率是99.2%。这个结果几乎不需要调参,第一次跑完就是这水平。后来我换了一个工业场景的数据集,每类只有三四百张图,DINOv2特征+线性探针也能做到89%的准确率,而传统ResNet迁移学习在那个数据集上大概只有82%左右。差距非常明显,而且DINOv2方案的训练时间几乎可以忽略不计,让我省出了大把时间去做数据分析和排错。
6. 常见问题与排查技巧实录
6.1 特征一模一样?检查是不是忘了eval模式
我见过好几个人跑来问,说DINOv2提取的特征怎么会有随机性,同一个输入两次特征都不一样。排查到最后基本都是同一句话:模型没有调用model.eval()。PyTorch默认是训练模式,Dropout层会随机屏蔽节点,LayerNorm也会对batch统计量做修正,这些都会导致输出不稳定。切记推理前调用model.eval(),并且用torch.no_grad()包住前向传播。
6.2 下载模型权重卡住了怎么办
torch.hub加载DINOv2时,首次需要下载数百MB的权重文件。网络状况不好时,下载过程可能长时间没有进度。解决办法是手动下载权重放到缓存目录。以Linux为例,缓存目录一般是~/.cache/torch/hub/checkpoints/,把权重文件放到这里就行。Windows下是C:\Users\你的用户名.cache\torch\hub\checkpoints\。这样torch.hub加载时会直接命中缓存,不再走网络。
6.3 类别的图片数量严重不均衡
如果某个类别的图片特别少,线性分类头容易偏向样本量大的类别。我的处理思路有几个:一是对少数类做简单的数据增强,比如随机翻转、旋转、颜色抖动,然后再提取特征;二是把逻辑回归的class_weight设为balanced,让损失函数自动给少数类加权;三是实在不行就换用余弦相似度分类,用每个类别的原型向量做最近邻判断,这种思路在极度不均衡的少样本场景下特别管用。
6.4 验证集上效果很好,但新数据上一塌糊涂
这是典型的过拟合或者数据分布不一致问题。常见原因有三个:一是验证集和训练集来自同一批采集环境,导致模型学到的是环境特征而不是物体本身特征,所以换到新环境就失灵。二是数据增强不合理,比如把训练图做了过度颜色扰动,让模型学到了很奇怪的判断依据。三是你在做数据预处理和推理预处理时用了不一致的流程,比如训练时缩放到224,预测时直接resize成别的尺寸。建议严格统一预处理流程,最好把预处理逻辑封装成一个函数,训练和预测都用同一个。
6.5 显存不够用
显存不足通常不是模型本身的问题,而是batch size设置太大了。ViT-B/14在224x224输入下,单张图片的显存占用大约是几百MB。如果一张卡只有8GB显存,batch size设32是稳妥的,再大就可能爆显存。还嫌不够的话,可以尝试半精度推理,把模型转成float16:
model = model.half()半精度能显著减少显存占用,对特征提取的效果影响很小。需要注意的是,输入图片也要转成半精度,否则类型不匹配会直接报错。
7. 从分类到更多玩法:这套方案的扩展思路
DINOv2特征的价值远不止训练一个分类头。我在实际项目中逐渐发现,一旦你把图片变成特征向量,很多以前觉得很麻烦的任务都变得轻巧了。
比如图像检索。把整个图片库的特征都提取出来存成矩阵,来了一个新查询图片,提取特征后跟库里的向量做余弦相似度排序,Top-K就是检索结果。这个方案不需要任何训练,效果在大多数场景下已经能媲美专门的检索模型。
再比如聚类。如果你手里有一堆完全没标注的图片,想先把它们粗分成几组,DINOv2特征搭配KMeans聚类就能做得不错。我在一个商品分类项目里就是用这种方式先做了预聚类,然后再人工给每个簇打标签,大大减少了标注工作量。
还有少样本分类。每类只用五张图甚至一张图,用特征向量取类内平均作为原型,然后做最近邻分类,也就是Prototypical Network的思路。DINOv2的特征质量决定了这个方案的上限,实测比在ResNet特征上做同样的操作要高出一大截。
回到这次的猫狗分类项目本身,我的体会是:DINOv2把“做图像分类”这件事的门槛大大降低了。以前你需要花大量时间在特征工程、网络结构设计和训练策略调优上,现在你只需要把图片整理好,过一遍预训练模型,然后训一个简单的分类器就能得到不错的结果。这种“预训练特征 + 轻量分类头”的开发模式,特别适合快速验证想法、做技术Demo、或者在小规模数据上落地业务。最后再分享一个小技巧:特征和标签尽量保留成npy文件,后续调分类头、做可视化、换评估指标,都不需要重新提取特征,非常节省时间。希望这篇记录能让你少走几步弯路,如果遇到什么问题,欢迎在评论区交流。