1. 背景与核心概念1.1 什么是 Out-of-Distribution Detection深度学习模型在训练阶段只会接触到有限的类别和样本但在真实场景中模型输入很难保证永远来自训练分布。当一张“从未见过”的图片被送入分类器时模型往往会给出一个自信满满但完全错误的预测结果。比如一个只在猫狗照片上训练过的分类器输入一张汽车图片时softmax 输出可能仍然显示“猫 0.93”这在自动驾驶、医疗影像、工业质检等领域是不可接受的。Out-of-Distribution Detection简称 OOD 检测就是要设计一套机制让模型在遇到分布外样本时能够主动“拒识”而不是强行分类。从工程角度看OOD 检测的目标不是把未知类别错误地归为某个已知类别而是输出一个独立的“异常分数”然后通过阈值判断当前样本是否来自训练分布In-Distribution简称 ID。如果异常分数超过阈值系统就会标记为“可疑样本”转入人工处理或直接拒绝。这项技术在企业级模型部署中非常常见例如垃圾内容过滤、用户行为风控、传感器异常监控等。可以说OOD 检测是模型从“能用”走向“可靠”的重要一环。围绕 OOD 检测学术界和工业界提出了大量方法早期比较有代表性的是基于 softmax 置信度的方法后来出现了基于距离的方法、基于能量函数的方法以及基于生成模型的方法。近两年随着 Vision TransformerViT在视觉任务中逐渐成为主流骨干网络如何针对 ViT 的架构特点设计更有效的 OOD 检测方案成为一个新的研究方向。本文要介绍的“基于 SVD 的典型性图谱”就是其中一种思路它利用奇异值分解从特征空间中提取数据分布的典型结构再以热力图的形式直观呈现每个图像区域的分布内/分布外倾向。1.2 为什么 Vision Transformer 需要专门的 OOD 方法Vision Transformer 与传统的卷积神经网络CNN在特征提取方式上有本质区别。CNN 通过局部感受野逐层提取纹理、边缘、形状等特征而 ViT 先把图像切分成固定大小的 patch每个 patch 展平后映射成 token再通过多头自注意力机制建模 token 之间的全局依赖关系。这种架构使得 ViT 的中间特征具有以下特点特征以 token 序列形式组织每个 token 对应图像中的一个 patch 区域空间位置信息相对明确。自注意力机制会混合全局信息单个 token 的语义不仅受自身区域影响还受到其他 patch 的影响。最后一层特征往往已经高度抽象直接用最后一层 [CLS] token 做 OOD 判断会丢失大量中间层的局部结构信息。如果将 CNN 时代的 OOD 检测方法直接照搬到 ViT 上通常会遇到两个问题。第一个问题是特征空间的几何结构不匹配。CNN 的全局平均池化特征相对平滑而 ViT 的 token 序列特征更稀疏简单计算欧氏距离或马氏距离时协方差矩阵估计容易不稳定。第二个问题是“信息过度压缩”。只用 [CLS] token 或全局特征做判断无法回答“图像中哪个区域让模型觉得陌生”这个问题而这对于可解释性和后续人工排查非常关键。因此专门针对 ViT 设计 OOD 检测方案需要做到两件事一是利用好 token 级别的局部特征而不是只盯着全局特征二是提供可视化的异常定位能力让开发者知道模型为什么觉得一个样本有问题。SVD-based Typicality Maps 正是沿着这两个方向展开的。它通过对 token 特征矩阵执行奇异值分解构建一个低维正交基并将每个 token 特征投影到该基上从而获得一个可解释的“典型性得分”最终生成与输入图像分辨率对齐的热力图。1.3 SVD 与 Typicality Map 的基本思想奇异值分解Singular Value Decomposition简称 SVD是线性代数中一种经典矩阵分解方法。任意一个 m×n 的实数矩阵 X 都可以分解为X U Σ V^T其中U 是 m 阶正交矩阵V 是 n 阶正交矩阵Σ 是对角矩阵对角线上的元素称为奇异值。SVD 在数据降维、推荐系统、信号处理等领域都有广泛应用。例如在 PCA 中特征分解可以通过 SVD 来数值稳定地实现在气象分析中SVD 常用于分析两个气象场之间的耦合模态在 LoRA 低秩微调方法中SVD 也被用来分析权重矩阵的低秩结构。SVD 的核心价值在于它能够用一组正交基向量捕捉数据矩阵中方差最大的方向从而揭示数据分布的低维本质。Typicality Map 可以理解为一张与输入图像空间位置对应的“典型性热力图”。图像的每个 patch 区域都有一个得分分数越高表示该区域与训练数据分布越“典型”分数越低表示该区域越可能是分布外内容。把 SVD 和 Typicality Map 结合起来思路非常直观先在 ID 训练集上收集大量 token 特征组成一个特征矩阵然后对这个矩阵做 SVD得到一组反映特征分布主方向的基向量接着把当前输入样本的每个 token 特征投影到这组基向量上计算投影坐标相对于训练数据的偏离程度最后把偏离程度映射为每个空间位置的典型性得分并上采样生成热力图。这种方法的优势在于它不需要额外训练一个分类器或生成模型只需要一次 SVD 分解和若干次矩阵乘法计算成本低易于集成到现有 ViT 推理流程中。更重要的是由于 SVD 正交基的数学性质比较清晰异常分数具备一定的可解释性投影坐标在哪个基方向上偏离偏离了多少都可以量化分析。2. 方法原理拆解从特征矩阵到典型性图谱2.1 从 ViT 中提取多层 token 特征要构建典型性图谱第一步是从 ViT 中提取特征。大部分 ViT 模型由多个 Transformer Encoder Block 堆叠而成每个 Block 输出的 hidden state 都是一个形状为 (B, N1, D) 的张量其中 B 是 batch sizeN 是 patch token 数量D 是隐藏维度额外的 1 个 token 是 [CLS] token。以输入分辨率为 224×224、patch size 为 16 的 ViT 为例图像会被切分成 14×14196 个 patch因此 N196特征张量形状为 (B, 197, D)。对于 OOD 检测来说提取哪一层的特征是一个需要实验决定的问题。通常认为浅层特征偏向纹理和边缘信息深层特征偏向高级语义信息。实践中可以提取倒数第二层或最后一层的所有 token包含 [CLS] token也可以将多个中间层的特征拼接起来获得更丰富的表征。为了保留空间结构我们会保留 token 序列的排列顺序因为 patch 位置是固定的后续可以重塑成一个二维特征图。在具体实现时可以在 ViT 的 forward hook 中捕获每一层输出而不用修改模型源码。PyTorch 提供了 register_forward_hook 机制可以在不侵入模型的前提下拿到中间张量。下面是一个通用特征提取逻辑的示意# 文件路径feature_extractor.py import torch from torch import nn class ViTFeatureExtractor: def __init__(self, model, layer_indicesNone): self.model model self.features {} self.hooks [] if layer_indices is None: layer_indices [len(model.blocks) - 1] for idx in layer_indices: block model.blocks[idx] hook block.register_forward_hook(self._make_hook(idx)) self.hooks.append(hook) def _make_hook(self, idx): def hook_fn(module, input, output): self.features[idx] output.detach() return hook_fn def __call__(self, x): self.features.clear() _ self.model(x) return self.features需要注意的是不同 ViT 实现中 Encoder Block 的属性名可能不同例如有的实现叫encoder.layer有的叫blocks。实际使用时需要先打印模型结构确认。上面的代码是核心片段需要根据你自己的模型结构调整属性名。2.2 对特征字典做奇异值分解当我们从 ID 训练集中提取了大量 token 特征之后可以得到一个特征矩阵 X。假设我们收集了 M 个 token 特征每个特征维度为 D则 X 的形状是 (M, D)。由于 M 通常远大于 D直接对 X 做 SVD 的数值复杂度较高但因为我们只需要右奇异向量 V可以通过对协方差矩阵 X^T X 做特征分解来等价计算。更简单的做法是直接调用 PyTorch 或 NumPy 的 SVD 接口。从数学上看SVD 给出了特征空间的一组标准正交基列向量 v_1, v_2, ..., v_D 是按照数据方差从大到小排列的主方向。奇异值 σ_i 则表示第 i 个方向上的“能量”大小。在大多数视觉特征中前若干维已经能解释大部分方差因此我们可以截取前 K 个奇异向量形成一个投影矩阵 V_k ∈ R^{D×K}。这个操作实际上等价于对特征做 PCA 白化之前的第一步。一旦得到投影矩阵我们便可以把任意一个新的 token 特征 z ∈ R^D 投影到主方向空间y V_k^T z得到的 y ∈ R^K 就是该 token 在主方向坐标系下的投影坐标。在 ID 训练集上我们可以统计每个主方向坐标的均值和标准差。如果某个 token 的投影坐标在某个方向上偏离均值太多说明该 token 与训练数据的典型模式不一致倾向于是分布外内容。下面给出构建 SVD 字典的代码示例# 文件路径svd_dictionary.py import numpy as np class SVDDictionary: def __init__(self, n_components64): self.n_components n_components self.V None # 右奇异向量矩阵 self.mean None # 训练特征均值 self.std None # 训练特征标准差 self.energy None # 奇异值能量 def fit(self, features: np.ndarray): features: 形状为 (M, D) 的 ID 特征矩阵 features features.astype(np.float32) self.mean features.mean(axis0, keepdimsTrue) centered features - self.mean # 对中心化后的特征做 SVD U, S, Vt np.linalg.svd(centered, full_matricesFalse) self.V Vt[:self.n_components].T # 形状 (D, K) self.std S[:self.n_components] / max(centered.shape[0] - 1, 1) self.energy S def project(self, x: np.ndarray) - np.ndarray: 将特征投影到主方向空间 x: 形状 (..., D) 返回投影坐标形状 (..., K) centered x - self.mean return np.dot(centered, self.V)这里有一个值得注意的细节std使用的是奇异值除以 sqrt(M-1)而不是直接使用 S因为 SVD 的奇异值在数值上相当于特征值λ的平方根而协方差矩阵的特征值约等于 λ/(M-1)。如果只是做排序和阈值判断这个常数因子不会影响最终结果但如果要做标准化必须使用正确的标准差。2.3 计算 token 的典型性得分并生成热力图有了投影矩阵和训练集上的均值和标准差下一步就是为当前图像的每个 token 计算典型性得分。常见做法是对每个主方向上的投影坐标做标准化t_i (y_i - μ_i) / σ_i其中 μ_i 和 σ_i 是训练集上第 i 个主方向坐标的均值与标准差。标准化后的 t_i 描述的是该 token 在某个正交方向上的偏离程度。一个 token 的总体典型性得分可以定义为所有主方向上标准化绝对值的加权汇总也可以取最大绝对值或者计算马氏距离。一个比较实用的得分函数如下score sqrt( sum_i ( (y_i - μ_i)^2 / σ_i^2 ) )这本质上就是马氏距离在主方向坐标系下的简化形式。因为主方向彼此正交协方差矩阵在对角化之后没有交叉项计算非常高效。得分越大说明该 token 越不典型越可能是分布外内容。生成热力图时我们先把 token 得分按照原始 patch 顺序排列成二维网格。对于 224×224 输入、16×16 patch size 的情况网格形状为 14×14。如果 ViT 使用 stride 小于 patch size 的重叠 patch热力图尺寸需要根据实际 token 数量调整。为了将热力图叠加到原图上可以使用 OpenCV 的 resize 函数将 14×14 的得分图放大到 224×224 或更高分辨率。核心代码如下# 文件路径typicality_map.py import numpy as np import cv2 class TypicalityMapBuilder: def __init__(self, svd_dict: SVDDictionary, img_size224, patch_grid(14, 14)): self.svd_dict svd_dict self.img_size img_size self.patch_grid patch_grid def compute_token_scores(self, features: np.ndarray) - np.ndarray: features: 形状 (N1, D)包含 [CLS] token 和 patch tokens 返回每个 patch token 的典型性得分形状 (N,) # 去掉 [CLS] token patch_features features[1:] proj self.svd_dict.project(patch_features) # (N, K) # 标准化投影坐标 normed (proj - self.svd_dict.mean_proj) / (self.svd_dict.std_proj 1e-6) scores np.sqrt(np.sum(normed ** 2, axis1)) # (N,) return scores def to_heatmap(self, token_scores: np.ndarray) - np.ndarray: h, w self.patch_grid score_grid token_scores.reshape(h, w) heatmap cv2.resize(score_grid, (self.img_size, self.img_size), interpolationcv2.INTER_CUBIC) return heatmap需要说明的是mean_proj和std_proj需要在训练阶段根据投影坐标统计得到它们与 SVD 分解中的mean和std不是同一个概念。前者是投影后坐标系里的统计量后者是原始特征空间的统计量。为了避免混淆实际代码中建议在SVDDictionary中增加fit_projection_statistics方法在fit之后对 ID 训练特征的投影坐标再做一次统计。2.4 与其他方案对比softmax 置信度、Mahalanobis、energy为了理解 SVD-based Typicality Map 的定位有必要把主流的 OOD 检测方案放在一起对比。softmax 置信度直接取 softmax 输出的最大概率作为置信度低于阈值则判定为 OOD。这个方法实现最简单但无法处理模型“过度自信”的问题。很多 OOD 样本经过对抗扰动后softmax 置信度依然很高因此在复杂场景下可靠性不足。Mahalanobis 距离在特征空间中计算样本到类中心的马氏距离通常会联合多个特征层。思路与本文方法有些接近但马氏距离需要估计完整的协方差矩阵在高维特征下协方差估计不稳定且计算代价较高。Energy score基于 logits 的 energy 函数公式为 E(x) -T * log(∑_i e^{f_i(x)/T})。相比 softmaxenergy 函数在理论上对任意输入不会过度自信且不需要额外训练但依然只停留在 logits 层面无法提供局部异常定位。SVD-based Typicality Map在 token 特征层面构建低维正交基通过投影偏离度计算异常得分。它的优势是既能给出图像级分数又能定位到 patch 级异常区域并且 SVD 分解不涉及协方差矩阵的求逆数值稳定性更好。在不同数据分布下这些方法的相对表现会有差异。例如当 OOD 样本与 ID 样本在全局语义上相近但局部纹理差异明显时SVD-based Typicality Map 通常能捕捉到 patch 级别的异常而基于全局特征的方法容易漏判。反之如果 OOD 样本与 ID 样本差异巨大几乎所有方法都能取得不错的效果。工程设计时不应该把某种方法奉为万能而应该结合数据集特性做对比实验。3. 环境准备与依赖3.1 运行环境本文的示例代码以 Python 为主推荐使用 Python 3.8 或更高版本。深度学习框架以 PyTorch 为例因为 PyTorch 的 hook 机制和动态图特性非常方便我们提取中间层特征。操作系统方面Windows、Linux、macOS 均可运行但如果你需要训练或微调 ViT 模型建议在 Linux 服务器或带 GPU 的环境中操作。CPU 环境下可以跑通推理流程但速度会明显变慢尤其是计算 SVD 时若特征矩阵较大建议确保内存充足。版本需要根据你的项目实际情况调整。本文示例以常见环境为例重点演示配置思路。如果你使用的是 PyTorch 2.x大部分接口与 1.x 兼容但个别模型加载方式和 transform 预处理可能略有不同。3.2 依赖库需要安装以下 Python 库torch和torchvision用于加载 ViT 模型、图像预处理和推理。numpy用于矩阵运算和 SVD。opencv-python用于热力图缩放与叠加。matplotlib用于可视化。timm可选如果你想加载更多预训练的 ViT 变体timm 是一个很好的选择。scikit-learn可选用于计算 AUROC 等评估指标。安装命令示例pip install torch torchvision pip install numpy opencv-python matplotlib pip install timm scikit-learn在安装 PyTorch 时建议根据官方文档选择与你的 CUDA 版本匹配的安装命令。如果不确定可以先用 CPU 版本保证代码跑通再切换到 GPU 版本。3.3 示例项目结构为了便于组织代码我建议按下面的目录结构管理实验文件svd_typicality_ood/ ├── config.py # 全局配置路径、超参数 ├── feature_extractor.py # ViT 特征提取模块 ├── svd_dictionary.py # SVD 字典构建 ├── typicality_map.py # 典型性图谱生成 ├── dataset_utils.py # 数据加载与预处理 ├── evaluate.py # 评估脚本计算 AUROC、FPR ├── visualize.py # 可视化脚本 └── outputs/ # 输出结果目录这样拆分的目的是让每个模块职责单一便于单独测试和替换。例如feature_extractor.py只负责特征提取不关心后续的 SVDsvd_dictionary.py只负责从特征矩阵中学习投影基不关心特征来自哪一层。工程开发中解耦永远是第一原则。4. 核心代码实现4.1 特征提取模块我们以timm.create_model(vit_base_patch16_224, pretrainedTrue)为例构建一个轻量级特征提取器。这个模型的默认结构是blocks属性包含 12 个 Transformer Encoder Block。我们提取第 10 层和第 11 层的输出并将两个层的特征拼接起来得到一个维度更高的特征向量。当然你也可以只取最后一层先跑通实验再逐步增加特征层。在特征提取之前需要将模型切换到eval()模式并关闭梯度计算。因为 OOD 检测推理过程不需要反向传播关闭梯度可以省下大量内存和计算时间。# 文件路径feature_extractor.py import torch import timm class TimmViTFeatureExtractor: def __init__(self, model_namevit_base_patch16_224, layer_indices(10, 11), use_gpuTrue): self.device torch.device(cuda if (use_gpu and torch.cuda.is_available()) else cpu) self.model timm.create_model(model_name, pretrainedTrue).to(self.device) self.model.eval() self.layer_indices layer_indices self.features {} self.hooks [] for idx in layer_indices: block self.model.blocks[idx] hook block.register_forward_hook(self._make_hook(idx)) self.hooks.append(hook) def _make_hook(self, idx): def hook_fn(module, input, output): self.features[idx] output.detach().cpu() return hook_fn torch.no_grad() def extract(self, images_tensor): images_tensor: 形状 (B, 3, H, W)已归一化 返回 dict: {layer_idx: 特征张量} self.features.clear() images_tensor images_tensor.to(self.device) _ self.model(images_tensor) return self.features def get_concat_feature(self, images_tensor): 拼接多个层的 token 特征并去掉 batch 维度 返回 shape (B, N1, D_concat) feats self.extract(images_tensor) concat torch.cat([feats[idx] for idx in self.layer_indices], dim-1) return concat这里有一个工程细节值得注意output.detach().cpu()会把特征搬到 CPU 内存中如果你的 batch 很大CPU 内存容易成为瓶颈。对于实验代码这样做简单直观如果追求性能可以在 GPU 上完成特征收集以后再一次性搬回 CPU。4.2 SVD 字典构建在构建 SVD 字典之前我们需要从 ID 训练集中收集特征。假设我们有一个 ID 训练集例如 CIFAR-10 的训练集我们从中随机采样一部分图片经过预处理后批量送入特征提取器得到 token 特征再把所有 token 特征收集到一个 NumPy 数组中。特征收集完成后调用SVDDictionary.fit()方法。这里补充一个投影坐标统计量计算的完整版本# 文件路径svd_dictionary.py import numpy as np class SVDDictionary: def __init__(self, n_components64): self.n_components n_components self.V None self.feature_mean None self.proj_mean None self.proj_std None self.energy None def fit(self, features: np.ndarray): features features.astype(np.float32) self.feature_mean features.mean(axis0, keepdimsTrue) centered features - self.feature_mean U, S, Vt np.linalg.svd(centered, full_matricesFalse) self.V Vt[:self.n_components].T self.energy S # 计算投影坐标的均值和标准差 proj centered self.V self.proj_mean proj.mean(axis0, keepdimsTrue) # 注意这里使用 ddof1 保证样本标准差 self.proj_std proj.std(axis0, ddof1, keepdimsTrue) 1e-6 def project_to_subspace(self, x: np.ndarray) - np.ndarray: centered x - self.feature_mean return centered self.V def typicality_scores(self, x: np.ndarray) - np.ndarray: proj self.project_to_subspace(x) normed (proj - self.proj_mean) / self.proj_std scores np.sqrt((normed ** 2).sum(axis-1)) return scores代码中ddof1是比较关键的。样本标准差使用 n-1 作为分母可以避免小样本时低估标准差。而1e-6是为了防止某个主方向上的标准差为 0导致除零错误。4.3 Typicality Map 生成有了typicality_scores方法之后生成热力图只需要两步去掉 [CLS] token把 scores reshape 成 patch 网格然后上采样。# 文件路径typicality_map.py import cv2 import numpy as np class TypicalityMapBuilder: def __init__(self, svd_dict, img_size224, patch_grid(14, 14)): self.svd_dict svd_dict self.img_size img_size self.patch_grid patch_grid def build_map(self, token_features: np.ndarray) - np.ndarray: token_features: 形状 (N1, D)第一行是 [CLS] token 返回热力图形状 (img_size, img_size) patch_features token_features[1:] scores self.svd_dict.typicality_scores(patch_features) h, w self.patch_grid score_grid scores.reshape(h, w) heatmap cv2.resize( score_grid, (self.img_size, self.img_size), interpolationcv2.INTER_CUBIC ) return heatmap def build_image_score(self, token_features: np.ndarray, aggmean) - float: patch_features token_features[1:] scores self.svd_dict.typicality_scores(patch_features) if agg mean: return float(scores.mean()) elif agg max: return float(scores.max()) elif agg p95: return float(np.percentile(scores, 95)) elif agg cls: # 也可以单独使用 [CLS] token 的得分 cls_score self.svd_dict.typicality_scores(token_features[:1]) return float(cls_score[0]) else: raise ValueError(fUnknown agg: {agg})agg参数决定了图像级得分如何从 patch 级得分中汇总。均值更平滑最大值对局部异常更敏感95 分位数介于两者之间抗噪声能力更好。实际项目中通常会在验证集上分别测试这些聚合方式选择 AUROC 最高的一种。4.4 图像级异常分数聚合图像级异常分数是整个 OOD 检测的关键输出。无论我们生成的是热力图还是 patch 得分最终都要变成一个标量才能与阈值比较。除了上一节提到的 mean、max、p95 之外还可以对不同尺度进行融合。例如把原始图像缩放成多个尺度分别计算热力图再取平均值或者把多个 Transformer 层的得分相加获得更稳定的结果。实践中一个比较有效的做法是把 max 和 mean 两个分数拼接成一个二维向量再用简单的逻辑回归或线性分类器做融合。虽然这会引入一点点额外训练但通常能显著提升 AUROC。这里我们不过度展开只给出一个最简单的线性融合公式score_final α * score_max (1 - α) * score_mean其中 α 是融合系数在验证集上搜索即可。搜索范围可以从 0.1 到 0.9步长取 0.1很快就能跑完。5. 完整实战案例在 CIFAR-10 上进行 OOD 检测实验5.1 数据准备我们使用一个经典的实验设置将 CIFAR-10 作为 ID 数据集将 CIFAR-100 或 SVHN 作为 OOD 数据集。由于 ViT 原始预训练输入是 224×224 图像而 CIFAR-10 是 32×32 图像直接输入会导致分辨率不匹配。常见的做法是先将图像 resize 到 224×224再进行标准化。这个处理方式会损失一些原始细节但作为方法验证完全可行。如果你希望更贴近真实场景可以换成 ImageNet 的子集作为 ID 数据。数据加载和预处理的代码# 文件路径dataset_utils.py import torchvision.transforms as T from torchvision.datasets import CIFAR10, CIFAR100, SVHN from torch.utils.data import DataLoader def get_transform(resize224): return T.Compose([ T.Resize((resize, resize)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) def load_cifar10_train(batch_size32, num_workers2): transform get_transform() dataset CIFAR10(root./data, trainTrue, downloadTrue, transformtransform) loader DataLoader(dataset, batch_sizebatch_size, shuffleTrue, num_workersnum_workers) return loader def load_cifar10_test(batch_size32, num_workers2): transform get_transform() dataset CIFAR10(root./data, trainFalse, downloadTrue, transformtransform) loader DataLoader(dataset, batch_sizebatch_size, shuffleFalse, num_workersnum_workers) return loader def load_cifar100_test(batch_size32, num_workers2): transform get_transform() dataset CIFAR100(root./data, trainFalse, downloadTrue, transformtransform) loader DataLoader(dataset, batch_sizebatch_size, shuffleFalse, num_workersnum_workers) return loader这里要注意CIFAR-10 和 CIFAR-100 都包含 10 个不重叠的大类吗并不是。CIFAR-100 有 100 个小类其中部分小类与 CIFAR-10 的 10 类在语义上可能重叠。例如CIFAR-10 的“cat”类别与 CIFAR-100 的“cat”类别可能共享图片来源。为了减小标签泄漏的影响更严格的做法是使用 SVHN 作为 OOD 数据集因为街景门牌号与 CIFAR 的物体类别差异很大。本文实验中我们建议将 SVHN 作为主要 OOD 数据集CIFAR-100 作为辅助对比。5.2 模型加载与特征收集模型加载使用前面写的TimmViTFeatureExtractor。为了控制内存特征收集阶段可以只从训练集中采样 5000 张图片每张图片得到 196 个 patch token 和 1 个 [CLS] token。如果全部 50000 张图都用来收集特征内存会比较大但效果通常会更好。你可以根据自己的硬件条件调整采样数量。特征收集脚本# 文件路径collect_features.py import numpy as np import torch from feature_extractor import TimmViTFeatureExtractor from dataset_utils import load_cifar10_train def collect_id_features(extractor, loader, max_samples5000): all_features [] count 0 with torch.no_grad(): for images, _ in loader: feats extractor.get_concat_feature(images) # (B, N1, D) # 去掉 [CLS] token patch_feats feats[:, 1:, :].reshape(-1, feats.shape[-1]).numpy() all_features.append(patch_feats) count feats.shape[0] * feats.shape[1] if count max_samples * (feats.shape[0] * feats.shape[1]): break return np.concatenate(all_features, axis0) if __name__ __main__: extractor TimmViTFeatureExtractor(layer_indices(10, 11)) loader load_cifar10_train(batch_size16) features collect_id_features(extractor, loader, max_samples5000) print(Collected feature shape:, features.shape) np.save(outputs/id_features.npy, features)一个容易被忽略的问题是[CLS] token的处理。在 typicality map 中我们通常直接丢弃 [CLS] token因为它的位置信息不确定无法对应到具体 patch。但 [CLS] token 本身蕴含全局语义可以作为图像级判断的补充信息。因此在特征收集阶段我建议既保存 patch token 特征也保存对应的 [CLS] token 特征方便后续实验对比。5.3 构建典型性图谱特征收集完成后调用SVDDictionary.fit()构建 SVD 字典。这里有几个超参数需要确定n_components投影子空间的维度。维度太低会丢失信息维度太高又会引入噪声。实验时可以从 16、32、64 开始尝试。layer_indices哪些层的特征参与构建。不同层的结果差异很大建议在 8 到 11 层之间做对比。agg图像级分数的聚合方式。训练 SVD 字典的脚本# 文件路径train_svd.py import numpy as np from svd_dictionary import SVDDictionary def main(): features np.load(outputs/id_features.npy) svd_dict SVDDictionary(n_components64) svd_dict.fit(features) np.save(outputs/svd_V.npy, svd_dict.V) np.save(outputs/svd_proj_mean.npy, svd_dict.proj_mean) np.save(outputs/svd_proj_std.npy, svd_dict.proj_std) print(SVD dictionary saved.) if __name__ __main__: main()保存这些矩阵之后推理阶段只需要加载 V、proj_mean、proj_std 三个数组而不需要保留原始训练特征可以显著减少模型部署时的内存占用。这也是 SVD 方法的一个工程优点字典体积小便于发布。5.4 运行与验证构建完字典之后我们分别对 ID 测试集和 OOD 测试集计算图像级得分然后计算 AUROC 和 FPR95。AUROCArea Under the Receiver Operating Characteristic Curve越接近 1表示 ID 和 OOD 的得分分布区分度越好FPR95 表示在 TPR 达到 95% 时OOD 样本被误判为 ID 的概率越低越好。评估脚本示例如下# 文件路径evaluate.py import numpy as np from sklearn.metrics import roc_auc_score from typicality_map import TypicalityMapBuilder from svd_dictionary import SVDDictionary def compute_scores_for_loader(extractor, loader, builder, max_batchesNone): scores [] for i, (images, _) in enumerate(loader): feats extractor.get_concat_feature(images) # (B, N1, D) for j in range(feats.shape[0]): token_feats feats[j].numpy() scores.append(builder.build_image_score(token_feats, aggmean)) if max_batches is not None and i max_batches: break return np.array(scores) def evaluate(): extractor TimmViTFeatureExtractor(layer_indices(10, 11)) svd_dict SVDDictionary(n_components64) features np.load(outputs/id_features.npy) svd_dict.fit(features) builder TypicalityMapBuilder(svd_dict, img_size224, patch_grid(14, 14)) id_loader load_cifar10_test(batch_size8) ood_loader load_svhn_test(batch_size8) id_scores compute_scores_for_loader(extractor, id_loader, builder, max_batches100) ood_scores compute_scores_for_loader(extractor, ood_loader, builder, max_batches100) labels np.concatenate([np.zeros(len(id_scores)), np.ones(len(ood_scores))]) scores_all np.concatenate([id_scores, ood_scores]) auroc roc_auc_score(labels, scores_all) print(fAUROC: {auroc:.4f}) if __name__ __main__: evaluate()需要说明示例中max_batches限制是为了快速验证流程正式实验应使用全部测试集否则结果会有较大波动。5.5 结果说明运行上述脚本后你会得到类似下面的输出具体数值随随机种子和数据版本变化Collected feature shape: (980000, 1536) SVD dictionary saved. AUROC: 0.8912如果把agg从mean换成maxAUROC 可能会有所变化。通常来说max 对局部异常更敏感mean 对全局分布偏移更敏感。如果你的 OOD 样本是“全局风格不同”例如真实图片 vs 卡通图片mean 往往更好如果 OOD 样本是“局部语义异常”例如正常场景中混入一个陌生物体max 或 p95 往往更好。调参时应该结合业务场景决定。此外你还可以输出几张典型性热力图直观观察模型认为哪些区域“不寻常”。可视化脚本# 文件路径visualize.py import matplotlib.pyplot as plt import cv2 import numpy as np def visualize_heatmap(image_np, heatmap, save_pathoutputs/heatmap.png): # image_np: (H, W, 3) 图像值范围 0-255 heatmap_norm (heatmap - heatmap.min()) / (heatmap.max() - heatmap.min() 1e-8) heatmap_color cv2.applyColorMap((heatmap_norm * 255).astype(np.uint8), cv2.COLORMAP_JET) overlay cv2.addWeighted(image_np, 0.6, heatmap_color, 0.4, 0) plt.figure(figsize(12, 4)) plt.subplot(1, 3, 1) plt.imshow(cv2.cvtColor(image_np, cv2.COLOR_BGR2RGB)) plt.title(Original Image) plt.subplot(1, 3, 2) plt.imshow(heatmap, cmapjet) plt.title(Typicality Score Map) plt.subplot(1, 3, 3) plt.imshow(cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)) plt.title(Overlay) plt.savefig(save_path, dpi150, bbox_inchestight)在实际项目中这种热力图可以作为模型日志的一部分用于人工审核。当系统检测到高 OOD 分数时不仅记录分数还要保存热力图和原始图像方便后续排查。6. 常见问题与排查思路在实际落地 SVD-based Typicality Map 的过程汇总最容易遇到下面几个问题。问题现象常见原因解决思路SVD 计算时内存不足特征矩阵 M 太大直接对 (M, D) 做 SVD减少采样数量或对特征先做PCA降维改用增量式SVD热力图分辨率与模型 patch_grid 不匹配不同 ViT 变体的 patch size 不同根据img_size // patch_size计算 grid 尺寸投影坐标标准差出现 NaN某些主方向奇异值接近 0增加1e-6防止除零或过滤掉能量过低的方向多次运行结果不一致SVD 符号翻转不同数据采样导致字典不同固定随机种子必要时对奇异向量符号做对齐hook 没有生效特征为空model.eval()与 hook 注册顺序问题或模型结构属性名错误打印模型结构确认 block 属性名调用 extract 前先清空 features 字典得分区分度很差使用的特征层太浅或 n_components 设置太小尝试靠近最后的层逐步增加 n_components 并在验证集上对比CPU 显存不足get_concat_feature把太多特征搬到 CPU在 GPU 上完成拼接后再整体搬回或减小 batch size关于 SVD 符号翻转的问题值得展开。SVD 得到的奇异向量在数学上并不是唯一的改变某个奇异向量的符号分解结果依然成立。如果我们在不同的训练阶段分别构建 SVD 字典两次得到的 V 矩阵可能在某些列上符号相反这会导致投影坐标发生符号翻转进而影响得分。解决办法是在训练前固定随机种子并保持训练数据顺序一致如果确实需要跨版本比较可以在拟合后做一次符号对齐例如强制每个主方向上与第一个奇异向量相同符号的训练特征数量大于一半。另一个常见问题是“特征空间差异较大”。如果你在不同数据集上分别构建字典得到的投影基向量完全不同。因此SVD 字典必须与你的 ID 训练集严格绑定不能跨模型、跨数据集复用。模型权重如果发生变化例如经过微调或增量训练原有字典需要重新构建。7. 最佳实践与工程建议7.1 数据划分与阈值校准在正式使用 OOD 检测系统之前一定要单独划分一个验证集用于校准阈值。通常我们会采集一部分“已知正常”的样本作为 ID 验证集再采集一部分“已知异常”或“模拟异常”的样本作为 OOD 验证集。在验证集上绘制得分分布图选择一个同时满足业务要求的阈值。例如医疗影像场景中漏检 OOD 的代价更高因此阈值应设置得更严格优先降低 FPR95在推荐系统场景中过度拒识会影响用户体验阈值可以适当放宽。注意不要用测试集调阈值。一旦用测试集调参评估指标就会乐观偏置上线后真实性能可能明显变差。更合理的流程是训练集构建 SVD 字典验证集校准阈值测试集做最终评估。7.2 多层特征融合与多尺度策略单一的最后一层特征并不总是最优选择。不同层特征对不同类型的 OOD 敏感度不同。工程上可以同时提取倒数第 1、第 2、第 3 层特征分别构建 SVD 字典并计算得分最后通过加权平均或 max 融合得到最终分数。多尺度策略同样是提升鲁棒性的常用手段。将一张图像缩放到 1.0、0.75、1.25 三个尺度分别计算典型性得分再取均值可以有效减少图像尺寸变化带来的影响。当然这会增加计算量需要根据业务响应时间要求做取舍。7.3 异常分数校准与可解释性热力图的价值在于“让人看得懂”。建议在实际系统中保存三样东西原始图像、典型性热力图、图像级异常分数。很多情况下单纯看分数很难定位问题但结合热力图就能快速判断模型是对图像中的哪些区域产生了“陌生感”。例如一个在室内场景训练的模型面对一张包含天空和树木的室外图像时热力图大概率会在图像上半部分形成高亮区域说明模型对天空纹理缺乏认知。这种可解释性对模型审计和业务沟通都很有帮助。7.4 安全与权限约束OOD 检测通常部署在模型推理服务中涉及数据隐私和模型安全。开发者需要明确一点OOD 检测不能替代内容安全审核也不能作为对抗样本的唯一防御手段。SVD-based Typicality Map 主要解决的是分布偏移问题对刻意构造的对抗样本可能失效。在生产环境中务必遵守最小权限原则限制对模型内部特征的访问权限避免攻击者利用特征向量进行模型窃取或对抗攻击。在测试环境验证过程中不要使用未经授权的数据如果涉及敏感数据应先脱敏再进行特征提取。7.5 性能优化当推理吞吐量要求较高时SVD 字典构建和特征提取可以分离。SVD 字典构建是离线任务只需要在模型发布或数据分布变化时重新训练在线任务的瓶颈主要在 ViT 推理和特征提取。热力图生成阶段n_components 不宜设置过大否则矩阵乘法会增加耗时。实测中n_components64 通常能在精度和速度之间取得较好的平衡。如果进一步追求效率可以把 V、proj_mean、proj_std 提前合并为一个偏置项和线性变换矩阵将多个矩阵乘法合并为一次运算。8. 总结与学习路线本文围绕 SVD-based Typicality Maps 在 Vision Transformer 上进行 OOD 检测做了完整拆解从模型原理、方法流程到代码实现和工程建议基本上覆盖了从零搭建一套 OOD 检测系统的关键环节。回顾一下核心要点我们利用 ViT 的 token 特征将训练集特征矩阵进行 SVD 分解得到低维正交基然后将每个 token 投影到正交基上通过标准化坐标的偏离程度计算典型性得分最后将得分聚合成图像级分数或可视化的热力图。整个过程不需要额外训练分类器字典体积小推理开销可控。下一步可以从以下几个方向继续深入在更大规模数据集如 ImageNet 子集上复现实验观察不同 ViT 变体DeiT、Swin Transformer下的表现差异。尝试把 SVD-based Typicality Map 与其他 OOD 方法结合例如将典型性得分与 energy score 做加权融合看 AUROC 是否提升。研究低秩微调LoRA场景下的影响。LoRA 微调会改变下游任务的特征分布原本在预训练特征上构建的 SVD 字典很可能失效需要考虑是否在微调后的特征上重新拟合字典。学习 OOD 检测的评估体系例如分布内性能保持度、OOD 检测的校准误差等指标建立更完整的模型监控体系。如果你正在做模型上线前的鲁棒性评估建议先把本文的代码在小型数据集上跑通重点对比不同特征层、不同 n_components 和不同聚合策略的影响再用验证集校准阈值。这种方式可以帮你快速判断这套方法在你的业务数据上是否值得投入。实际项目中没有一种 OOD 检测方法是银弹更重要的是建立“数据分布漂移监控 异常样本回流标注 模型定期更新”的完整闭环。