做视觉项目的时候,很多人一开始只把“图像输入”当成一个 numpy 数组,预处理随便写两行,然后直接丢给模型。等到真把训练好的人脸识别模型接到图片库或者摄像头上,才发现从原始像素到最终那串高维向量输出的过程里,藏着大量被入门教程悄悄跳过的问题。这篇十七号笔记正好用来把这些坑挨个填上:围绕“图像进、向量出”这一条主线,把数据预处理、卷积骨干、嵌入层设计、损失函数、L2 正则化、多任务 loss 和推理优化这些环节全部串起来。
这个内容适合谁?如果你已经跑通过一个简单的 Python 神经网络,知道反向传播大概是怎么回事,但一到现实视觉场景就不知道从哪里下手,那这一篇基本就是为你写的。它不会只讲一个孤立的模型训练,而是站在“生产环境里真正要跑起来”的角度,把从图片路径到特征向量的完整链路拆开给你看。我也把这段时间踩过的坑放在后面,方便你直接对照排查。
1. 整体设计:图像进、向量出是一个端到端系统工程
很多初学者会把“识别”想象成一个线性过程:图片进去,标签出来,完事。实际做项目的时候,尤其是做人脸识别、图像检索、相似度匹配这类任务,你要的不是一个分类标签,而是一个具有语义区分度的高维向量。这个向量通常叫做 embedding,也叫特征嵌入。后面所有相似度计算、比对、聚类,都是建立在这个向量之上的。
1.1 先想清楚你要的“向量”是什么语义
这是整个项目的起点,也是决定后面一切选型的核心问题。同样是“输出一个高维向量”,不同任务对向量的要求完全不同。
如果你做的是人脸验证,目标很明确:同一个人的两张照片,向量距离要近;不同人的两个人,向量距离要远。此时向量更多承担的是“身份度量”的功能,常用欧氏距离或者余弦相似度来衡量远近。如果你做的是人脸属性分析,比如年龄、性别、表情估计,那向量里的不同维度可能分别承载不同语义,训练方式也更偏向多任务回归或分类。还有一类是图像检索,向量用来表征“这张图和那堆图在内容上是否相似”,超类模型把整个图像压缩成一个全局描述子,向量质量直接决定检索精度。
所以第一步不是急着搭网络,而是先问自己一句:最终业务方拿这个向量去做什么?这个问题的答案,决定了你的监督信号怎么设计,损失函数怎么选,甚至决定你需不需要做特征归一化。
1.2 从像素到向量的五段流水线
我习惯把整个流程拆成五段,每一段都有自己清晰的职责,这样出了问题也好定位:
第一段是图像输入,负责把不同尺寸的图片统一成模型能吞的形状,同时转换成张量。第二段是数据预处理,包括标准化、增广、归一化这些操作,目的是让输入分布尽可能稳定,减轻模型的学习负担。第三段是骨干特征提取,通常使用卷积神经网络结构,把二维图像逐步抽象为具有空间结构的特征图。第四段是嵌入映射,把特征图压缩映射到固定长度的高维向量,这一步是你最终拿出去用的东西。第五段是训练目标与优化,包括损失计算、正则化、学习率调整,还有梯度反传。
前面两段最容易被忽略,但它往往决定了训练能不能收敛;中间两段往往是论文里最热闹的部分,但实现起来相对套路化;最后一段则决定模型泛化能力和稳定性。这五段不是简单的线性关系,比如预处理的设计要考虑到骨干网络对输入分布的要求,嵌入维度的大小又和最终后端存储、检索性能强相关。
1.3 方案选型:为什么不是纯前馈,也不是纯卷积
有同学可能会问:标题热词里既有“前馈神经网络”又有“卷积神经网络”,这个项目里到底应该用哪种?
我的经验是,纯前馈网络处理图像非常吃亏。一张 224x224 的 RGB 图像,展开成一维就是 150528 个输入单元,第一层全连接参数量就大得离谱,而且完全丢失了像素之间的空间关系。卷积神经网络通过局部感受野和权值共享,用很少的参数就能捕获局部纹理、边缘、形状这些层次化特征。所以做图像相关的嵌入学习,骨干网络常规选择就是 CNN 结构。
但这不代表前馈网络完全没用。在实际工程里,前馈层通常以“全连接层”的形式嵌在 CNN 的尾部,负责把卷积输出的特征图做最后的全局映射。很多现成骨架网络最后也都带着一层或几层全连接。所以你可以在一个项目里同时看到这两种结构,只是它们各自负责的阶段不同。
2. 核心细节:从图像张量到高维向量的关键转换
这一节是整个流程里最值得花时间细抠的部分。很多时候模型效果不好,不是骨干网络的锅,而是图像预处理和特征压缩这两环出了问题。
2.1 图像预处理:标准化不是可选项是必选项
图像从文件里读出来,最开始是一个三维数组,形状一般是 H×W×3,值域是 0 到 255。你要是直接把这种数据喂给网络,最常见的后果是 loss 不下降,或者收敛到很差的一个局部解。
神经网络内部普遍依赖输入的数值范围居中且尺度一致。骨干网络的批归一化层虽然对输入分布有一定容忍度,但标准差太大依然会让早期梯度不稳定。常规做法是把每个像素值除以 255 缩放到 [0,1],然后再用每个通道的均值 mean 和方差 std 做标准化。这么做相当于告诉模型“大多数输入数据的特征都落在一个合理的统计范围内”,后续卷积核的初始化也才真正有意义。
同时还要注意,不同预训练骨架的统计参数不一样。如果你加载了一个在特定数据集上预训练好的权重,那么预处理必须严格采用训练时的 mean/std,否则输入分布对不上,预训练发挥不出迁移效果,严重时精度会掉十几个点。这一点是很多人容易忽略的“隐形坑”。
2.2 卷积骨干如何把图像变成特征图
图像一路经过卷积、激活、池化,其实是在不断扩大感受野,同时把底层像素信息逐步转换成高层语义信息。这个过程可以理解成一个连续的信息提炼过程:一开始记住的是边缘和颜色块,中间层开始组合出纹理图案,深层则能感知出眼睛、鼻子这类部件级语义。
以人脸识别为例,输入一张 112x112 或 224x224 的人脸图,经过多层卷积和下采样之后,最终得到的是一个空间分辨率大幅缩小的特征图,比如 7x7,通道数可能是 512 或 1024。这个特征图保存的是“人脸语义的空间响应分布”,但它还不是最终要用的向量。
这个阶段的关键参数是骨干网络输出特征图的通道数和空间尺寸。通道数决定了特征表达能力,空间尺寸决定了位置信息保留程度。做识别任务时,我们希望最后压缩成向量后能对平移、缩放有一定的鲁棒性,所以通常把空间维压得很小。
2.3 从特征图到高维向量:全局池化、投影层与归一化
拿到 7x7x512 的特征图之后,下一步是把它变成一段固定长度的向量。常见做法是先做全局平均池化,把空间维度直接平均掉,得到一个 512 维的向量。这一步把特征图的空间坐标信息抹掉了,留下的是全局的、对位置不那么敏感的语义统计量。
如果你只是面向非常简单的分类任务,全局平均池化后的向量可以直接送进分类器。但如果是做嵌入学习,建议再加一个投影层,通常设计成全连接 Linear 层,把 512 维映射到你想要的嵌入维度,比如 128 维或 256 维。这个投影层的作用是让模型在一组更紧凑的特征维度上表达语义,同时方便你调节最终向量的存储和检索成本。
嵌入层输出后还有一个非常重要的操作:L2 正则化或者说归一化。把向量除以其 L2 范数,使模长变成 1。这让向量只保留方向信息,而丢弃尺度信息。这件事在计算余弦相似度时几乎必须做,不然模长大小会干扰距离度量。哪怕你用欧氏距离,也建议统一归一化之后的向量,否则某些样本的“高置信度”会以向量长度变大来呈现,但这种长度差异往往不是语义差异。
3. 实操实录:在 PyTorch 中实现一整套流程
理论讲再多,不如直接跑一遍。下面这段我按自己常用的工程结构,给出一份可以直接参考的实现思路。环境就是常规的 Python 3.8+ 加 PyTorch,不依赖额外花哨的库。
3.1 数据读取与增强配置
图像数据首先要解决的是“路径和图对应关系”的问题。最省事的设计是维护一个列表,每一项是图片路径和标签。为了节省内存,不要一次性把所有图片读成数组,而是实现一个 Dataset,在getitem里即时读取。
import torch from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class FaceDataset(Dataset): def __init__(self, samples, transform=None): # samples: [(图片路径, 身份标签), ...] self.samples = samples self.transform = transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") if self.transform: img = self.transform(img) return img, torch.tensor(label, dtype=torch.long)训练时的关键 transform 组合建议是这样:先 Resize 到输入尺寸,然后随机水平翻转,再用随机仿射做微小的平移缩放扰动,最后转成张量并执行标准化。
train_transform = T.Compose([ T.Resize((224, 224)), T.RandomHorizontalFlip(), T.RandomAffine(degrees=5, translate=(0.02, 0.02)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里要注意一点:验证和推理时的 transform 不能带随机增强。推理阶段只做 Resize、ToTensor、Normalize,连水平翻转都不能加,否则输出向量不稳定。
3.2 骨干网络与嵌入头设计
骨干网络可以直接用现成的残差结构,也可以自己搭一个小型卷积网络。如果你是初学,建议先用现成结构,把精力放在嵌入头和训练策略上。
下面是一个可配置的小型骨干加嵌入头的结构,前馈层放在卷积之后。
import torch.nn as nn class EmbeddingNet(nn.Module): def __init__(self, embedding_dim=128): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, stride=2, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, 64, kernel_size=3, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) self.pool = nn.AdaptiveAvgPool2d((1, 1)) self.embed = nn.Linear(256, embedding_dim) def forward(self, x): x = self.features(x) x = self.pool(x) x = torch.flatten(x, 1) x = self.embed(x) return x这个小网络结构不算复杂,但完整覆盖了“卷积到特征图,再经全局池化到向量”的标准路径。唯一要说的是 AdaptiveAvgPool2d 比普通 AvgPool2d 更省心,它对任意输入尺寸都能输出固定大小的特征,改输入尺寸的时候不用动结构。
如果你加载预训练模型,那就把骨干替换成现成的残差网络,冻结前几层,微调最后几层和嵌入头,效果通常会更好。
3.3 训练循环与 L2 正则化的真正实现方式
很多人以为 L2 正则化就是在 loss 后面加一个权重平方项,其实在 PyTorch 里最优雅的实现方式是直接用优化器的 weight_decay 参数,它内部会对参与训练的权重做 L2 罚项。
import torch.optim as optim model = EmbeddingNet(embedding_dim=128) optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4)weight_decay 这个值需要试。太大会把权重压得太小,导致模型表达力不足;太小等于没加。我看过一个经验范围是 1e-4 到 1e-3 之间,分类任务常用 5e-4,嵌入学习任务里我经常从 1e-4 起步。
另一种更直接的方式也能做,就是在 loss 计算时手动把所有权重的平方和加进去,遇到只想正则化部分层的情况时更灵活。但日常项目里直接用 weight_decay 就够了,代码简洁还不用背公式。
训练循环里还有一个我强烈建议加上的操作:每个 epoch 训练完,在验证集上计算一次 embedding 在同一身份样本上的类内距离和不同身份样本上的类间距离。如果类内距离缓慢下降、类间距离缓慢上升,说明模型是在往正确的方向学;如果两者一起上升,多半是 loss 设计有问题。
3.4 多任务场景:多个 Loss 之间的权重怎么调
热词列表里专门有“如何调整多个 loss 间的比例”,这个问题我遇到太多次了。当你做的人脸识别模型既想保持身份可分,又想预测年龄、性别这些属性时,通常要同时优化多个任务。
最简单粗暴的做法是给每个 loss 配一个固定权重,然后加起来。比如:
loss = cls_loss + 0.5 * age_loss + 0.2 * gender_loss但这种做法有个明显毛病:不同任务的 loss 量纲和梯度尺度不一样,固定权重很难调。比如身份分类的交叉熵可能是 3 左右,年龄回归 loss 可能是 20,直接相加让年龄项完全主导梯度。
我后来常用的方案有两个。一个是让每个任务共享骨干但使用独立的预测头,然后采用“不确定性加权”动态调整每个任务的权重。核心思路是让每个任务自动学习一个噪声参数,任务更难、噪声更大的网络,权重自动变小。实现上就是每个任务维护一个可学习的 log 方差参数。
log_var_id = torch.zeros(1, requires_grad=True) log_var_age = torch.zeros(1, requires_grad=True) loss = (0.5 / torch.exp(log_var_id) * id_loss + log_var_id + 0.5 / torch.exp(log_var_age) * age_loss + log_var_age)这样训练过程中模型自己会把不同 loss 拉到合理的量级。另一个更朴素的方案是在训练初期把各个任务的 loss 做标准化,先各自训练几个 epoch 观察它们的幅度范围,然后再定固定比例。我在多属性识别项目里实际测下来,不确定性加权收敛快,而且不用反复调参,值得优先尝试。
3.5 推理输出与后处理
推理阶段模型输出的原始向量还需要做一步归一化,然后再进入下游比对。归一化之后的向量,计算余弦相似度时直接用矩阵乘法即可。
def inference(model, img_tensor): model.eval() with torch.no_grad(): embedding = model(img_tensor.unsqueeze(0)) embedding = torch.nn.functional.normalize(embedding, p=2, dim=1) return embedding向量维度也不是越宽越好。128 维在大多数大规模人脸检索场景里已经够用,256 维精度通常会好一点,但存储空间和检索耗时也上去了。如果项目是百万级底库,需要认真评估维度带来的内存开销和索引延迟,很多时候 128 维是工程上的甜点位。
4. 踩坑记录:典型问题与排查思路
这一节算是整个系列的保留节目。下面这些问题,有些是我自己在项目里一次次撞出来的,有些是同行交流时反复遇到的。我按“现象、原因、解决方案”的方式整理成表,方便直接对照。
4.1 训练 Loss 在下降,但输出向量完全失控
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| Loss 越来越小,但同类样本余弦相似度很低 | 没做特征归一化,模型依靠向量模长“作弊” | 训练和推理时统一对 embedding 做 L2 归一化 |
| 验证集类内距离大、类间距离小 | 嵌入维度设置过高,训练数据量不够 | 降低嵌入维度到 64 或 128,或增加训练样本 |
| 同一张图多次输入,输出向量不稳定 | 推理时带了数据增强或 Dropout 未关闭 | model.eval() 中关闭 Dropout,去掉随机增强 |
| 训练 loss 一直震荡不收敛 | 学习率偏大,数据没做标准化 | 降低 lr,检查预处理 Normalize 参数 |
第一行那个问题我印象最深。当时 loss 降得挺顺利,但检索结果一塌糊涂。后来我把每个样本输出向量的模长拉出来看,发现不同身份之间的向量长度差异非常大。模型其实学会了用模长来表达“判断置信度”,但这根本不是我们想要的身份语义。归一化之后,问题立刻消失。
4.2 多任务 Loss 不均衡导致某个任务被“放弃”
多个任务共用一个骨干的时候,经常出现一个任务收敛得很好,另一个任务的 loss 纹丝不动。从梯度角度来看,梯度之和的方向被量级大的任务控制,量级小的任务等于没有梯度。
排查顺序建议是先打印两个 loss 的比例,持续观察几十个 step。如果比例悬殊,先按不确定性加权方案替换固定权重。另一个实用技巧是给各个任务设置不同的学习率衰减策略,比如主干任务用正常衰减,辅助任务保持较高的学习率更久一点。多任务动态权重至今没有一劳永逸的方案,建议在项目里做成可配置的,方便实验。
4.3 推理速度太慢:从训练到边缘部署的优化
训练的时候大家不在意速度,可到了部署阶段,一张图跑 50 毫秒可能都嫌慢。常见的优化思路有这么几个:
第一是换轻量骨干网络,把标准残差块替换成深度可分离卷积,参数和计算量都能大幅下降。第二是模型量化,比如从 float32 转成 float16 或 int8,在精度损失可控的前提下显著提速。第三是减少预处理耗时,比如避免在推理线程里做 PIL 的多次格式转换。还有一个很容易忽略的点:模型推理时设置 torch.no_grad() 并且开启 cudnn.benchmark,在某些输入尺寸固定的场景能明显加速。
边缘计算场景还需要关心调度。一个摄像头画面几路过来,模型实例是怎么复用的?如果每路请求都重新加载一次权重,模型加载的时间可能比推理还长。我的做法是启动一个常驻推理服务,模型只加载一次,请求进来只做张量计算,实测吞吐量有天壤之别。
4.4 环境与依赖问题
热词里出现很多“python 安装”“python 下载 cv2”之类的搜索,说明环境配置依然是很多人的拦路虎。我的建议是用虚拟环境,不要直接往系统 Python 里装深度学习库。在虚拟环境里安装 PyTorch 时,要注意选择与自己的 CUDA 驱动匹配的版本。装不上 GPU 版就先用 CPU 版把代码流程跑通,再补 GPU 版,两者在代码层几乎没有区别。OpenCV 读取图像时要用 BGR 通道顺序,如果直接转成 RGB 忘记转换,图像颜色会错乱,训练出来的模型等于在“看”一个色调错乱的世界。这类数据 bug 难排查,因为训练流程不会报错,只会在结果上歪掉。
5. 这个系列后续怎么走
写到这里,第十七篇的核心内容差不多讲完了。我把这次的重点再按自己的理解串一下:图像进、向量出的流程不是某一个模型的功劳,而是数据流、网络结构、损失设计和工程部署共同配合的结果。预处理决定了模型看到什么,骨干网络决定了模型提炼什么,嵌入头决定了模型的表达能力,损失和正则化决定了模型往哪个方向收敛,最后推理优化决定了模型能不能在业务场景活下来。
如果你现在正卡在某个环节,我的建议是不要只盯着网络结构改。先用小规模数据把整条流水线跑通,打印出每个阶段张量的 shape,确认没 bug 之后再放大数据。然后单独评估一下模型输出的 embedding 质量,不要只看 loss,因为 loss 降低和向量语义正确之间,中间还隔着一条很宽的河。
最后再送一个实测小技巧:在数据集里挑出三类样本——同身份不同姿态、不同身份但相似背景、极端光照下的同一身份,训练过程中每次保存模型后都跑一下这三类样本的相似度矩阵。这个矩阵比任何 loss 曲线都直观。我发现很多项目里,模型看似训练完成了,但这类典型样本对上的向量距离仍然糟糕,这种问题早发现早调整,拖到上线就是事故。