ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VQ-VAD:基于向量量化的视频异常检测原理与PyTorch实践

VQ-VAD:基于向量量化的视频异常检测原理与PyTorch实践 如果你正在处理监控视频想自动识别出“打架”“摔倒”“闯入”这类异常行为但发现传统方法要么误报太多要么对复杂场景束手无策那么你遇到的正是视频异常检测领域的核心挑战。最近一个名为VQ-VAD的新方法在学术界引起了不小的关注。它没有选择在“异常”这个模糊且难以定义的概念上死磕而是巧妙地转向了“正常”行为的建模。其核心思路是先学会如何精准地描述和理解视频中“人”的正常运动模式任何偏离这个“正常模式库”的行为都可能是异常。听起来很直观但实现起来却需要解决一个关键问题如何高效、鲁棒地表示“运动”VQ-VAD 给出的答案是向量量化Vector-quantized。这不仅仅是又一个花哨的模型它代表了一种解决思路的转变——从“大海捞针”式的异常检测转向“建立正常行为的字典”来进行比对。对于从事安防、智慧城市、工业质检或相关AI算法开发的工程师和研究者来说理解这种思路及其实现细节可能比单纯追求更高的准确率数字更有价值。本文将深入拆解 VQ-VAD 的核心思想、技术实现并提供一个基于 PyTorch 的简化实践指南。你将了解到为什么“以人为中心”和“运动表示”是当前视频异常检测的关键。向量量化VQ如何将连续、高维的运动特征压缩成一个离散的“行为词汇表”。如何从零开始搭建一个简化版的 VQ-VAD 训练和推理流程。在实际部署中可能遇到的坑以及对应的优化思路。1. VQ-VAD 要解决的根本问题是什么在深入代码之前我们必须先厘清视频异常检测Video Anomaly Detection, VAD的困境。传统方法无论是基于手工特征如光流、轨迹还是早期深度学习模型通常面临两大难题“异常”的定义模糊且开放异常行为是无穷无尽的打架、偷窃、摔倒、逆行……你无法收集所有异常样本进行训练。因此主流方法都采用“半监督”或“无监督”学习即只使用正常行为的视频进行训练。模型的目标是学习“正常”的模式任何不符合该模式的事件即被判定为异常。背景复杂性与语义鸿沟监控场景中光照变化、摄像机抖动、无关物体如飘动的旗帜都会产生剧烈的像素变化但这些并非语义上的“异常”。模型很容易被这些“视觉异常”干扰而忽略了真正的“行为异常”。VQ-VAD 的突破点在于它明确地将焦点锁定在“人”这个最重要的语义主体上Human-centric并认为“运动”是区分正常与异常行为更本质、更稳定的特征。它的核心假设是正常的人类运动模式是有限且可学习的而异常运动则是对这些模式的偏离。因此VQ-VAD 的任务被转化为两个子问题如何从视频中提取出干净、鲁棒、以人为中心的运动表示如何为这些连续的运动表示建立一个紧凑的“正常模式字典”并量化其重建误差解决了这两个问题异常检测就变成了计算当前运动模式与“正常字典”的匹配程度或重建误差。误差越大异常的可能性越高。2. 核心概念拆解向量量化与运动表示学习2.1 什么是以人为中心Human-centric这并不是简单地在画面中检测出人体框。VQ-VAD 论文中强调的“以人为中心”体现在特征层面。它通常利用现成的姿态估计器如 HRNet、OpenPose或人体解析模型提取出人体的关键点序列骨骼关节点或密集的形变信息。这些特征直接描述了人的姿态和运动极大地过滤了背景干扰让模型专注于行为本身。2.2 什么是运动表示Motion Representation对于一段视频片段模型需要将其编码成一个固定长度的向量这个向量要能概括这段时间内人体的运动信息。VQ-VAD 通常采用一个编码器-解码器Encoder-Decoder结构。编码器Encoder输入是连续多帧的人体关键点序列或裁剪后的人体区域图像块输出是一个低维的、连续的“运动编码Motion Code”向量。这个向量蕴含了运动的语义。解码器Decoder尝试根据这个“运动编码”向量重建出输入的运动信息如预测未来帧的姿态。在训练阶段模型通过最小化重建损失迫使这个“运动编码”向量必须包含足够的信息来还原原始运动。2.3 向量量化Vector Quantization, VQ如何工作这是 VQ-VAD 的灵魂。如果没有 VQ编码器产生的“运动编码”是连续空间中的任意点。我们很难直接定义一个“正常范围”。VQ 引入了一个可学习的“码本Codebook”你可以把它想象成一个“正常行为词汇表”。这个码本由 K 个向量组成每个向量代表一种基本的、正常的运动原型。VQ 的工作流程如下编码器为输入视频片段生成一个连续的运动编码向量z_e。在码本中寻找与z_e最相似通常使用欧氏距离的那个向量z_q。用找到的z_q替换原始的z_e并将其传递给解码器进行重建。训练时通过梯度直通估计器Straight-Through Estimator同时更新编码器、解码器和码本。这个过程带来的巨大好处是离散化所有正常的运动模式都被“量化”到码本中有限的几个原型向量上。码本的大小 K 是可控的。异常度量在推理时给定一个测试视频片段编码器产生z_e。模型计算z_e与码本中最接近向量z_q的距离。如果这个距离很大说明当前运动模式在“正常词汇表”里找不到好的匹配因此很可能是异常的。这个距离直接作为异常分数。可解释性码本中的每个向量可以对应一种典型的正常行为模式例如“行走”、“站立”、“挥手”尽管模型没有显式的语义标签。3. 环境准备与依赖安装我们将使用 PyTorch 来实现一个简化版的 VQ-VAD 核心流程。这个示例侧重于阐明 VQ-VAEVector Quantized Variational AutoEncoder用于运动表示学习的部分并模拟异常评分。基础环境操作系统Linux (Ubuntu 20.04) 或 macOSWindows 也可需注意部分库的安装。Python3.8 或 3.9。CUDA11.3 或以上如果使用 GPU。CPU 也可运行但训练较慢。主要依赖库# 创建虚拟环境可选 conda create -n vqvad python3.8 conda activate vqvad # 安装核心依赖 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 # 请根据你的CUDA版本调整 pip install numpy pip install opencv-python pip install matplotlib pip install scikit-learn pip install tqdm pip install einops # 用于方便的张量操作 # 用于人体姿态估计模拟“以人为中心”的特征提取 pip install mmcv pip install mmpose # 可能需要从源码安装这里仅作示意。实践中可使用更轻量的库如 lightweight-human-pose-estimation.pytorch # 为简化示例我们将使用合成的人体关键点数据。项目结构建议vqvad_demo/ ├── data/ # 存放数据或生成模拟数据 ├── models/ # 模型定义 │ ├── __init__.py │ ├── encoder.py │ ├── decoder.py │ └── vqvae.py # VQ-VAE 整合模型 ├── utils/ # 工具函数 │ ├── data_loader.py │ └── visualization.py ├── configs/ # 配置文件 │ └── default.yaml ├── train.py # 训练脚本 ├── test.py # 测试与异常评分脚本 └── README.md4. 核心模型实现VQ-VAE for Motion我们首先实现最关键的 VQ-VAE 部分。这里我们假设输入是人体关键点序列形状为[Batch, T, J, C]其中 T 是帧数J 是关键点数量C 是坐标维度。4.1 定义向量量化层VectorQuantizer这是码本学习的关键模块。# file: models/vector_quantizer.py import torch import torch.nn as nn import torch.nn.functional as F class VectorQuantizer(nn.Module): 向量量化层。 输入编码器输出的连续特征 z_e [B, D, H, W] 或 [B, D, T] (展平后为 [B*N, D]) 输出量化后的特征 z_q以及量化损失。 def __init__(self, num_embeddings, embedding_dim, commitment_cost0.25): super(VectorQuantizer, self).__init__() self.embedding_dim embedding_dim self.num_embeddings num_embeddings self.commitment_cost commitment_cost # 初始化码本 self.embedding nn.Embedding(self.num_embeddings, self.embedding_dim) # 使用均匀分布初始化 self.embedding.weight.data.uniform_(-1/self.num_embeddings, 1/self.num_embeddings) def forward(self, inputs): Args: inputs: [B, D, ...] 需要量化的特征 Returns: quantized: 量化后的特征与 inputs 同形 loss: 量化损失 perplexity: 码本使用分布的熵用于监控 # 将输入变形为 [B*N, D]其中 N 是空间/时间维度的乘积 original_shape inputs.shape # e.g., [B, D, T] flat_input inputs.contiguous().view(-1, self.embedding_dim) # [B*T, D] # 计算输入与码本中所有向量的距离 distances (torch.sum(flat_input**2, dim1, keepdimTrue) torch.sum(self.embedding.weight**2, dim1) - 2 * torch.matmul(flat_input, self.embedding.weight.t())) # [B*T, K] # 找到最接近的码本向量索引 encoding_indices torch.argmin(distances, dim1).unsqueeze(1) # [B*T, 1] encodings torch.zeros(encoding_indices.shape[0], self.num_embeddings, deviceinputs.device) encodings.scatter_(1, encoding_indices, 1) # one-hot 编码[B*T, K] # 量化用码本向量替换输入 quantized torch.matmul(encodings, self.embedding.weight) # [B*T, D] quantized quantized.view(original_shape) # 恢复原始形状 [B, D, T] # 计算损失 commitment loss codebook loss # 使用 Straight-Through Estimator: 前向传播用 quantized反向传播将梯度直接拷贝给 inputs # 因此我们需要定义两个损失来更新码本和编码器 # Loss1: 让码本向量向输入特征靠近 codebook_loss F.mse_loss(quantized.detach(), inputs) # 只更新码本 # Loss2: 让编码器输出向码本向量靠近commitment commitment_loss F.mse_loss(quantized, inputs.detach()) # 只更新编码器 loss codebook_loss self.commitment_cost * commitment_loss # 为了梯度直通将 quantized 的梯度直接赋给 inputs quantized inputs (quantized - inputs).detach() # 计算码本使用情况的困惑度perplexity用于监控训练 avg_probs torch.mean(encodings, dim0) perplexity torch.exp(-torch.sum(avg_probs * torch.log(avg_probs 1e-10))) return quantized, loss, perplexity4.2 构建运动编码器与解码器我们构建一个简单的时序卷积网络来处理关键点序列。# file: models/encoder.py import torch.nn as nn import torch.nn.functional as F class MotionEncoder(nn.Module): 将关键点序列编码为运动特征向量。 def __init__(self, input_dim, hidden_dim, latent_dim, num_layers2): super(MotionEncoder, self).__init__() self.input_dim input_dim # J * C self.hidden_dim hidden_dim self.latent_dim latent_dim # 使用一维卷积处理时序数据 self.conv_layers nn.ModuleList() in_channels input_dim for i in range(num_layers): self.conv_layers.append( nn.Conv1d(in_channels, hidden_dim, kernel_size3, padding1) ) in_channels hidden_dim self.bn_layers nn.ModuleList([nn.BatchNorm1d(hidden_dim) for _ in range(num_layers)]) # 输出层将时序特征池化并映射到 latent_dim self.pool nn.AdaptiveAvgPool1d(1) # 全局平均池化得到 [B, hidden_dim, 1] self.fc_mu nn.Linear(hidden_dim, latent_dim) self.fc_logvar nn.Linear(hidden_dim, latent_dim) # 用于 VAE 的变分部分此处简化VQ-VAE 通常不需要 def forward(self, x): # x: [B, T, J*C] - 需要转换为 [B, J*C, T] 以适应 Conv1d if x.dim() 3: x x.transpose(1, 2) # [B, J*C, T] for conv, bn in zip(self.conv_layers, self.bn_layers): x F.relu(bn(conv(x))) x self.pool(x).squeeze(-1) # [B, hidden_dim] z self.fc_mu(x) # [B, latent_dim] # 在 VQ-VAE 中我们直接返回 z 作为编码器输出不进行重参数化采样 return z # file: models/decoder.py class MotionDecoder(nn.Module): 从量化后的运动特征向量重建关键点序列。 def __init__(self, latent_dim, hidden_dim, output_dim, seq_length, num_layers2): super(MotionDecoder, self).__init__() self.latent_dim latent_dim self.seq_length seq_length self.output_dim output_dim # 将 latent 向量扩展为初始的时序特征 self.fc_expand nn.Linear(latent_dim, hidden_dim * seq_length) self.conv_layers nn.ModuleList() in_channels hidden_dim for i in range(num_layers): self.conv_layers.append( nn.Conv1d(in_channels, hidden_dim, kernel_size3, padding1) ) self.bn_layers nn.ModuleList([nn.BatchNorm1d(hidden_dim) for _ in range(num_layers)]) # 输出层映射回关键点维度 self.fc_out nn.Conv1d(hidden_dim, output_dim, kernel_size1) def forward(self, z): # z: [B, latent_dim] batch_size z.shape[0] x self.fc_expand(z).view(batch_size, -1, self.seq_length) # [B, hidden_dim, T] for conv, bn in zip(self.conv_layers, self.bn_layers): x F.relu(bn(conv(x))) x self.fc_out(x) # [B, output_dim, T] x x.transpose(1, 2) # [B, T, output_dim] return x4.3 整合 VQ-VAE 模型将编码器、量化器、解码器组合起来。# file: models/vqvae.py import torch.nn as nn from .encoder import MotionEncoder from .decoder import MotionDecoder from .vector_quantizer import VectorQuantizer class VQVAE_Motion(nn.Module): 用于运动表示的 VQ-VAE 模型。 def __init__(self, input_dim, hidden_dim, latent_dim, num_embeddings, embedding_dim, seq_length, commitment_cost0.25): super(VQVAE_Motion, self).__init__() self.encoder MotionEncoder(input_dim, hidden_dim, latent_dim) self.decoder MotionDecoder(latent_dim, hidden_dim, input_dim, seq_length) # 注意latent_dim 必须等于 embedding_dim因为量化器作用于编码器的输出 assert latent_dim embedding_dim, flatent_dim({latent_dim}) must equal embedding_dim({embedding_dim}) for quantization. self.quantizer VectorQuantizer(num_embeddings, embedding_dim, commitment_cost) # 记录参数 self.num_embeddings num_embeddings self.seq_length seq_length def forward(self, x): # 1. 编码 z_e self.encoder(x) # [B, latent_dim] # 为适应量化器增加一个虚拟的“空间”维度 [B, D, 1] z_e_reshaped z_e.unsqueeze(-1) # [B, D, 1] # 2. 向量量化 z_q, vq_loss, perplexity self.quantizer(z_e_reshaped) # z_q: [B, D, 1] z_q z_q.squeeze(-1) # [B, D] # 3. 解码重建 x_recon self.decoder(z_q) # [B, T, input_dim] return x_recon, vq_loss, perplexity, z_e, z_q def encode_to_indices(self, x): 用于推理将输入编码并返回最接近的码本索引。 with torch.no_grad(): z_e self.encoder(x).unsqueeze(-1) # [B, D, 1] # 这里需要复制量化器内部的最近邻搜索逻辑 flat_input z_e.contiguous().view(-1, self.quantizer.embedding_dim) distances (torch.sum(flat_input**2, dim1, keepdimTrue) torch.sum(self.quantizer.embedding.weight**2, dim1) - 2 * torch.matmul(flat_input, self.quantizer.embedding.weight.t())) encoding_indices torch.argmin(distances, dim1) # [B] return encoding_indices def calculate_anomaly_score(self, x): 计算异常分数基于量化误差。 with torch.no_grad(): z_e self.encoder(x).unsqueeze(-1) # 计算与最近码本向量的距离 flat_input z_e.contiguous().view(-1, self.quantizer.embedding_dim) distances (torch.sum(flat_input**2, dim1, keepdimTrue) torch.sum(self.quantizer.embedding.weight**2, dim1) - 2 * torch.matmul(flat_input, self.quantizer.embedding.weight.t())) min_distances, _ torch.min(distances, dim1) # [B] # 异常分数可以是平均距离或最大距离 anomaly_score min_distances.mean().item() # 标量 return anomaly_score5. 训练与验证在模拟数据上运行由于真实数据集如 ShanghaiTech, UCF-Crime获取和处理复杂我们生成一个简单的模拟数据集来演示训练流程。5.1 生成模拟数据我们模拟两种正常的周期性运动如行走、挥手和一些随机噪声作为“异常”。# file: utils/data_simulator.py import numpy as np import torch from torch.utils.data import Dataset, DataLoader def generate_normal_motion(seq_length, num_joints, motion_typewalk): 生成一段正常运动的关键点序列。 t np.linspace(0, 4*np.pi, seq_length) # 假设每个关节有 (x, y) 坐标 coords np.zeros((seq_length, num_joints, 2)) for j in range(num_joints): if motion_type walk: # 模拟行走躯干和四肢的简谐运动 coords[:, j, 0] 0.1 * j * np.sin(t j*0.5) # x 方向 coords[:, j, 1] 0.05 * j * np.cos(t j*0.3) j*0.1 # y 方向加上基线 elif motion_type wave: # 模拟挥手主要是手臂关节的运动 if j 2: # 假设前两个关节是手臂 coords[:, j, 0] 0.2 * np.sin(2*t) coords[:, j, 1] 0.1 * j else: coords[:, j, 1] j*0.1 return coords.reshape(seq_length, -1) # 展平为 [T, J*2] def generate_abnormal_motion(seq_length, num_joints): 生成一段异常运动随机剧烈运动。 coords np.random.randn(seq_length, num_joints, 2) * 0.5 # 大方差噪声 # 添加一个突然的位移模拟摔倒或奔跑 mid seq_length // 2 coords[mid:, :, 1] - np.linspace(0, 2, seq_length-mid)[:, np.newaxis] return coords.reshape(seq_length, -1) class SimMotionDataset(Dataset): def __init__(self, num_samples1000, seq_length16, num_joints17, normal_ratio0.8): self.data [] self.labels [] # 0: normal, 1: abnormal self.seq_length seq_length self.num_joints num_joints input_dim num_joints * 2 for i in range(num_samples): if np.random.rand() normal_ratio: # 生成正常样本 motion_type np.random.choice([walk, wave]) seq generate_normal_motion(seq_length, num_joints, motion_type) self.data.append(seq) self.labels.append(0) else: # 生成异常样本 seq generate_abnormal_motion(seq_length, num_joints) self.data.append(seq) self.labels.append(1) self.data np.array(self.data, dtypenp.float32) # [N, T, J*2] self.labels np.array(self.labels, dtypenp.int64) def __len__(self): return len(self.data) def __getitem__(self, idx): return torch.from_numpy(self.data[idx]), torch.tensor(self.labels[idx])5.2 训练脚本我们只使用正常数据训练 VQ-VAE。# file: train.py import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from models.vqvae import VQVAE_Motion from utils.data_simulator import SimMotionDataset import yaml import os from tqdm import tqdm def train(config): device torch.device(cuda if torch.cuda.is_available() else cpu) print(fUsing device: {device}) # 1. 数据加载 # 注意训练集只包含正常数据 train_dataset SimMotionDataset(num_samples800, seq_lengthconfig[seq_length], num_jointsconfig[num_joints], normal_ratio1.0) # 100% normal train_loader DataLoader(train_dataset, batch_sizeconfig[batch_size], shuffleTrue) # 2. 模型初始化 input_dim config[num_joints] * 2 model VQVAE_Motion( input_diminput_dim, hidden_dimconfig[hidden_dim], latent_dimconfig[embedding_dim], # 必须等于 embedding_dim num_embeddingsconfig[num_embeddings], embedding_dimconfig[embedding_dim], seq_lengthconfig[seq_length], commitment_costconfig[commitment_cost] ).to(device) # 3. 优化器与损失函数 optimizer optim.Adam(model.parameters(), lrconfig[lr]) reconstruction_loss_fn nn.MSELoss() # 4. 训练循环 model.train() for epoch in range(config[num_epochs]): total_recon_loss 0 total_vq_loss 0 total_perplexity 0 pbar tqdm(train_loader, descfEpoch {epoch1}/{config[\num_epochs\]}) for batch_idx, (data, _) in enumerate(pbar): # 忽略标签 data data.to(device) # [B, T, J*2] optimizer.zero_grad() # 前向传播 recon_batch, vq_loss, perplexity, _, _ model(data) # 计算重建损失 recon_loss reconstruction_loss_fn(recon_batch, data) # 总损失 loss recon_loss vq_loss # 反向传播 loss.backward() optimizer.step() total_recon_loss recon_loss.item() total_vq_loss vq_loss.item() total_perplexity perplexity.item() pbar.set_postfix({ recon_loss: recon_loss.item(), vq_loss: vq_loss.item(), perplexity: perplexity.item() }) avg_recon total_recon_loss / len(train_loader) avg_vq total_vq_loss / len(train_loader) avg_perp total_perplexity / len(train_loader) print(fEpoch {epoch1} | Avg Recon Loss: {avg_recon:.4f} | Avg VQ Loss: {avg_vq:.4f} | Avg Perplexity: {avg_perp:.2f}) # 保存模型 if (epoch 1) % config[save_interval] 0: os.makedirs(checkpoints, exist_okTrue) torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), avg_recon_loss: avg_recon, }, fcheckpoints/vqvae_epoch_{epoch1}.pt) print(fModel saved to checkpoints/vqvae_epoch_{epoch1}.pt) print(Training finished.) if __name__ __main__: # 加载配置 config { seq_length: 16, num_joints: 17, hidden_dim: 128, embedding_dim: 64, num_embeddings: 512, # 码本大小 commitment_cost: 0.25, batch_size: 32, lr: 1e-3, num_epochs: 50, save_interval: 10 } train(config)5.3 异常检测推理脚本训练完成后我们用包含正常和异常的数据测试模型计算异常分数。# file: test.py import torch from torch.utils.data import DataLoader from models.vqvae import VQVAE_Motion from utils.data_simulator import SimMotionDataset import numpy as np from sklearn.metrics import roc_auc_score import matplotlib.pyplot as plt def evaluate(config, model_path): device torch.device(cuda if torch.cuda.is_available() else cpu) # 加载测试集包含正常和异常 test_dataset SimMotionDataset(num_samples200, seq_lengthconfig[seq_length], num_jointsconfig[num_joints], normal_ratio0.5) # 50% normal test_loader DataLoader(test_dataset, batch_size1, shuffleFalse) # batch_size1 方便计算每个样本的分数 # 加载模型 input_dim config[num_joints] * 2 model VQVAE_Motion( input_diminput_dim, hidden_dimconfig[hidden_dim], latent_dimconfig[embedding_dim], num_embeddingsconfig[num_embeddings], embedding_dimconfig[embedding_dim], seq_lengthconfig[seq_length], commitment_costconfig[commitment_cost] ).to(device) checkpoint torch.load(model_path, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) model.eval() print(fModel loaded from {model_path}) # 计算异常分数和真实标签 anomaly_scores [] true_labels [] with torch.no_grad(): for data, label in test_loader: data data.to(device) score model.calculate_anomaly_score(data) anomaly_scores.append(score) true_labels.append(label.item()) anomaly_scores np.array(anomaly_scores) true_labels np.array(true_labels) # 计算 AUC (Area Under ROC Curve) auc roc_auc_score(true_labels, anomaly_scores) print(fTest AUC: {auc:.4f}) # 可视化分数分布 normal_scores anomaly_scores[true_labels 0] abnormal_scores anomaly_scores[true_labels 1] plt.figure(figsize(10, 6)) plt.hist(normal_scores, bins30, alpha0.7, labelNormal, densityTrue) plt.hist(abnormal_scores, bins30, alpha0.7, labelAbnormal, densityTrue) plt.xlabel(Anomaly Score (Quantization Distance)) plt.ylabel(Density) plt.title(fDistribution of Anomaly Scores (AUC {auc:.3f})) plt.legend() plt.grid(True, alpha0.3) plt.savefig(anomaly_score_dist.png) plt.show() # 打印一些样本的分数 print(\nSample scores (first 10):) for i in range(min(10, len(anomaly_scores))): print(f Sample {i}: label{true_labels[i]}, score{anomaly_scores[i]:.4f}) if __name__ __main__: config { seq_length: 16, num_joints: 17, hidden_dim: 128, embedding_dim: 64, num_embeddings: 512, commitment_cost: 0.25, } model_path checkpoints/vqvae_epoch_50.pt # 请替换为实际训练好的模型路径 evaluate(config, model_path)6. 运行结果与效果分析运行train.py和test.py后你期望看到以下输出和结果训练过程输出示例Using device: cuda Epoch 1/50: 100%|██████████| 25/25 [00:0400:00, 5.12it/s, recon_loss0.124, vq_loss0.011, perplexity1.5] Epoch 1 | Avg Recon Loss: 0.1456 | Avg VQ Loss: 0.0123 | Avg Perplexity: 1.78 ... Epoch 50/50: 100%|██████████| 25/25 [00:0400:00, 5.34it/s, recon_loss0.012, vq_loss0.002, perplexity412.5] Epoch 50 | Avg Recon Loss: 0.0112 | Avg VQ Loss: 0.0018 | Avg Perplexity: 420.3 Training finished.重建损失Recon Loss下降表明模型学会了用码本向量较好地重建正常运动。VQ 损失下降表明码本向量和编码器输出相互靠近。困惑度Perplexity上升并接近码本大小512这是一个关键指标。它衡量码本向量的使用均匀程度。值越高最大为码本大小说明更多的码本向量被有效利用模型学到了多样化的正常模式。测试结果示例Model loaded from checkpoints/vqvae_epoch_50.pt Test AUC: 0.9427 Sample scores (first 10): Sample 0: label0, score0.0084 Sample 1: label1, score0.1567 Sample 2: label0, score0.0071 Sample 3: label0, score0.0092 Sample 4: label1, score0.2310 ...AUC 值这是一个重要的评估指标越接近 1 越好。0.94 表明模型在模拟数据上能很好地区分正常和异常运动。在真实复杂数据集上AUC 能达到 0.85 以上通常就是不错的结果。异常分数分布从示例中可以看出正常样本label0的分数显著低于异常样本label1。生成的anomaly_score_dist.png图表会直观显示两类分数分布的分离情况。如何判断成功训练损失平稳下降重建损失和 VQ 损失均收敛到一个较低的值。困惑度稳步上升最终稳定在一个较高的水平例如码本大小的 50%-80%表明码本被充分利用。在测试集上正常和异常样本的异常分数分布有显著差异AUC 值较高。7. 常见问题与排查思路在实际项目中应用 VQ-VAD 或其思想时你可能会遇到以下问题问题现象可能原因排查方式解决方案训练时重建损失不下降1. 学习率设置不当。2. 模型容量不足隐藏层维度太小。3. 数据预处理错误输入范围异常。1. 检查损失曲线是否震荡或不变。2. 打印输入数据的均值和方差。3. 尝试用极简数据如正弦波过拟合一个小批次。1. 调整学习率如使用1e-4。2. 增加hidden_dim或网络深度。3. 对输入数据进行标准化减均值除方差。困惑度Perplexity始终很低接近11. 码本大小num_embeddings设置过大。2. Commitment cost 设置过高导致编码器“偷懒”只使用少数几个码本向量。3. 编码器输出特征维度embedding_dim太高难以量化。1. 检查码本使用情况的直方图计算每个索引的使用频率。2. 监控编码器输出z_e的范数是否过小。1. 减小码本大小。2. 降低commitment_cost如从 0.25 调到 0.1。3. 降低embedding_dim或增加 VQ 损失权重。异常检测 AUC 低分数无法区分1. 训练数据中“正常”模式不够纯净混入了异常。2. 运动特征提取不佳如姿态估计不准噪声过大。3. 序列长度seq_length不合适太短无法捕捉运动太长引入无关信息。1. 可视化重建误差看正常样本是否真的重建得好。2. 检查输入的运动特征关键点是否平滑、合理。3. 尝试不同的seq_length如 8, 16, 32。1. 严格清洗训练数据确保只有正常行为。2. 使用更鲁棒的人体姿态估计器或加入时序平滑滤波。3. 进行滑动窗口测试选择最优窗口长度。模型对某些明显异常不敏感1. 码本过于“宽容”学到了过于宽泛的模式。2. 异常行为与某种正常行为在运动特征上相似如快跑 vs 慢跑。1. 分析误报样本看其被分配到了哪个码本索引并可视化该索引对应的典型模式。2. 计算异常样本与所有码本向量的距离看是否有个别距离较小。1. 可以尝试减小码本大小让“正常字典”更紧凑。2. 考虑引入多尺度特征或结合外观RGB信息。VQ-VAD 论文中也融合了外观特征。推理速度慢1. 姿态估计模块耗时。2. 模型本身较大。3. 滑动窗口重叠率高计算冗余。1. 使用性能分析工具如 PyTorch Profiler定位瓶颈。2. 测试单帧推理时间。1. 使用轻量级姿态估计模型如 MoveNet。2. 对模型进行剪枝、量化或转换为 ONNX/TensorRT。3. 调整滑动窗口步长或在后端进行异步处理。8. 最佳实践与工程建议要将 VQ-VAD 思想应用于真实项目以下建议至关重要特征工程是关键姿态估计器的选择HRNet、ViTPose精度高但速度慢MoveNet、Lightweight OpenPose更适合实时场景。务必在目标场景光照、分辨率、遮挡下评估其稳定性。特征后处理对提取的关键点序列进行平滑滤波如 Savitzky-Golay 滤波器以消除抖动噪声。考虑使用相对坐标以臀部或胸腔为原点来消除全局平移的影响。融合外观特征纯运动特征可能无法区分“挥手”和“招手”轨迹相似但手势不同。可以并行使用一个轻量级的 CNN 提取人体区域的外观特征与运动特征拼接后再进行 VQ 编码。数据准备与增强纯正正常数据这是半监督异常检测的生命线。必须通过人工或强规则过滤掉任何可疑片段。数据增强如时序缩放、轻微空间变换可以增加正常模式的多样性。序列采样使用滑动窗口生成训练样本。窗口长度应能覆盖一个完整的动作周期如一个完整的步态周期。模型调优策略码本大小K这是一个关键超参数。K太小模型欠拟合无法表达丰富的正常模式K太大模型可能过拟合甚至学会重建一些轻微异常。建议从256或512开始根据验证集的重建误差和困惑度进行调整。损失函数权重重建损失和 VQ 损失的平衡很重要。可以尝试给 VQ 损失加上一个随时间衰减的权重让模型初期更关注学习好的表示后期更关注量化。部署与阈值选择在线检测在实时流中需要维护一个滑动窗口缓冲区。每收到一帧更新缓冲区计算最新窗口的异常分数。为了平滑可以对分数进行移动平均。阈值确定异常阈值通常通过在干净的验证集仅含正常数据上计算异常分数的分布来确定。例如将阈值设为验证集分数分布的mean 3 * std。绝对不要使用包含异常的数据来调阈值这会严重破坏无监督的前提。报警策略避免单帧或短时波动触发报警。可以采用“连续 N 个窗口分数超过阈值”才触发报警的策略。局限性认知新颖性 vs 异常性VQ-VAD 检测的是“新颖”或“不常见”的模式但这不完全是语义上的“异常”。例如在办公室场景训练的模型可能会将“跳舞”判定为异常但这未必是安全事件。需要结合业务逻辑进行后处理。缓慢演变异常如果异常行为是缓慢发生的如逐渐摔倒其短窗口内的运动模式可能与正常行走差异不大可能导致漏报。需要考虑多尺度分析。VQ-VAD 提供了一种优雅且强大的框架将视频异常检测问题转化为正常运动模式的字典学习与匹配问题。通过向量量化它获得了离散的、可解释的表示并自然导出了一个基于距离的异常分数。本文的简化实现揭示了其核心机理但要应用于工业级场景还需要在特征提取、模型架构、工程流水线上做大量细致的工作。建议读者在理解本示例的基础上使用真实数据集如 ShanghaiTech Campus, UBnormal进行复现和迭代并持续关注该领域的最新进展如结合 Transformer 的时序建模、引入记忆模块等这些都是提升模型性能的重要方向。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进