☰
基于Transformer的多模态异常检测:原理、数据与PyTorch实战
2026/10/14 9:33:11 网站建设 项目流程

简介:面向机器学习、深度学习与异常检测方向学习者的Transformer多模态异常检测项目资源包,涵盖完整数据集、可运行源码与配套教程,旨在解决如何融合多模态信息并识别异常的核心问题,适用于课程设计、竞赛备赛及算法研究。压缩包共314个文件,体积约107.6MB,以npy数据数组、csv时序表格、py源码脚本、md教程文档为主,辅以txt说明与json配置,整体结构清晰,便于按需查阅。目前已有256人参与学习/下载,适合正在研究自注意力机制、多模态建模或异常检测的读者。资源内置多个合成及真实场景的异常检测数据集,并提供src目录下的模型构建、训练与评估代码,以及详细的README项目说明;跟随其中步骤,可掌握多模态数据预处理、Transformer模型构建和实验结果复现的完整流程,是一份理论与实践结合的高质量参考材料。

1. 基于Transformer的多模态异常检测是什么:为什么单模态方案开始不够用了

监控画面里人摔倒了,图像上"突然躺下"的运动轨迹并不醒目;但同一时刻音频里有一声撞击,门禁传感器也出现异常记录。单看任何一路信号,漏报和误报都很难压住——这正是"基于Transformer的多模态异常检测"要解决的问题:把视频帧、音频频谱与传感器数值一起送进Transformer做token级交互,用注意力机制对齐多模态特征,从重建误差中揪出偏离正常分布的样本。

多模态不等于"多个单模态模型投票"。真正有效的做法是让模态在特征层面互相查证:视频里的运动要向音频索要声音证据,传感器波形要向视频索要事件上下文,这个互相检索的过程恰好是Transformer的注意力结构最擅长的事。这个方向适合两类读者:一是手上同时有视频、音频、振动、温度等多路信号的安防与工业视觉工程师;二是想用一份带数据集的完整教程快速跑通基线的异常检测研究者。下面从原理、数据、代码到排坑,按我实际走过的顺序讲一遍。

2. Transformer凭什么接管多模态异常检测:注意力机制与三种融合架构

2.1 自注意力与交叉注意力:对齐为什么比拼接有效

传统做法是CNN提特征、把特征向量拼起来、再接分类或重构头。问题在于拼接发生在固定维度的向量上,模态之间的对应关系没有被建模:视频第12帧的运动和音频第3秒的撞击声有没有因果关系?拼接法回答不了这个问题,它只是把两段信息"并排"放着,后续网络要自己从零摸索对应关系,数据量不够时根本学不出来。

Transformer里的自注意力算的是序列内部两两token的关系。扩展到跨模态场景就是交叉注意力:用视频token做query,去音频token的key/value里检索最相关的信息,写成公式就是 Attention(Q_v, K_a, V_a) = softmax(Q_v K_aᵀ / √d) V_a。背后的直觉很直接:模型自动学习"哪些视觉patch与哪些音频频段相关"。正常运转的传送带,画面节奏和振动频率高度同步;轴承一旦磨损,画面节奏没变但振动频谱变了,交叉注意力的权重分布会跟着漂移——这个漂移就是异常信号。

所以在Transformer结构里,多模态对齐不是一个附加模块,而是注意力机制自带的计算过程。实际操作中,每个模态的Embedding层必须独立,不要让视频和音频共享同一个线性投影层。我踩过这个坑:共享Embedding后,token数多的视频模态主导了梯度,音频分支基本学废了。独立Embedding、再做深度交互,是稳妥的默认方案。

2.2 早期、中期、晚期融合:一张表看懂怎么选

融合时机决定了模型能看到什么、计算量多大、好不好解释。三种常见做法对比如下:

融合方式对齐粒度计算量可解释性适用场景
早期融合(输入端拼接)无真正对齐低差各模态物理单位一致、分辨率相同,较少见
中期融合(token级交互)token级中中视频+音频+传感器,最常用
晚期融合(决策级)无低好各模态已有成熟独立模型,做加权投票

我一般会选中期融合:每个模态先过一个小型编码器提取模态内特征,再把所有token拼成一个长序列,送进后半段共享编码器做交互。这样做的好处是模态内部结构不被破坏,跨模态交互又发生在足够深的网络里。缺点是对显存不友好——序列长度是三个模态token数之和,自注意力复杂度是O(n²),后面避坑章会详细讲怎么压缩。

在实际工程里,早期融合只有在极少数情况下成立,例如两个传感器采样率一致、量纲相近,直接把数值拼成多维向量。晚融合适合已有成熟单模态模型的场景,比如视频和音频各有一个调好的分类器,需要快速上线,那就先做决策级投票,后面再慢慢升级成中期融合。这个升级路径也是我把中期融合推荐给多数团队的原因。

2.3 异常分数的三种范式:重建误差、预测误差与特征距离

多模态异常检测的"异常"定义决定了训练目标和推理时的打分方式。常用的有三种范式:

  • 重建误差:训练时遮掉部分token,让模型用其余token重建。正常样本重建误差小,异常样本误差大。优点是不需要异常样本标注;缺点是模型可能"重建得过于好",把异常内容也一并重建出来。
  • 预测误差:用前T帧预测第T+1帧或下一时刻的多模态状态。异常事件难以预测,误差自然大。缺点是对噪声敏感,数据稍有抖动误差就飙升。
  • 特征距离:用一个预训练编码器把正常样本压成特征分布,测试样本离分布中心越远越异常。优点是不需要训练,缺点是分布漂移时误报严重。

做实际项目时我推荐前两种结合:以掩码重建为主、时间预测为辅,两路误差加权求和。只靠重建误差,对"正常但罕见"的样本(比如偶尔飘过的一片落叶)误报很高;只靠预测误差,在传感器噪声大的场景里指标会剧烈波动。两条路径互补,能在多数工业场景里把AUC往上拉3到5个点。三种范式不是互斥的,落地时按数据特点组合才是常态。

3. 数据集怎么组织:目录结构、时间对齐与预处理管线

拿到"内含数据集和教程的压缩包"后,第一步不是改模型,而是看清数据长什么样。多模态异常检测项目里,绝大多数看似模型的问题,最后都定位在数据组织上。

3.1 拿到数据先做三件事:目录结构、场景ID与元信息核对

常见做法是按"场景"而不是按"模态"组织目录。每个场景是一个样本,内部包含多路信号共用一个时间轴:

data/ ├── train/ │ ├── normal_001/ │ │ ├── frames/ # 按序号排列的视频帧 │ │ │ ├── 000001.jpg │ │ │ └── ... │ │ ├── audio.wav # 同时间段录音 │ │ ├── sensor.csv # 振动/温度等传感器数值 │ │ └── meta.json # 帧率、采样率、时间起点 │ └── normal_002/ └── test/ ├── normal_010/ └── anomaly_005/

这个结构的核心逻辑是:以"场景"为样本单位,所有模态共享同一条时间轴。单个视频帧不是独立样本,必须归属到场景里;训练测试划分也要按场景切,这一点直接影响评估是否真实,在第5章会展开。

打开数据集后我建议先做三件事:第一,检查meta.json里的视频帧率、音频采样率、传感器采样率是否和文件实际内容一致;第二,确认每个场景的三路信号起始时间是否对齐,很多数据集录制时设备独立启动,起始时刻差几秒很正常;第三,确认有没有提供划分好的训练测试列表。这三件事做完,你对这套数据的信任度心里就有数了。

3.2 帧、音频、传感器三路对齐:时间戳与重采样

对齐的目标是把三路信号统一到同一个时间基上,我这里以视频帧率为基准,把音频和传感器都插值到每一帧的时刻。伪代码如下:

import cv2, librosa, numpy as np, pandas as pd def align_modalities(video_path, audio_path, sensor_csv, fps=30, audio_sr=16000): # 1) 视频:直接按帧读取,帧i对应时刻 t = i / fps cap = cv2.VideoCapture(video_path) video_frames = [] while True: ok, frame = cap.read() if not ok: break video_frames.append(cv2.resize(frame, (224, 224))) video_frames = np.stack(video_frames) n_frames = len(video_frames) # 2) 音频:重采样到16k,按帧时长切分,每帧取一个短时能量特征 audio, _ = librosa.load(audio_path, sr=audio_sr) frame_len = audio_sr // fps audio_feat = np.array([ np.sqrt(np.mean(audio[i * frame_len:(i + 1) * frame_len] ** 2)) for i in range(min(n_frames, len(audio) // frame_len)) ]) # 3) 传感器:按时间戳线性插值到视频帧时刻 df = pd.read_csv(sensor_csv) # 至少包含 t 和 value 两列 t_sensor = df["t"].to_numpy() v_sensor = df["value"].to_numpy() t_frame = np.arange(n_frames) / fps sensor_feat = np.interp(t_frame, t_sensor, v_sensor) return video_frames, audio_feat, sensor_feat

对齐时两个细节要注意:传感器插值用线性插值即可,不要用高阶插值,传感器数据的物理突变会被过度光滑掉,异常尖峰直接消失;音频按帧取短时能量是快速验证用的粗粒度做法,正式实验建议换成log mel谱,但mel谱的时间分辨率要和视频帧率匹配,不然又是新的错位。

注意:对齐完成后务必人工抽几段"三路信号同屏图"肉眼确认,音频撞击声所在帧和视频里动作发生帧应该是同一帧。这一步排掉的坑,比调任何模型参数都多。

3.3 Patch Embedding与归一化:让三路信号进同一个序列

对齐完之后,三路信号还要转成Token才能进Transformer。这一步处理得好不好,效果差距比想象中大:

  • 视频帧:resize到224×224,按16×16的patch切,每个patch展平后过一个线性层变成768维token,共196个token。
  • 音频:先算log mel谱(128个mel频带、128个时间帧),再按16×16切片成64个patch token。
  • 传感器:时间序列按固定窗口切段,每段过一个1D卷积或MLP变成token,一段一个token。

数值归一化方面:图片像素除以255进[0,1];音频能量做z-score;传感器每个通道独立做z-score。这里有一个隐蔽的泄漏源:z-score的均值和方差只能从训练集统计,如果混入测试集,模型等于提前看到了测试数据的分布信息,上线后指标会明显缩水。很多数据集教程里给的预处理脚本没有注意这一点,照着跑结果虚高,换到真实环境就现原形。

数据增强上,视频做随机裁剪和翻转没问题,但传感器数据不要做随机关闭通道之类的强增强,会破坏异常信号的物理语义。音频可以加少量高斯噪声提高鲁棒性,但注意增强后的数据要重新对齐到视频帧,音频被时间拉伸后帧边界对不上,模型训练时会持续收到冲突信号。

4. 用PyTorch搭一个可运行的Transformer多模态异常检测模型

这一章给一套能直接照着改的模型骨架,结构不复杂,重点是让三路模态进入统一的token序列,然后用掩码重建做训练。

4.1 多模态Tokenization:视频帧、音频频谱与传感器数值转Token

先定义视频和音频的PatchEmbed模块。视频patch通过卷积完成分块和线性投影,等价于把每个16×16小块和768维可学习权重做内积;位置编码直接加到token上,让Transformer知道patch的空间位置。

import torch import torch.nn as nn class VideoPatchEmbed(nn.Module): def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768): super().__init__() self.patch_size = patch_size n_patches = (img_size // patch_size) ** 2 self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) self.cls_token = nn.Parameter(torch.randn(1, 1, embed_dim) * 0.02) self.pos_embed = nn.Parameter(torch.randn(1, n_patches + 1, embed_dim) * 0.02) def forward(self, x): # 输入 x: (B, C=3, H=224, W=224) x = self.proj(x).flatten(2).transpose(1, 2) # (B, 196, D) x = torch.cat([self.cls_token.expand(x.size(0), -1, -1), x], dim=1) x = x + self.pos_embed return x class AudioPatchEmbed(nn.Module): def __init__(self, mel_bins=128, time_steps=128, patch_size=16, embed_dim=768): super().__init__() self.proj = nn.Conv2d(1, embed_dim, kernel_size=patch_size, stride=patch_size) n_patches = (mel_bins // patch_size) * (time_steps // patch_size) self.pos_embed = nn.Parameter(torch.randn(1, n_patches, embed_dim) * 0.02) def forward(self, x): # 输入 x: (B, 1, mel_bins, time_steps) x = self.proj(x).flatten(2).transpose(1, 2) # (B, 64, D) x = x + self.pos_embed return x

音频mel谱是二维结构,纵轴频率、横轴时间。patch切分时16×16会同时跨频率和时间。如果你希望模型区分"高频噪声"和"低频振动",建议把patch设成(4,16)这种不对称形状,保留频率方向的分辨率;如果只关心事件起止,对称patch就够。传感器分支不需要PatchEmbed,一个线性层把每个时间窗口映射成embedding即可,窗口切得越长,token越少,但时间分辨率越低。

4.2 Transformer编码器与掩码重建:模型主体怎么搭

所有模态token拼成一个长序列后,输入共享的Transformer编码器。我加了一个可学习的mask_token,训练时用它替换被遮住的patch,测试时不遮。注意不能用"置零"代替mask_token——置零会让模型学到"零等于被遮",测试时输入分布和训练分布不一致,重建误差会失真。

class MultimodalAnomalyModel(nn.Module): def __init__(self, embed_dim=768, num_heads=12, depth=6, mask_ratio=0.15): super().__init__() self.video_embed = VideoPatchEmbed(embed_dim=embed_dim) self.audio_embed = AudioPatchEmbed(embed_dim=embed_dim) self.sensor_embed = nn.Linear(1, embed_dim) # 11落地可换1D Conv encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=embed_dim * 4, dropout=0.1, activation="gelu", batch_first=True) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=depth) self.norm = nn.LayerNorm(embed_dim) self.mask_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) self.head_video = nn.Linear(embed_dim, 3 * 16 * 16) self.head_audio = nn.Linear(embed_dim, 16 * 16) def _mask_tokens(self, tokens, mask_ratio): # tokens: (B, N, D),随机选 N*mask_ratio 个位置替换成 mask_token B, N, D = tokens.shape mask = torch.rand(B, N, device=tokens.device) < mask_ratio masked = torch.where(mask.unsqueeze(-1), self.mask_token.expand(B, N, D), tokens) return masked, mask def forward(self, video, audio, sensor, mask_ratio=0.0): v_tokens = self.video_embed(video) # (B, 197, D) a_tokens = self.audio_embed(audio) # (B, 64, D) s_tokens = self.sensor_embed(sensor) # (B, S, D) self.v_mask = self.a_mask = None if mask_ratio > 0 and self.training: v_tokens, self.v_mask = self._mask_tokens(v_tokens, mask_ratio) a_tokens, self.a_mask = self._mask_tokens(a_tokens, mask_ratio) x = torch.cat([v_tokens, a_tokens, s_tokens], dim=1) x = self.encoder(x) x = self.norm(x) n_v = v_tokens.size(1) v_out = self.head_video(x[:, :n_v]) # 重建视频patch像素值 a_out = self.head_audio(x[:, n_v:n_v + a_tokens.size(1)]) return v_out, a_out

这里的关键是融合方式:三个模态token拼成长序列后走同一个编码器,等价于让视频token和音频token在每一层都做隐式交叉注意力。好处是不用手动指定模态间的对应关系,坏处是序列变长、显存压力增大。如果要做显式交叉注意力,把编码器拆成"模态内编码器 + 跨模态编码器"两段也可以,效果差异不大。depth=6、num_heads=12、embed_dim=768是较大配置,显存紧张时先降到embed_dim=384、depth=4。

4.3 训练循环与异常分数阈值:从重建误差到判定

训练目标只对被mask的token算重建误差,强迫模型用跨模态上下文补全被遮内容。mask比例不是越大越好:15%是常用起点,mask太多模型学不到有效上下文,mask太少重建任务太容易、学不到异常敏感的表征。

def train_one_epoch(model, loader, optimizer, criterion, mask_ratio=0.15): model.train() total_loss = 0 for batch in loader: video = batch["video"] # (B, 3, 224, 224) audio = batch["audio"] # (B, 1, 128, 128) sensor = batch["sensor"] # (B, S, 1) optimizer.zero_grad() v_out, a_out = model(video, audio, sensor, mask_ratio=mask_ratio) # 只对被mask的位置计算重建损失 v_loss = criterion(v_out[model.v_mask], video_patchify(video)[model.v_mask]) a_loss = criterion(a_out[model.a_mask], audio_patchify(audio)[model.a_mask]) loss = v_loss + 0.5 * a_loss loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)

video_patchify 是把原图按16×16切patch并展平,输出形状对齐 v_out。音频loss乘0.5是压低弱模态的loss量级,防止视频主导梯度——这个系数按初始化后两个模态的loss比值来调,不要固定写死。优化器用AdamW,学习率3e-4,warmup 5个epoch,总训练40到60个epoch,weight decay 0.05。如果loss曲线下降很慢,先检查是否数据对齐问题,再动学习率。

推理时的异常分数和阈值:

def anomaly_score(model, batch): # 推理时mask_ratio=0,重建误差就是异常分数 v_out, a_out = model(batch["video"], batch["audio"], batch["sensor"]) v_err = ((v_out - video_patchify(batch["video"])) ** 2).mean() a_err = ((a_out - audio_patchify(batch["audio"])) ** 2).mean() return v_err + 0.5 * a_err # 阈值:在验证集正常样本上取99分位数 scores = torch.tensor([anomaly_score(model, b) for b in val_loader]) threshold = torch.quantile(scores, 0.99)

阈值不要用固定值,重建误差的量级受光照、背景复杂度影响很大,同一套模型在不同场景下误差可能差一个数量级。取正常样本的99分位数,含义是允许1%的正常样本被误报。线上部署时用滑动窗口持续更新这个分位数,避免数据漂移导致阈值慢慢失效。

5. 避坑指南:多模态异常检测的5个常见翻车点

5.1 模态不平衡:强模态拖垮弱模态

现象:训练时loss一直在降,但单独看音频分支的重建误差几乎不降,传感器通道的loss占比越来越小,最后模型退化成一个"视频检测器"。

原因:视频patch数量(196个token)远超音频(64个)和传感器token数,MSE损失量级也被视频主导。优化器更新梯度时主要照顾视频分支,弱模态的梯度被淹没,等于没学。

解决:初始化后分别统计三个模态分支在初始模型上的loss量级,把加权系数设成让三者初始贡献大致相等。更稳的做法是每个模态分支用独立的head和一个小的LayerNorm,隔离跨模态梯度尺度差异;必要时给弱模态分支加更高dropout防止过拟合。

5.2 时间戳错位:数据看起来没问题,指标一直上不去

现象:训练过程一切正常,验证集AUC在0.6附近徘徊,怎么调模型都上不去。仔细检查数据,发现视频第40帧画面里人已经倒了,音频里撞击声却对应第38帧附近。

原因:数据集制作时各设备独立启动,起始时间不一致;或者视频标称30fps实际是29.97fps,半小时的数据累积偏移超过2秒。多模态场景里这个偏移量足以破坏所有对齐关系,模型学到的跨模态对应全是错位的。

解决:预处理阶段用ffprobe读取真实帧率,不要盲信meta.json里的标注;音频与视频的相对偏移用互相关或人工抽样对比校准;传感器数据用线性插值到视频帧时刻。对齐之后画一张三路信号同步图,肉眼确认几十帧再进训练管线。这个检查习惯帮我排掉了不止一次数据坑。

5.3 阈值设不好:重建误差分布不均匀

现象:测试集上正常样本的分数方差很大,用均值加3倍标准差当阈值,漏报一堆;用99分位数当阈值,误报率高得没法看。

原因:重建误差在"正常但少见"的场景(比如偶尔飘过落叶、光线突变)上本来就高,一个全局标量阈值无法适配不同时段、不同背景的分数分布差异。

解决:不要用全局标量阈值,按时间段或场景分桶统计分数分布,每个桶用自己的阈值;或者改走无阈值路线,训练一个one-class分类头,把重建误差作为附加特征一并输入。分桶阈值简单直接,one-class头更鲁棒,但需要额外的验证集标注,按项目标注成本取舍。

5.4 显存爆炸:序列长度与batch size没匹配好

现象:把三路数据拼起来之后OOM,batch size调到1还是爆。

原因:视频196个token加音频64个token加传感器128个token,总序列长度接近400。自注意力复杂度是O(n²),12头乘以6层算下来中间激活非常占显存,多模态拼接把这个问题放大了。

解决:优先降序列长度而不是降batch size——视频patch从16x16改成32x32,token数从196降到49;传感器序列按50个采样点一段而不是128;再用gradient checkpointing(torch.utils.checkpoint)省激活显存;batch size设1配合梯度累积。经验法则是:多模态token交互质量比梯度稳定性更敏感,所以保序列长度、牺牲batch size。

5.5 数据泄露:同一场景的帧被拆进训练和测试

现象:训练loss很低,验证AUC也很高,但一到真实场景效果崩掉,换个同类型数据集AUC直接腰斩。

原因:划分数据集时按"帧"随机切分,同一段视频的帧同时出现在训练和测试集。Transformer的注意力记忆能力很强,见过同场景的相近帧后重建误差自然低,测试分数虚高。多模态下更隐蔽:同一个场景的音频和视频被拆到两端,时间相邻的片段高度相似,模型等于"变相作弊"。

解决:划分必须按"场景/视频片段"为最小单位,同一场景所有模态、所有帧只能进入训练集或测试集。自查方法:训练集和测试集各抽几帧,人工看背景是否重复。如果数据集已经按帧切好,需要用场景ID重新聚合后再划分。

排查顺序建议:遇到指标不对,先查数据对齐,再查划分泄漏,然后查模态均衡,最后才动模型结构和超参数。按这个顺序能省下大量"调参调了三天结果发现是数据问题"的时间。

6. 进阶玩法:注意力定位异常区域与少量样本微调

训练完模型之后,最常见的需求从"这一段时间异常"升级成"异常来自哪里"。做法是复用编码器最后一层的注意力权重,把关注度最高的patch映射回原图位置。

def build_attention_heatmap(model, batch): # 前向时保存最后一层的注意力权重,形状 (B, num_heads, L, L) attn = model.encoder.layers[-1].self_attn # 拿到模块后手动注册forward hook取权重 # 这里示意:attn_map 形状 (B, num_heads, L, L) n_v = 197 attn_video = attn_map[:, :, :n_v, :n_v].mean(dim=1) # (B, 197, 197) heatmap = attn_video[0, 1:, 1:].mean(dim=0).view(14, 14) # 去掉cls,按patch排列 return heatmap

注意力权重反映了模型在做重建时依赖了哪些patch的信息。异常patch通常是信息最不一致的区域,注意力会明显集中。把14×14的热力图放大到原图尺寸叠加,能直接看到异常定位——比如传送带上多出来的异物、画面角落的火光。这种可解释能力是单模态CNN模型很难提供的,也是Transformer方案在安防、质检场景里能落地的重要原因。

另一个实用操作是few-shot微调。手里只有二三十条标注异常样本时,不要从头训练,在训练好的重建模型上把最后一层换成线性分类头,用二元交叉熵微调2到3个epoch。只更新分类头和最后一层编码器,前面的重建参数全部冻结,能有效抑制小样本过拟合。我做过一组对比:不微调直接用这些小样本训分类器,AUC只有0.72;冻结重建参数微调后AUC到0.88,提升稳定。

最后说一个贯穿始终的习惯:任何多模态异常检测项目,我都先跑一个单模态基线,比如只用视频走同样的重建流程,拿这个AUC当底线,再逐个加入音频、传感器,验证每一路到底贡献了多少提升。如果加了一路模态AUC不涨,问题大概率在数据对齐或融合层,而不是"模态不够多"。多模态不是越多越好,每一路都要能证明自己的价值,这套方案才值得投入。希望这篇笔记能帮你把Transformer多模态异常检测从"看到别人有效果"推进到"自己也能跑出来并说清为什么有效"。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询