拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深度学习CV基础:从图像表示到CNN训练实践

深度学习CV基础:从图像表示到CNN训练实践

说实话,这份“深度学习-CV基础知识笔记”,我在不同阶段翻来覆去写过好几遍。最早是刚入门时记给自己看的,后来带新人时又整理过一版,这次借着重新梳理的机会,把概念、公式和踩坑记录放在一起,按一条实际能走通的学习路径重新排了一遍。

不管你是正在啃《动手学深度学习》、在B站追吴恩达,还是被课程作业和期末复习逼着必须搞定CNN,这份笔记都按“先建框架、再补细节、最后上手调参”的顺序来写。核心就围绕三件事:图像在深度学习里到底怎么表示、卷积神经网络为什么适合做CV、训练一套分类模型时那些参数和报错到底该怎么处理。看完不敢说让你变高手,但至少能把基础概念连成一条线,遇到问题知道往哪个方向查。

1. 动手之前,先把这套知识框架搭起来

1.1 深度学习解决CV问题的基本套路

CV(计算机视觉)要处理的事情很多,图像分类、目标检测、语义分割、姿态估计、图像生成,每一类任务看起来差别巨大,但落到深度学习里,套路其实高度一致。先有一堆带标注的数据,把数据预处理成张量,扔进一个神经网络,网络输出一个预测结果,拿预测结果和真实标注算损失,反向传播更新网络参数,重复这个过程直到指标不再明显提升。

这个套路说起来简单,真正理解它需要你把几个概念彻底想明白:什么是张量、什么是前向传播、什么是损失函数、什么是反向传播、什么是优化器。很多人一上来就调库跑通了LeNet,觉得自己会了,但换一个数据集、换一个任务就完全懵了。原因就是只记住了代码流程,没理解每一步在干什么。

我个人的建议是:前期不急着上复杂模型,先用全连接网络把一个最简单的二分类跑通,把数据张量的形状变化画出来,把每一层的输入输出维度写在纸上,然后再引入卷积、池化这些CV专属组件。这个“慢就是快”的过程能帮你省掉后面大量的排查时间。

1.2 图像在计算机里到底是什么

这是整个笔记里最基础也最容易被忽略的问题。一张灰度图,本质上就是一个二维矩阵,矩阵里每个元素是0到255之间的整数,代表该像素点的亮度,0是纯黑,255是纯白。一张彩色图,则是三个这样的矩阵叠在一起,分别对应红、绿、蓝三个通道。也就是说,图像进入深度学习模型之前,就是数值矩阵。

把这些矩阵喂给深度学习框架之前,还要再做两步标准化。第一步是调整尺寸,因为网络通常要求固定输入大小,比如224×224或32×32。第二步是归一化,把像素值从0到255缩放到0到1或-1到1范围,这样能避免数值过大导致梯度不稳定。很多初学者在归一化这一步偷懒,结果模型训练时loss乱跳,还以为是网络结构的问题。

到了框架内部,图像会被组织成四维张量,形状一般是(batch_size, channels, height, width),简写为NCHW。batch_size是一次性喂给网络的图片数量,channels是通道数,灰度图为1,彩色图为3。理解这个维度顺序特别重要,因为你在代码里打印tensor.shape时看到的就是这四个数字,后续所有reshape、permute操作都在跟它打交道。

1.3 一张图从输入到输出的完整数据流

把图像输入卷积神经网络后,数据流的走向一般是这样的:原始图像先经过预处理变成标准形状的张量,然后进入若干个卷积层和池化层,逐步提取低级特征(边缘、颜色)和高级特征(纹理、部件),最后经过展平操作变成一维向量,送入全连接层或全局平均池化层,输出一个长度等于类别数的向量,再经过softmax转换成各类别的预测概率。

这个流程里最需要理解的是“特征图”这个概念。每经过一次卷积,图像的高度和宽度通常会变小,通道数通常会变多。通道变多意味着网络在同时关注多种不同的特征,而尺寸变小意味着特征逐渐从精细变得抽象。可以打个比方:一开始你看一张猫的照片,看到的是胡须、耳朵、毛发的纹理,往后逐层抽象,网络在更高层关注的是“有猫耳朵”“有猫脸轮廓”这些组合特征。

学习阶段,建议你随便拿一张图,自己写代码看一下每一层输出的shape变化。这个习惯比你背十遍网络结构都管用,因为shape就是网络结构的具象化表现。shape对不上,一定某个环节理解错了。

2. 卷积神经网络:CV里躲不开的积木

2.1 卷积到底在算什么

卷积是CNN最核心的操作,但初次接触的人很容易被那个“翻转卷积核”的数学定义绕晕。在实际的深度学习框架里,卷积的实现就是一个滑动窗口的点乘求和操作,不做核翻转,严格来说叫“互相关”,但大家习惯上都叫卷积。

你可以这样理解:一个3×3的卷积核,就是在图像上按步长滑动,每次取当前位置周围3×3的像素块,和卷积核的9个数字逐位相乘再求和,得到一个输出值。滑完整个图像,就得到一张新的特征图。卷积核里的数字不是人设计的,而是网络通过训练学出来的,每个卷积核相当于一个特征检测器,有的负责检测水平边缘,有的负责检测垂直边缘。

这里有个关键参数要注意:填充(padding)和步长(stride)。padding是在图像周围补一圈0,防止边缘像素参与计算次数太少导致输出尺寸缩小过快;stride是卷积核每次滑动的距离,stride为2时输出尺寸直接减半。输出特征图尺寸的计算公式是:output_size = (input_size - kernel_size + 2 × padding) / stride + 1。这个公式建议自己动手推一遍,因为几乎所有shape对不上的报错都和它有关。

2.2 池化层:压缩信息,保留关键特征

池化层在早期CNN里几乎是标配,它做的事情很简单:在特征图的一个小窗口内取最大值(最大池化)或平均值(平均池化)。最常用的是2×2、步长为2的最大池化,效果就是特征图的高和宽各减半,通道数不变。

池化带来两个好处。第一个是降低计算量,特征图小了一半,后续卷积层的计算量直接降到原来的四分之一。第二个是带来一定程度的平移不变性,也就是说物体在图像里稍微挪动几个像素,池化后的结果变化不大,这对分类任务是有利的。但随着网络设计的发展,很多现代网络(比如ResNet)已经不怎么用池化层了,而是用卷积本身来降采样,效果更好,但理解池化仍然是读懂早期经典网络的基础。

做笔记的时候我建议把卷积、池化、激活函数这三样放在一起理解。它们通常是交替出现的:卷积提取特征,激活函数引入非线性,池化压缩尺寸。看任何经典网络结构图,只要抓住这个交替模式,就能把结构看懂一大半。

2.3 激活函数:没有它,堆再多层也白搭

如果神经网络里只有卷积和全连接,没有激活函数,那不管叠多少层,整个网络依然是一个线性变换。打个比方:你用几张透明塑料片叠在一起,不管怎么叠,透过它们看到的东西还是清清楚楚的,不会有任何“扭曲变形”来丰富表达。激活函数的作用就是引入这种非线性扭曲,让网络有能力拟合复杂函数。

CV里最常见的激活函数是ReLU,公式是f(x) = max(0, x),正数不变,负数全部置零。它的优点是计算极快、梯度在正数区间恒为1,能有效缓解梯度消失。实际使用中你会看到很多网络在卷积层之后紧跟一个ReLU。后来出现的LeakyReLU、SiLU等变体,本质上都是在解决ReLU在负数区间的“神经元死亡”问题,初学者先把ReLU吃透就够用了。

记住一个原则:卷积层和全连接层后面通常都要接激活函数,但输出层一般不接,或者只在做分类时接softmax。原因很简单,输出层要输出连续的置信度分数,不需要做非线性截断。

2.4 从全连接到输出层:分类任务的最后一公里

卷积层和池化层负责把图像转换成特征,最后需要一个分类器来输出预测结果。传统做法是把特征图展平成一维向量,接几个全连接层,最后一层的神经元数量等于类别数,输出一个得分向量。做多分类时,再对这个得分向量做softmax,把得分转换成概率分布,所有类别的概率之和等于1。

这里有一个值得注意的细节:实际计算损失时,PyTorch这类框架通常把softmax和交叉熵损失合并成了一个函数,比如CrossEntropyLoss,它会直接接收模型输出的原始得分(logits),内部帮你做softmax再算损失。很多新手踩过的坑是:在模型输出层手动加了softmax,然后又用CrossEntropyLoss算损失,相当于做了两次softmax,导致训练初期loss就异常。我自己也犯过这个错,排查半天才发现问题出在这。

全连接层参数量很大,一张32×32×3的输入,展平后就有3072维,再接一个稍大的全连接层,参数动辄几十万。现代网络倾向于用全局平均池化替代展平+全连接:对最后一张特征图的每个通道求平均,得到一个长度等于通道数的向量,再接一个线性层输出分类结果。这种方式参数量小,还不太容易过拟合。

3. 训练一套自己的分类模型

3.1 环境与工具选型

练手阶段,我个人推荐PyTorch,原因有三。第一是调试方便,张量维度不对的时候报错信息比较清楚;第二是生态完善,torchvision里带了常用数据集和预训练模型,下载就能用;第三是社区资料多,遇到问题一搜就有答案。TensorFlow也很好,但在易用性上对新手确实有些门槛。

环境的安装细节不展开了,给一个能跑的最小组合:显卡驱动能正常识别GPU的前提下,用conda创建一个新环境,Python版本用3.9或3.10,PyTorch版本用官方命令安装最新的稳定版即可。没有NVIDIA显卡也不要紧,纯CPU版本完全够跑通CIFAR-10小模型练习,只是慢一些。

集成的开发环境我比较推荐VS Code加Jupyter插件,既能写脚本,又能一段段跑代码观察中间结果。学习阶段,能实时打印每一层的输出shape、loss变化曲线,比任何调试器都直观。

3.2 数据准备:不要小看预处理

很多人觉得数据处理是最没技术含量的环节,恰恰相反,数据质量直接决定模型上限。以CIFAR-10为例,这是一个10类、每类6000张32×32彩色小图的数据集,跑起来非常快,非常适合做练习。

用torchvision加载CIFAR-10时,有两步必须做:转成Tensor,然后做归一化。转Tensor会把像素值从0到255变成0到1,归一化则是按数据集的均值和标准差做标准化。CIFAR-10的常用均值是(0.4914, 0.4822, 0.4465),标准差是(0.2470, 0.2435, 0.2616)。这套数字是预先算好的,直接抄来用就行。归一化不是可有可无的步骤,它能让各个通道的数值分布保持一致,训练时收敛更快更稳。

数据加载还有一个重要概念:DataLoader。它的作用是把数据集切成一个个batch、打乱顺序、在训练时按batch取数据。batch_size建议从64或128开始,太小会导致梯度更新太频繁、训练震荡,太大又可能显存不足。在数据流水线这块,新手最容易忽略的是num_workers参数,它控制用几个子进程来加载数据,默认0表示只在主进程里加载,数据量大时会拖慢训练。设置为4或8能明显提高GPU利用率,前提是你的电脑内存足够。

3.3 写一个最小可用的训练循环

下面这份代码是能在CIFAR-10上跑到70%左右准确率的完整训练流程,结构非常简单,建议逐行看懂后再动手改网络结构。

import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms # 1. 数据预处理 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform) trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=4) testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform) testloader = torch.utils.data.DataLoader(testset, batch_size=128, shuffle=False, num_workers=4) # 2. 定义一个简单的CNN class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(2, 2) self.fc = nn.Linear(64 * 8 * 8, 10) self.relu = nn.ReLU() def forward(self, x): x = self.pool(self.relu(self.bn1(self.conv1(x)))) x = self.pool(self.relu(self.bn2(self.conv2(x)))) x = x.view(x.size(0), -1) x = self.fc(x) return x # 3. 初始化模型、损失函数、优化器 model = SimpleCNN() device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) # 4. 训练循环 for epoch in range(20): running_loss = 0.0 for images, labels in trainloader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f'Epoch {epoch+1}: loss = {running_loss / len(trainloader):.4f}') # 5. 在测试集上评估 correct = 0 total = 0 model.eval() with torch.no_grad(): for images, labels in testloader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'Test accuracy: {100 * correct / total:.2f}%')

这个结构里每行都值得品味。optimizer.zero_grad()清空上一轮梯度,因为PyTorch的梯度是累积的,不清空会把多次反向传播的梯度叠加在一起。loss.backward()计算梯度,optimizer.step()用梯度更新参数,这三步是训练循环的固定三连。model.eval()和with torch.no_grad()在评估时一定要加,前者会关闭BatchNorm和Dropout的训练逻辑,后者会禁止计算梯度图,省内存也省时间。

有个容易忽略的小坑:喂给模型的tensor要调用.to(device),把数据和模型放到同一个设备上。如果模型在GPU但数据还在CPU,你会看到一个报错Found device cpu but expected cuda。这句话基本是每个PyTorch新手都遇到过的。

3.4 训练参数怎么定:epoch、batch size、学习率

epoch、batch size、学习率这三个参数是训练阶段最常调的东西,但很多教程只是丢给你一组默认值,不讲为什么。

epoch是遍历整个训练集的次数。每个epoch结束后,模型把整个数据集看过一遍。epoch太小,模型还没学够,欠拟合;epoch太大,模型把训练集背下来了,测试集上反而变差,过拟合。CIFAR-10这样的小数据集,20到50个epoch比较常见,具体以验证集准确率不再提升为早停时机。

batch size决定每次用多少张图算一次梯度。直观理解是:batch size越大,算出来的梯度越接近全局真实的梯度方向,训练越稳;batch size越小,梯度噪声越大,反而可能起到正则化作用,帮助跳出局部最优。但也有一个微妙的问题:batch size过大时,训练收敛快但泛化性能可能略微下降,业界把这种现象称为“大批量效应”。练手时不用纠结,64或128起步,遇到loss不稳定就调小一点。

学习率是最重要的超参数,它决定每一步参数更新的幅度。学习率太大,loss曲线直接震荡甚至变成NaN;学习率太小,loss下降得极其缓慢,像蜗牛爬。一个可行的策略是先用Adam优化器加0.001的初始学习率,观察loss曲线的下降趋势,然后逐步调整。如果loss前几个epoch就开始震荡,果断调小十倍;如果loss下降慢得像没学一样,试试调大十倍。

关于优化器的选择,早期教程普遍用SGD加momentum,因为它在很多任务上最终准确率更高;但由于SGD对学习率很敏感,新手不好掌握,Adam这种自适应学习率的方法更省心。我自己的习惯是:快速验证想法用Adam,追求最终指标时再换回SGD配CosineAnnealing学习率调度。

4. 训练中踩过的坑和排查方法

4.1 Loss不降、梯度爆炸和NaN问题

训练刚开始,最常见的问题就是loss完全不动或者直接变成NaN。loss是NaN的排查顺序我建议这样走:先看学习率是不是太大了,调小十倍试试;再看数据里有没有包含NaN或无穷大,归一化代码有没有写上;然后检查模型输出层有没有做两次softmax;最后考虑是不是梯度爆炸,可以在优化器里加一个clip_grad_norm_操作,把梯度的范数裁剪到1.0以内。

loss完全不降的情况则要复杂一些,可能的原因包括:数据没有归一化,网络结构有Bug(比如忘记加激活函数),batch size太小导致梯度噪声太大,或者是标签和数据没对齐。我遇到过最乌龙的一次是DataLoader的参数shuffle设成了False,模型在每个epoch里看到的都是相同顺序的数据,虽然还是能学会,但泛化能力明显变差。

遇到这种问题,不要瞎猜,把loss每个epoch的输出记录下来画成曲线。曲线能告诉你很多信息:直线在0.7附近不动,大概率是模型没学到任何有效特征;先降后升,说明过拟合了;反复震荡但整体有下降趋势,说明学习率略大。养成记录训练曲线的习惯,排查效率至少提高一倍。

4.2 过拟合的典型信号和处理手段

如果训练准确率一路飙升到99%,但测试准确率只有70%,恭喜你,你过拟合了。这是深度学习中“背诵”和“理解”的差别:模型在训练集上背下了每个样本的答案,但遇到没见过的样本就不会了。

处理过拟合有一整套层层递进的手段。第一层是数据层面,做数据增强,比如随机裁剪、水平翻转、颜色抖动,相当于免费增加了训练样本数量;第二层是模型层面,降低模型复杂度,减少卷积核数量或全连接层神经元数量,加Dropout层随机丢弃一部分神经元,加BatchNorm层稳定分布;第三层是训练策略层面,早停,也就是在测试准确率连续几个epoch不提升时停止训练,加入权重衰减(weight_decay)正则项。

初学者最容易犯的错误是一上来就用预训练模型微调,结果模型本身就很大,小数据集上必然过拟合。先在小数据集上把基础分类练好,再尝试迁移学习,这个顺序更合理。

4.3 显存不足和训练速度慢的取舍

显存不够是另一个高频问题。RuntimeError: CUDA out of memory这行报错,处理方案按优先级排列依次是:调小batch_size(立竿见影但别太小,影响梯度稳定性);降低输入图像分辨率;改用一个更小的模型;开启梯度累积,也就是多个小batch的梯度攒起来再更新一次参数,效果等价于大batch size但显存占用小得多。

训练速度慢的问题则要先定位瓶颈在哪。GPU利用率不高时打开任务管理器看一眼,如果GPU利用率经常不到50%,说明数据加载的速度跟不上模型计算的速度,这时候调大num_workers、把数据格式改成LMDB或TFRecord、或者使用更快的SSD通常能解决问题。反过来,如果GPU利用率已经接近100%还嫌慢,那就只能从模型本身瘦身了:减少卷积核数量、用深度可分离卷积替代普通卷积、开启混合精度训练。

混合精度训练是个值得推荐的工程技巧。现代GPU对半精度浮点数计算速度是单精度的两倍以上,而大部分深度学习模型的训练并不需要那么高的数值精度。PyTorch里用torch.cuda.amp.GradScaler几行代码就能开启,在训练速度和显存占用上都能得到明显改善,精度损失几乎可以忽略。

4.4 几个值得长期保持的实操习惯

最后分享几个我踩过无数次坑之后沉淀下来的习惯,每一个都是用掉头发换来的。

第一个习惯是:改任何代码之前,先想清楚你要验证什么假设,改完之后记录下结果。模型训练不是玄学,每一次调参都应该有明确的目的。如果连着试了好几种方案都没效果,停下来重新审视数据,多半是数据本身有问题。

第二个习惯是:第一次跑通模型时,先只拿一小部分数据(比如100张图)来过拟合,看loss能不能降到很低。如果100张图都过拟合不了,赶紧排查代码Bug;如果顺利过拟合,再逐步加大数据量跑正式训练。这个方法能在几分钟内帮你发现绝大多数的代码错误,省下几小时的无效等待。

第三个习惯是:固定随机种子。在代码开头设置torch.manual_seed(42)以及numpy.random.seed(42),这样每次实验的初始权重、数据打乱都是可复现的。没有固定随机种子的时候,你很难判断模型效果的提升到底是你的改进起作用了,还是仅仅是运气好。

根据我个人经验,深度学习CV方向的学习曲线不是线性上升的,而是在很长一段时间内徘徊,然后某一天突然感觉所有概念都通了。那个拐点往往不是因为你多看了某篇文章,而是你把底层的数据流、梯度流亲手捋了一遍,手写了一个哪怕很小的网络并把它训练到收敛。希望这份笔记能帮你更快走到那个拐点。

返回列表