☰
VeriCam验证基线实战:Unknown Data视觉分类与拒识
2026/10/6 11:27:44 网站建设 项目流程

在真实视觉项目里,模型上线后最怕的往往不是“答错”,而是遇到它没见过的新物种还硬答。比如产线质检系统只训练过良品和几种固定划痕,某天突然来了一张端子变形图片;旧流程大概率会把它直接塞进某一个已知类别里,最终导致误判。这类问题光靠单个分类模型很难解决,需要在模型入口处建立一套专门验证 Unknown Data 的机制。本文围绕 VeriCam 项目展开,完整演示如何搭建一个可用于“未知数据分类验证”的基线方案,覆盖概念、实验设计、特征提取、阈值判定、评估指标和工程化落地,代码可以直接复用。

1. 背景与核心概念:VeriCam 要解决什么问题

1.1 未知数据分类和普通分类有什么不同

Unknown Data 在中文技术语境里通常叫“未知数据”,指的是模型在训练阶段没有出现过的类别或分布。比如一个只学过高空抛物、车窗抛物和垃圾堆积的监控模型,突然接到一张“火灾烟雾”的图片,这张图对整个业务系统来说就是未知数据。

普通分类任务是一个封闭集合(Closed Set)问题:训练集有 10 类,测试集也只会出现这 10 类,模型只需要在有限答案里做选择。但真实场景是开放集合(Open Set)问题:线上数据永远存在训练时没见过的类别。如果仍按普通分类的逻辑来做,模型会强行把所有输入都映射到已有类别上。CIFAR-10 训练 8 类、剩余 2 类完全不参与训练,推理时让模型去判定第 9、10 类图片,就是模拟这种场景。

Unknown Data 的难点在于:模型必须具备“不知道”的权利。即遇到陌生类别时,输出不能是“某个已知类 + 一个勉强排到 0.8 的自信概率”,而应该输出 unknown 状态,交给人工复核或后续处理。这已经不是单纯的图像分类问题,而是包含分类、拒识、验证三层逻辑的系统问题。

1.2 Verification Baseline 到底是什么

Verification Baseline 可以翻译为“验证基线”。它并不是一个特别炫酷的算法,而是一套稳定、透明、可重复的流程,用来当后续更复杂算法的对照物。所谓 Baseline,中文叫基线,是所有评估工作的起点。如果连一套简单基线都没跑通,直接上 OpenMax、度量学习、能量模型,很难判断效果提升究竟是算法的功劳,还是数据变化的结果。

VeriCam 这个名字来自 Verification + Camera,可以理解为“视觉输入侧的验证闸门”。在项目里,它的职责不是替代最终分类器,而是在图像进入真实业务之前回答几个问题:

  • 当前样本能不能被已训练的分类模型处理?
  • 如果可以,它应该属于哪个已知业务类别?
  • 如果不可以,系统能不能稳定识别为 unknown,而不是随便给一个类别?

VeriCam 作为验证基线,通常由一个特征提取器、一个浅层分类器和一个未知阈值判定模块组成。特征提取器负责把图片压缩成向量,分类器负责在已知类别之间做区分,阈值判定模块负责把低于置信度阈值的样本拒绝为未知数据。

1.3 为什么需要 VeriCam 这样的验证入口

很多团队会把 Unknown Data 问题简单等同于给模型增加一个“其他”类别。这个做法问题很明显:真实未知类别的形态是无穷的,不可能靠一个类别样本完全覆盖。另一种常见错误是直接看 softmax 概率,认为低于 0.5 就算 unknown。实际上,如果模型从未见过陌生样本,它仍然可能在陌生样本上输出很高的 softmax 概率,因为训练过程只优化了已知类别之间的决策边界。

VeriCam 的定位是给现有分类流程补上“验证环节”。从工程角度看,它带来的收益是确定的:减少硬错误、给样本留下复核入口、让模型迭代有可量化指标。从算法角度看,它提供了一个能作为对照的简单流程,后续想尝试更复杂方法时,只要保证同一个数据集、同一套指标即可公平比较。

2. 整体架构与验证流程

2.1 架构分层设计

一套完整的视觉验证基线通常包含四个环节:输入层、预处理层、特征层、决策层。VeriCam 的侧重点在特征层和决策层,因为图像质量问题和格式问题通常由更前端的质检模块处理。

从流程去看,整体可以抽象成这条链路:

图像输入 -> 预处理 -> 特征提取 -> VeriCam 验证判定 -> 已知类别 / 未知数据 | v 人工复核 / 未知样本库

输入层可以是摄像头抓拍帧、本地图片、视频抽帧画面;预处理层负责尺寸缩放、归一化、通道转换这些基础操作;特征层将图片转换为可用于计算的特征向量;决策层根据特征向量计算已知类别概率,并结合阈值决定放行还是拒绝。

2.2 为什么分类任务也要单独考虑验证

打个比方,普通分类器类似一个只会做选择题的考生,无论会不会都必须从 A 到 H 中选一个。VeriCam 相当于在考生正式作答前增加了一个“初筛老师”,老师发现自己不会的题时,可以直接标记为“待定”,不让考生乱猜。

在很多业务中,unknown 判定比分类结果更重要。例如医疗辅助诊断系统,识别未知病灶比把病灶误报成常见病更安全;再比如自动驾驶测试,把道路上从未见过的新奇物体标记为 unknown,比直接识别成行人更可靠。对 Verification Baseline 来说,评估核心不是“未知样本会不会被某种算法漂亮地聚类”,而是“未知样本被拒识的比例高不高、已知样本被误伤的比例低不低”。

2.3 VeriCam 与传统分类 Pipeline 的差异

传统分类 Pipeline 通常只有“特征提取 + 分类器”,输出每个已知类别概率并取最大值;VeriCam 增加了一个验证决策层,整体逻辑可以整理如下:

能力普通分类 PipelineVeriCam 验证基线
已知类别分类支持支持
未知类别拒识基本不支持通过阈值和指标量化
输出状态硬分类标签known / unknown 双状态
数据回流通常不做提供人工复核通道
评估维度准确率、F1已知保留率、未知拒识率、AUC

所以 VeriCam 不是某一种固定算法,而是一套让“分类和拒识都能被评估”的工程框架。

3. 环境准备与项目结构

3.1 环境依赖

本文示例以 Python 和 PyTorch 生态为主,核心依赖库如下:

python>=3.8 torch torchvision scikit-learn numpy joblib Pillow matplotlib

版本不需要完全一致,重点保持 torchvision 和 torch 版本匹配。示例中会使用 torchvision 的 ResNet18 预训练权重作为特征提取器,如果你的 torchvision 版本较旧,不支持weights=models.ResNet18_Weights.IMAGENET1K_V1这种写法,可以改为models.resnet18(pretrained=True)。整体流程不受影响。

3.2 实验设计:用 CIFAR-10 模拟已知与未知

为了让演示代码容易复现,这里选择 CIFAR-10 数据集。CIFAR-10 一共有 10 个类别,分别为 airplane、automobile、bird、cat、deer、dog、frog、horse、ship、truck。

实验设计如下:

  • 将前 8 个类别定义为“业务已知类别”,只使用这 8 个类别训练基线分类器;
  • 将第 9、10 类,即 ship 和 truck,设置为完全没参与训练的“未知数据”;
  • 在测试阶段,用前 8 类的测试图片评估“已知样本保留率”,用 ship 和 truck 的测试图片评估“未知样本拒识率”。

这个设计本质上模拟了真实场景:系统只学会了 8 种业务对象,某天突然收到陌生对象的图片,它应该判断这些对象不属于任何已知业务类别。选择 ship 和 truck 是有意为之,它们与其他 8 类中的类别有一定视觉差异,同时 truck 又和 automobile 存在相似性,对基线来说不是毫无难度。这样的设置能更真实反映阈值和特征提取能力的重要性。

3.3 项目目录结构

实战示例建议按下面的结构组织文件:

vericam-baseline-demo/ ├── data/ # CIFAR-10 数据集自动下载目录 ├── models/ # 保存 scaler、baseline 模型 ├── train_vericam_baseline.py # 特征提取、训练、评估主脚本 ├── verifier.py # 验证器封装,用于推理阶段 └── requirements.txt

在开始写代码前,可以在终端先创建一个项目目录,也可以直接新建上述 Python 文件。

4. 实战落地:构建 VeriCam 验证基线

4.1 数据准备与特征提取

ResNet18 是常用的轻量级卷积神经网络,本文使用它在 ImageNet 上预训练得到的权重,仅保留倒数第二层特征,将最终的全连接分类层替换为单位映射。这样做的好处是,不需要重新训练 CNN,也能把一张 224×224 的图片映射成 2048 维特征向量。

核心脚本写在train_vericam_baseline.py中,首先处理依赖、数据集加载和类别索引:

# 文件路径:train_vericam_baseline.py import os import numpy as np import joblib import torch import torch.nn as nn from torch.utils.data import DataLoader, Subset from torchvision import datasets, models, transforms from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, accuracy_score SEED = 42 np.random.seed(SEED) torch.manual_seed(SEED) DATA_DIR = "./data" MODEL_DIR = "./models" os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(MODEL_DIR, exist_ok=True) # 业务已知类别:CIFAR-10 前 8 类 KNOWN_CLASSES = list(range(8)) # 验证时当作 unknown 的类别:ship、truck UNKNOWN_CLASSES_FOR_TEST = [8, 9] # 每类抽取的样本数量 TRAIN_NUM_PER_CLASS = 500 TEST_NUM_PER_CLASS = 200 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]), ]) # 下载 CIFAR-10。如果数据已下载,可以把 download 改为 False train_ds = datasets.CIFAR10(root=DATA_DIR, train=True, download=True, transform=transform) test_ds = datasets.CIFAR10(root=DATA_DIR, train=False, download=True, transform=transform) def build_index(ds, classes, num_per_class=None): """从数据集中挑出指定类别,并可限制每类样本数量。""" targets = np.asarray(ds.targets) idx = [] for c in classes: idx_c = np.where(targets == c)[0] if num_per_class is not None: idx_c = idx_c[:num_per_class] idx.append(idx_c) return np.concatenate(idx) train_idx = build_index(train_ds, KNOWN_CLASSES, TRAIN_NUM_PER_CLASS) known_test_idx = build_index(test_ds, KNOWN_CLASSES, TEST_NUM_PER_CLASS) unknown_test_idx = build_index(test_ds, UNKNOWN_CLASSES_FOR_TEST, TEST_NUM_PER_CLASS) train_loader = DataLoader(Subset(train_ds, train_idx), batch_size=64, shuffle=False) known_loader = DataLoader(Subset(test_ds, known_test_idx), batch_size=64, shuffle=False) unknown_loader = DataLoader(Subset(test_ds, unknown_test_idx), batch_size=64, shuffle=False)

这里有个细节需要理解:前 8 类作为训练已知类,但 ship 和 truck 并没有被模型学习过。因此测试阶段模型面对 ship 和 truck 时,属于真正意义上的未知数据验证。而不是把 ship 和 truck 放进某个“其他类”里去训练,那样就成了三分类而不是验证。

接下来定义特征提取器。这里用的是 ResNet18 去掉最后分类头后的特征输出,维度是 2048:

class FeatureExtractor(nn.Module): def __init__(self): super().__init__() # 如果 torchvision 版本较旧,可改为 models.resnet18(pretrained=True) self.backbone = models.resnet18( weights=models.ResNet18_Weights.IMAGENET1K_V1 ) # 去掉最后的 1000 类分类头,只保留特征向量 self.backbone.fc = nn.Identity() def forward(self, x): return self.backbone(x) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") extractor = FeatureExtractor().to(device).eval() @torch.no_grad() def extract_features(model, loader): """遍历 DataLoader,输出特征矩阵和对应的标签数组。""" model.eval() feats, labels = [], [] for x, y in loader: x = x.to(device) feat = model(x).cpu().numpy() feats.append(feat) labels.append(y.numpy())

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询