ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Transformer的多模态异常检测:跨模态重建误差原理与工业落地

基于Transformer的多模态异常检测:跨模态重建误差原理与工业落地 简介基于Transformer的多模态异常检测项目资料包内含数据集和教程面向有深度学习基础、想深入掌握异常检测与多模态学习的开发者与研究者。项目利用Transformer自注意力机制处理视觉、听觉等异源数据解决传统RNN/CNN难以捕捉跨模态长期依赖的痛点适合作为课程设计、论文复现或工业异常监测的参考方案。压缩包共314个文件以npy数据、txt说明、csv数据集、py源码、md教程文档为主整体约107.6MBnpy与csv构成训练和测试数据py脚本实现模型定义、预处理与训练流程md文件提供环境配置与运行指南。目前已有256人学习使用。通过此包可完整走通多模态异常检测的代码实现理解如何加载图像、音频等不同模态数据并融合特征附带的多个csv数据集如合成异常、机器温度故障等可直接用于实验验证配合源码中的训练与评估逻辑帮助快速上手Transformer在异常检测场景中的实战应用。1. 基于Transformer的多模态异常检测为什么我放弃单模态改用跨模态重建误差做工业质检和服务器监控的同行应该都有类似经历单模态模型在训练时指标漂亮一上线就频繁误报。图像信号稳定时还好一旦光照变化、传感器丢包、某个模态数据质量变差单模态模型就会把正常样本判成异常。我后来转向基于Transformer的多模态异常检测核心思路不再依赖单一模态的绝对特征而是让模型去学习模态之间的一致性——正常样本各模态互相印证异常样本一定会在某个模态上“说不圆”。这篇文章就是沿着这个标题讲透多模态异常检测为什么要用Transformer、数据怎么准备、模型怎么搭、参数怎么调、坑在哪。适合正在做工业质检、设备预测性维护或视频监控异常的工程师无论你是第一次接触Transformer还是已经在跑单模态异常检测想升级。2. 多模态异常检测到底难在哪Transformer凭什么是合适的骨架2.1 异常检测的本质是分布外检测不是分类很多刚入手的同学会把异常检测当成二分类问题给正常和异常各贴标签去训一个分类器。这在工业场景里几乎行不通因为异常的种类是开放的——设备可能因为轴承磨损、电路老化、异物卡入、传感器漂移等无数种原因出问题你不可能在训练时收集全所有异常模式。异常检测更准确的定位是“分布外检测”训练阶段只给正常样本让模型记住正常数据的分布推断阶段计算输入样本与正常分布的偏差偏差超过阈值就判定为异常。这里有个反直觉的结论分类模型对“见过的异常”有效对“没见过的异常”反而可能给出高置信度的正常判断而基于重建误差或特征距离的异常检测方法对任何偏离正常分布的输入都会给出高分。Transformer在这类任务里的角色不是去学“什么是异常”而是去学“正常的跨模态模式是什么样的”。所以异常分数通常定义为重建误差、特征距离或一致性分数而不是分类器的softmax输出。2.2 多模态的三种融合方式与Transformer的注意力优势多模态异常检测的难点在于“怎么融合”。我见过三种融合方式按融合位置划分早期融合输入级拼接、晚期融合决策级投票、中间融合特征级交互。早期融合最简单把图像像素、时序数值、文本特征拼成一个长向量喂给模型但这样做忽略了模态间的异步性和异构性比如图像是30帧/秒传感器是100Hz采样直接把数值拼接会让模型无法对齐。晚期融合的效果也不理想因为每个模态独立打分后再投票本质上退化成多个单模态模型等于没做多模态。中间融合是Transformer真正发挥价值的地方。Transformer的self-attention可以在特征空间中让不同模态的token互相“看到对方”图像patch通过注意力找到与它最相关的传感器读数文本token通过注意力对齐到对应的视觉区域。这种跨模态注意力等价于在模型内部做对齐和交互比手工设计的融合规则可靠得多。尤其是跨模态注意力cross-attention可以设计成双向交互而不是简单拼接后一起做self-attention。2.3 为什么不用CNN/LSTM/VAE而用Transformer异常检测领域的旧方案各有各的短板我简单列一下选型理由CNN如ResNet、Swin Transformer的CNN变体擅长提取局部空间特征但感受野有限。多模态异常往往体现为长距离依赖比如温度传感器读数与几个月前的某个事件相关CNN很难捕捉这种全局长程关系。Swin Transformer虽然用窗口注意力缓解了计算量但本质上仍是局部优先跨模态交互需要额外设计分支。LSTM/GRU适合时间序列异常检测但多模态场景下要同时建模视觉和序列时LSTM只能顺序处理很难并行对齐两个异构模态。而且LSTM对长时间跨度的依赖仍然有梯度衰减问题。VAE/GAN单模态重建效果好但多模态场景下VAE需要假设各模态之间独立或线性相关不符合真实数据特性。GAN训练不稳定异常检测的收敛性更难控制。Transformer的优势在于第一self-attention的复杂度是O(n²)patch序列化后可以建模任意位置的长程依赖第二模态对齐可以统一成“把各模态token放进同一个序列用注意力让它们交互”第三多头注意力让模型同时关注模态内和模态间的关系。当然Transformer不是万能的后面我会讲到小数据集上它比CNN更容易过拟合。3. 把数据集整理成Transformer能吃的样子预处理与样本构造3.1 数据集长什么样多模态样本的组织方式标题里说“内含数据集和教程”说明作者已经帮你准备好了数据。但拿到数据集后第一件事不是急着写模型而是先搞清楚数据组织格式。常见做法是每个样本由一个样本ID关联多个模态文件比如工业质检一个样本ID对应一张产品图像、一组振动传感器曲线、一个文本描述如批次号、设备参数。运维监控时间窗口内采样的温度、压力、电流等多维时序加上同窗口的监控视频帧或设备照片。视频异常检测一段视频被切分为视频片段帧序列、对应音频特征、以及字幕/元数据文本。数据集目录一般是按模态分子目录或者按样本ID分文件夹。你需要先写一个数据加载器把多个模态的文件对齐到同一个sample。这里的“对”指样本ID一致但时间戳、分辨率、长度可能完全不同需要在代码里做显式对齐。我一般会先打印所有样本的模态长度分布看看有没有样本缺某个模态文件——后面我会讲模态缺失是最大的坑之一。3.2 图像与时序/文本的编码patch化与归一化Transformer需要序列输入所以每个模态都要转成token序列。下面是三种常见模态的预处理伪代码覆盖了多模态异常检测里出现频率最高的形态图像、多变量时间序列、文本标签。import torch import torch.nn.functional as F from torchvision import transforms from PIL import Image # ---------- 图像模态切patch ---------- def image_to_patches(image_path, patch_size16, max_patches196): 把图像切成 (N, C, patch_size, patch_size) 的patch序列 img Image.open(image_path).convert(RGB) # 先缩放到固定尺寸比如224x224 img img.resize((224, 224)) tensor transforms.ToTensor()(img) # (3, 224, 224) C, H, W tensor.shape num_patches_h H // patch_size num_patches_w W // patch_size # view permute实现patch切分等价于F.unfold patches tensor.permute(1, 2, 0).reshape( num_patches_h, patch_size, num_patches_w, patch_size, C ) patches patches.permute(0, 2, 1, 3, 4).reshape( num_patches_h * num_patches_w, C, patch_size, patch_size ) # 限制最大patch数超出部分丢弃通常取前max_patches patches patches[:max_patches] return patches # (num_patches, 3, 16, 16) # ---------- 多变量时序模态滑动窗口标准化 ---------- def series_to_window(series, window_size64, stride1, eps1e-6): series shape: (T, D)返回 (windows, D) 的窗口序列 T, D series.shape windows [] start 0 while start window_size T: w series[start:start window_size] # (window_size, D) # 沿时间轴做z-score标准化需要复用训练集的mean/std mean w.mean(dim0, keepdimTrue) std w.std(dim0, keepdimTrue) eps w_norm (w - mean) / std windows.append(w_norm) start stride return torch.stack(windows) # (num_windows, window_size, D)先说图像切patch的逻辑。这里用permutereshape把一张224×224×3的图像切成14×14共196个patch每个patch保持通道维在最后。实际用在Transformer里时还需要把每个patch展平并线性投影成embedding这个我放到模型章节。注意max_patches是为了统一序列长度——不同尺寸图像切出的patch数量不同batch内必须一致所以要么统一resize要么限制最大patch数并做mask。时序窗口化时最容易被忽略的是标准化参数。直接在每个窗口内部做z-score会让每个窗口都变成0均值1方差等于把振幅异常抹掉了——而振幅突变恰恰是异常检测最重要的信号。正确做法是先用训练集的所有正常样本统计每个特征维度的全局mean/std再用这套参数去标准化所有窗口。我在代码里用w_norm (w - mean) / std只是示意窗口内归一化真正落地时应改为传入全局参数。文本模态在异常检测里一般不是长文本而是短标签、型号、操作指令例如“转速1200rpm、温度正常”这样的描述。处理方式有两种如果数据量足够直接用一个小的预训练BERT输出句向量作为token如果标签是少数离散值比如5种型号可以直接做整数编码并添加可学习的embedding表。千万不要把中文标签直接one-hot当token那种做法学不到语义关系。3.3 训练/验证/测试划分与异常注入多模态异常检测的划分逻辑和常规监督学习完全不同。标准做法是训练集只包含正常样本。你可以随机抽取80%的正常样本用于训练20%的正常样本作为验证集的一部分。验证集包含正常样本和一小部分已知异常样本用来选择阈值、早停、调超参数。很多人只留正常样本导致训练完不知道该把阈值设多高。测试集包含正常样本和多种异常类型异常类型必须与验证集不完全重复才能评估模型的泛化能力。如果数据集本身没有标注异常常见的做法是“人工注入异常”。比如图像模态上添加光照突变、遮挡、条纹噪声时序模态上添加脉冲尖峰、平台漂移、周期性干扰文本模态上替换成不存在的型号或乱码。注入异常的目的是让你能计算AUC和F1。我一般会准备一个“异常注入工具函数”把异常强度作为参数方便后续做异常分数阈值评测。def inject_anomaly(sample_dict, anomaly_typespike, strength1.0): 在正常样本上注入异常返回注入后的样本和标签1 # 图像加随机高斯斑点或遮挡块 if anomaly_type blob: img sample_dict[image] h, w img.shape[-2:] blob torch.randn_like(img[:, :h//8, :w//8]) * strength img[:, :h//8, :w//8] img[:, :h//8, :w//8] blob sample_dict[image] img # 时序在随机位置加脉冲 elif anomaly_type spike: series sample_dict[series] # (T, D) pos torch.randint(0, series.size(0), (1,)) series[pos, :] series[pos, :] * (1 strength * 3) sample_dict[series] series return sample_dict, 1注入异常时有个容易犯的错误——把异常加得太重导致模型学到的“区分正常/异常”变成“区分有噪声/无噪声”而不是真正的异常语义。建议强度从低到高做多档测试先确认模型在低强度异常上仍然能给高分再看强度提升时分数是否单调上升。如果分数不单调说明模型对某些异常模式不敏感需要调整融合策略。4. 搭建并训练一个最小可用的Transformer多模态异常检测模型4.1 模型结构模态编码器 跨模态Transformer 重建头整体结构分为三层每个模态单独编码成embedding序列然后做跨模态注意力融合最后通过重建头输出每个token的重建值用重建误差作为异常分数。下面是核心模型代码基于PyTorch实现。import torch import torch.nn as nn import math class PatchEmbed(nn.Module): 图像patch线性投影成embedding def __init__(self, in_ch3, patch_size16, embed_dim128): super().__init__() self.proj nn.Conv2d(in_ch, embed_dim, kernel_sizepatch_size, stridepatch_size) self.patch_size patch_size self.embed_dim embed_dim def forward(self, x): # x: (B, C, H, W) - (B, num_patches, embed_dim) x self.proj(x) x x.flatten(2).transpose(1, 2) return x class SeqEmbed(nn.Module): 时序窗口的embedding取每个窗口最后特征做线性映射 def __init__(self, in_dim, embed_dim): super().__init__() self.proj nn.Linear(in_dim, embed_dim) def forward(self, x): # x: (B, T, D) - (B, embed_dim) 先做全局平均池化再投影 x x.mean(dim1) return self.proj(x).unsqueeze(1) # (B, 1, embed_dim) class CrossModalTransformer(nn.Module): 把不同模态的token序列拼一起做self-attention def __init__(self, embed_dim128, num_heads8, depth6, dropout0.1): super().__init__() self.embed_dim embed_dim self.cls_token nn.Parameter(torch.zeros(1, 1, embed_dim)) self.pos_embed nn.Parameter(torch.zeros(1, 2048, embed_dim)) encoder_layer nn.TransformerEncoderLayer( d_modelembed_dim, nheadnum_heads, dim_feedforwardembed_dim*4, dropoutdropout, activationgelu, batch_firstTrue, norm_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layersdepth) # 重建头把每个token重建回原始特征维度 self.reconstruct nn.Linear(embed_dim, embed_dim) self._init_weights() def _init_weights(self): nn.init.trunc_normal_(self.cls_token, std0.02) nn.init.trunc_normal_(self.pos_embed, std0.02) def forward(self, seqs, masksNone): # seqs: list of (B, n_tokens, embed_dim) seq torch.cat(seqs, dim1) B, N, _ seq.shape seq seq self.pos_embed[:, :N] seq torch.cat([self.cls_token.expand(B, -1, -1), seq], dim1) out self.encoder(seq) cls_out out[:, 0] token_out out[:, 1:] recon self.reconstruct(token_out) return cls_out, recon模型设计说明PatchEmbed用卷积做patch投影这是ViT的标准做法把每个patch一次映射成embedding比先展平再线性变换更高效。SeqEmbed把多变量时序经过时间池化变成一个token这里做了一个简化——如果时序过长最好把窗口切分成多个片段得到多个token否则一个时序模态只有一个token会损失时间细节。在实际项目中我会把序列按步长切成多个子窗口每个子窗口一个token从而保留时间局部性。CrossModalTransformer里所有模态的token拼成一个序列加上一个cls_token用来汇聚全局信息。这里的“跨模态”体现在self-attention会看到所有模态的token。如果你希望更显式的跨模态对齐可以改成两层结构每个模态内部先做self-attention再通过cross-attention向其他模态查询。但最小模型直接用拼接self-attention就足够了。注意pos_embed我预分配了2048长度这是上限实际序列长度必须小于等于它。训练时如果序列长度很短不需要截断推理时如果超过就报错所以设计时要估算最大token数。4.2 训练策略与损失函数重建误差 特征匹配异常检测模型的训练目标是让模型学会重建“正常模式”异常样本的重建误差会很大。我用重建误差作为主损失同时加上特征匹配损失来稳定训练。def train_one_epoch(model, dataloader, optimizer, device, lambda_feat0.1): model.train() total_loss 0 for batch in dataloader: # batch是多模态dict每个模态B个 image batch[image].to(device) # (B, C, H, W) series batch[series].to(device) # (B, T, D) text_ids batch[text_ids].to(device) # (B, L) # 获得各模态embedding img_emb model.patch_embed(image) # (B, N_img, D) ser_emb model.seq_embed(series) # (B, 1, D) txt_emb model.text_embed(text_ids) # (B, N_txt, D) seqs [img_emb, ser_emb, txt_emb] cls_out, recon model(seqs) # 重建目标用原始embedding作为target target torch.cat([img_emb, ser_emb, txt_emb], dim1) recon_loss F.mse_loss(recon, target) # 特征匹配损失让相邻模态的cls特征尽量一致 feat_loss model.feature_matching(cls_out, target) # 内部实现为cosine距离 loss recon_loss lambda_feat * feat_loss optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这里重建目标不是原始图像像素而是patch embedding。为什么这么做因为Transformer在embedding空间做重建比在像素空间重建更容易收敛而且异常检测关注的高层语义差异在embedding空间更明显。像素重建太细会把光照、平移等无关变化也当成高重建误差导致误报增多。feature_matching我简写了常见实现是把cls token与各模态token的全局池化向量算余弦距离强迫全局信息与各模态信息保持一致。这个损失函数的作用是防止模型只靠某一个模态重建而忽略跨模态交互。λ系数我一般设0.1如果发现模型对单模态过拟合即异常分数只反映图像重建误差就调大到0.5。训练时还需要注意Transformer的embedding维度不宜太大异常检测数据量通常比分类任务小128维足够大模型容易过拟合。学习率用AdamW初始学习率1e-4配合warmupcosine退火。batch size尽量开到32以上如果显存不够就减小embedding维度或深度。4.3 必调参数patch大小、模态dropout、学习率、窗口长度这组参数是经验值我直接给推荐范围和调整逻辑参数推荐范围调整逻辑patch_size16224×224图像patch越小序列越长模型越能捕捉细节但显存和时间成本线性上涨。patch大于32会丢失细粒度异常。embed_dim64256数据量小用64/128数据量大用256。超过512容易在异常检测任务上过拟合。transformer_depth48深度越大跨模态交互越充分。工业小数据集建议4层起步6层足够。模态dropout0.10.3训练时随机丢弃某个模态的所有token增强模型对模态缺失的鲁棒性。学习率1e-4warmup使用cosine退火到1e-5。过大的学习率会让重建损失震荡。时序窗口长度数据采样率的1/501/100窗口太短看不到周期规律太长会让位置编码压力大。建议先做滑动窗口实验。模态dropout是最值得调的一个参数。它是指在每个batch里随机选一个模态把它的token全部置为0。这样模型无法依赖固定模态被迫学习跨模态规律。我见过很多项目不设模态dropout一旦线上某个传感器故障导致该模态全部为0模型会把正常样本判为异常。加了模态dropout后模型见过“看不见某个模态”的情况对缺失模态的鲁棒性提升明显。5. 避坑指南多模态Transformer异常检测的6个常见翻车点5.1 模态缺失引发全量误报现象模型在验证集上AUC很高上线后只要某个传感器断连或某路摄像头黑屏系统就开始疯狂报警而且全是假异常。原因训练时所有模态都是完整的模型学会了“所有模态必须同时出现”。推断时某个模态缺失模型把这看成一种从未见过的输入分布重建误差暴涨。解决训练时开启模态dropout随机屏蔽一个模态。具体做法是在数据加载时以20%概率把某个模态替换成全零token或一个可学习的[MISSING] token。另外推断时如果检测到模态缺失可以单独用剩余模态计算异常分数也可以将缺失模态的token全部置为[MAU]。5.2 训练集混入异常样本模型把异常也学会了现象模型在验证集上异常分数普遍偏低尤其对某些异常类型完全不敏感AUC只有0.6左右。原因数据准备时只按标签“正常”筛选但标签本身不干净。工业数据里很多“正常”样本实际包含轻微蹭伤、背景噪点、环境波动被当成正常样本训练后模型把这些也纳入了正常分布。解决训练前先做离群点清洗。我一般先跑一个简单的单模态autoencoder计算每个样本的重建误差把误差高于P95的样本拿出来人工看一眼。如果确实有异常痕迹就删掉或修正标签。宁可少训练样本也不要让污染样本参与训练。5.3 位置编码长度不够序列被截断导致性能骤降现象输入图像的宽高比不固定模型训练时序列长度不统一运行时报错或性能下降。原因我前面预分配的pos_embed长度有限。如果某个模态token数超过预设的2048直接截断会丢失远端patch信息如果小于2048又有大量位置编码没参与计算浪费参数。解决要么把所有图像resize到固定大小并统一patch个数要么用插值方式动态调整位置编码。对于异常检测我推荐统一resize因为异常检测对尺度不敏感。另外时序模态如果窗口长度变化大可以用自适应池化把窗口统一到固定token数。5.4 重建误差对“纹理异常”敏感对“结构异常”不敏感现象图像上有划痕、变色等纹理异常时异常分数很高但物体部位缺失、装配关系错位时异常分数反而很低。原因重建目标用的是patch embedding模型在embedding空间重建时主要恢复了局部纹理信息而没有充分建模全局结构关系。解决在损失函数里加入结构约束。常见做法是额外让模型预测patch之间的相对位置关系类似Swin Transformer的relative position bias或者对重建的embedding做一次全局平均池化同时计算池化后的特征距离。更有效的做法是使用掩码重建——随机mask一部分模态token让模型根据其他模态去预测被mask的部分这样模型必须理解结构才能真正重建。5.5 异常分数阈值用验证集固定换环境就失效现象模型在A设备上报警正常到了B设备上同样阈值疯狂误报或漏报。原因不同设备、不同工况下的正常数据分布不完全一致同一个重建误差的绝对值可能偏移。解决不要用全局阈值改为“自适应阈值”。常见做法是维护一个正常情况下异常分数的滑动窗口取当前均值加3倍标准差作为动态阈值。或者把异常分数先做z-score标准化再与标准阈值比较。如果环境差异太大就要做domain adaptation比如用少量新环境正常样本做微调。5.6 多模态融合变成“单模态主导”现象去掉图像模态模型性能几乎不变去掉时序模态性能骤降。说明时序模态主导了决策图像模态基本没起作用。原因不同模态的信息量不均衡时序数据重建任务简单损失被优化得很低图像重建任务难损失一直偏高。梯度被容易的任务主导模型忽略了图像。解决给每个模态的重建损失加上可学习的权重而不是简单相加。可以用不确定性加权heteroscedastic uncertainty每个模态的损失除以该模态的可学习方差参数。另一个做法是让每个模态单独计算异常分数再通过加权平均或秩聚合得到最终分数这样即使某个模态被忽略也能单独输出该模态的异常贡献方便排查。6. 验证与部署用异常分数阈值和可视化看模型是否真的在查异常异常检测模型不能只看AUC你必须确认它“真的在查异常”而不是在查某种固定噪声。我习惯做三件事。第一绘制异常分数分布曲线。正常样本的分数应该集中在低值区异常样本的分数应该拉开一个尾巴两者分得越开越好。如果正常分数和异常分数重叠严重说明模型的判别力不够优先去调patch大小和模态dropout而不是换模型。第二用滑动窗口可视化每个模态的重建误差。把图像patch的重建误差映射回原图分辨率生成热力图你能直观看到模型关注的是不是真正的缺陷区域时序模态则画出原始曲线和重建曲线的对比看差异是不是发生在真实异常时刻。如果热力图红点在正常纹理上乱跳说明模型学到的是“纹理不一致”而非“结构异常”这时候要回看第5.4节的结构约束。第三阈值选取不要贪图验证集上的最优F1。我一般会调低一些选择验证集上精确率和召回率曲线拐点往右移一点的位置。因为在真实场景里漏掉一个异常往往比多报几个正常更严重代价不对称。具体操作是把验证集所有异常分数排序找到让“正常样本误报率低于1%”的最大阈值然后把这个阈值乘以一个0.8的保险系数。部署时还有一个细节Transformer推理速度可能不满足实时要求。如果单条样本推断时间超过100ms先把图像侧patch size从16改成32序列长度直接降为1/4再精简深度从6层降到4层。如果还不行就把时序模态先做特征提取用轻量MLP替代Transformer分支。异常检测任务里性能下降一点换来的实时性通常值得。最后分享一个血泪经验我最早跑通模型时兴奋地直接在测试集上挑阈值结果AUC 0.97落地后一塌糊涂——因为测试集里异常类型和训练集太像。后来我学到一个习惯把数据集按时间顺序切分用前一段正常数据训练后一段正常异常数据测试模拟真实的时间漂移。这个方法虽然会让AUC掉到0.8以下但上线后很少翻车。多模态Transformer异常检测拼的不是模型有多花哨而是数据预处理和评价方式是否贴近真实分布。希望这篇笔记能帮你少踩几个坑踏踏实实把这个方案落地。本文还有配套的精品资源点击获取
返回列表