1. 从“看局部”到“看全局”:CNN如何教会计算机看懂世界

我记得第一次接触卷积神经网络(CNN)的时候,感觉它就像一个特别聪明的“局部扫描仪”。你想啊,我们人眼看一张照片,也不是一眼就看清所有细节的,总是先注意到眼睛、鼻子这些局部特征,再组合起来认出这是一个人脸。CNN干的就是这个事儿。

它的核心武器是卷积核,你可以把它想象成一个拿着放大镜的小探测器。这个探测器在图片上一点点滑动(这就是“卷积”操作),专门寻找特定的图案,比如横线、竖线或者拐角。最开始几层找到的都是这些非常基础的“边边角角”;随着网络变深,后面的层就能把这些简单的边角组合起来,认出更复杂的东西,比如眼睛的轮廓或者车轮的形状。我刚开始自己写CNN代码时,总喜欢把中间层的输出可视化出来,看着那些从模糊的纹理逐渐变成清晰的物体部件,感觉特别神奇。

这里有个非常关键的细节,就是参数共享。同一个卷积核会扫过整张图片的所有区域。这意味着,不管图片里猫的眼睛在左上角还是右下角,用来检测“圆形”的卷积核都能把它找出来。这大大减少了模型需要学习的参数数量,也让模型具备了“平移不变性”——物体挪个位置,照样能认出来。这比老式的全连接网络聪明多了,全连接网络如果训练时猫头在左边,测试时猫头跑到右边,它可能就懵了。

当然,光有卷积层还不够,还得有池化层。池化层的作用是“浓缩信息”。比如最大池化,它在一个小区域里(比如2x2的格子)只保留数值最大的那个像素。这相当于在说:“这块区域最重要的特征就是这个亮点了,其他细节我先忽略掉。”这样做有两个好处:一是让特征图变小,计算量降下来了;二是让模型不那么死抠像素级的细节,增强了泛化能力,对图片里物体轻微的位移、形变更鲁棒了。

在实际项目里,搭建一个CNN就像搭积木。下面是一个用PyTorch实现一个简单CNN来识别手写数字(MNIST数据集)的经典结构:

import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self):
        super(SimpleCNN, self).__init__()
        # 第一个卷积块:提取基础边缘特征
        self.conv1 = nn.Sequential(
            nn.Conv2d(in_channels=1, out_channels=16, kernel_size=5, stride=1, padding=2), # 输出尺寸:(16, 28, 28)
            nn.ReLU(), # 激活函数,引入非线性
            nn.MaxPool2d(kernel_size=2) # 输出尺寸:(16, 14, 14)
        )
        # 第二个卷积块:组合基础特征成更复杂的形状
        self.conv2 = nn.Sequential(
            nn.Conv2d(16, 32, 5, 1, 2), # 输出尺寸:(32, 14, 14)
            nn.ReLU(),
            nn.MaxPool2d(2) # 输出尺寸:(32, 7, 7)
        )
        # 全连接层:将学习到的特征映射到分类结果
        self.out = nn.Linear(32 * 7 * 7, 10) # 最终分成10类(0-9)

    def forward(self, x):
        x = self.conv1(x)
        x = self.conv2(x)
        x = x.view(x.size(0), -1) # 将特征图“拍平”成一维向量
        output = self.out(x)
        return output

这个简单的模型已经能取得不错的效果了。但工业级应用里,我们很少从头搭建。像ResNetMobileNet这些经典架构,都是经过千锤百炼的。比如ResNet的残差连接,它允许数据“抄近路”跳过某些层,直接传到后面,这完美解决了网络层数太深时梯度消失的难题,让训练上百层的网络成为可能。而MobileNet则利用深度可分离卷积,在几乎不损失精度的情况下,把模型体积和计算量砍掉一大截,特别适合放在手机等移动设备上跑。

提示:对于初学者,我强烈建议不要只停留在调包(model = ResNet50(pretrained=True))。至少亲手用框架实现一遍上面的SimpleCNN,理解每一行代码对应的张量形状变化。这会让你对“特征如何一层层传递和变换”有肌肉记忆般的理解。

1.1 实战踩坑:你的CNN为什么效果不好?

理论很美好,但一上手训练,问题就来了。我见过最多的两个坑,一是模型根本不收敛(损失函数居高不下),二是模型在训练集上表现完美,一到测试集就“翻车”(过拟合)。

对于第一个问题,首先检查数据预处理。图片的像素值通常是在0-255之间,直接扔进网络会加重计算负担,也容易导致梯度不稳定。标准的做法是进行归一化,比如转换成0-1范围,或者进行减均值除标准差的操作。在PyTorch里可以这样方便地集成到数据管道里:

from torchvision import transforms

transform = transforms.Compose([
    transforms.ToTensor(), # 将PIL图像或NumPy数组转为Tensor,并自动缩放到[0,1]
    transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet数据集的标准均值
                         std=[0.229, 0.224, 0.225]) # ImageNet数据集的标准方差
])

第二个过拟合问题,是深度学习的老对手。CNN因为参数多,尤其容易过拟合。我的经验是,一套“组合拳”打下来最有效:

  1. 数据增强:这是成本最低、效果最好的正则化手段。随机裁剪、水平翻转、调整亮度对比度,都能让模型看到的“同一种物体”有无数种变体,迫使它去学习更本质的特征,而不是记住训练图片的像素。
  2. Dropout:在训练时,随机让网络中的一部分神经元“失活”。这相当于每次训练都在一个略微不同的子网络上进行,是一种模型平均,能有效防止神经元之间形成复杂的共适应关系。
  3. 早停:持续监控模型在验证集上的表现。一旦发现验证集误差连续几个epoch不再下降甚至开始上升,就果断停止训练。这能避免模型在训练集上“钻牛角尖”。
  4. 权重衰减:在优化器(如Adam)中设置一个很小的权重衰减参数,这本质上是对模型的大权重进行惩罚,鼓励模型学习更简单、更平滑的函数。

我印象很深的一个项目是做瑕疵检测,训练数据只有几百张。一开始模型很快就在训练集上达到99%准确率,但测试集只有60%多。后来我疯狂做数据增强(旋转、加噪、模拟光照变化),再加上Dropout,最终把测试集准确率稳定提升到了85%以上。所以,当数据不够时,别急着换更复杂的模型,先把数据增强做到位。

2. 处理“有顺序”的数据:RNN与LSTM的进击之路

CNN擅长处理图像这种空间数据,但世界上还有大量“有顺序”的数据,比如一段话(词的序列)、股票价格(时间序列)、一段语音(音频帧序列)。处理这类数据,就需要**循环神经网络(RNN)**登场了。RNN的核心思想是“记忆”,它有一个内部状态(隐藏状态),像是一个不断更新的记事本。当它读到序列中的下一个词时,会结合当前的输入和记事本里之前记录的内容,一起思考,然后更新记事本,并做出输出。

你可以把RNN单元想象成一个有着固定配方的小厨房。每次进来一个新食材(输入),厨师(RNN单元)会结合手边现有的半成品(隐藏状态),按照食谱(权重参数)加工一下,产出一道新菜(输出),并更新手边的半成品(新的隐藏状态)。这个食谱(参数)在整个序列处理过程中是共享的,这极大地减少了参数量。

但经典的RNN有个致命弱点:短期记忆。它那个“记事本”的容量和记忆力非常有限。当序列很长时(比如一篇长文章),开头的信息在传递到末尾的过程中,经过多次复合变换,其影响会指数级地衰减或爆炸(这就是梯度消失/爆炸问题)。导致模型只能记住最近几步的信息,无法捕捉长距离的依赖关系。

为了解决这个问题,**长短期记忆网络(LSTM)**被发明了出来,它可以说是RNN的“完全体”。LSTM在RNN的基础上,设计了一个精妙的“传送带”系统(细胞状态)和三个“控制门”:

  • 遗忘门:决定记事本(细胞状态)里哪些旧信息应该被扔掉。
  • 输入门:决定当前的新输入有哪些值得记到记事本里。
  • 输出门:决定基于当前的记事本和输入,应该输出什么。

这三个门都是通过sigmoid函数(输出0到1)来实现的,0代表“完全不让过”,1代表“完全放行”。这样,LSTM就能非常精细地控制信息的流动,让重要的长期信息在“传送带”上几乎无损地传递很远,同时又能灵活地加入新的短期信息。这就像你在读一本小说时,能一直记着主角的长期目标(细胞状态),同时又不会忽略当前章节的剧情细节(隐藏状态)。

下面是一个用PyTorch实现LSTM进行情感分析(判断句子是正面/负面)的简化示例:

import torch.nn as nn

class SentimentLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim, output_dim):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim) # 将单词索引转为稠密向量
        self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True) # LSTM层
        self.fc = nn.Linear(hidden_dim, output_dim) # 全连接分类层
        self.dropout = nn.Dropout(0.5)

    def forward(self, text):
        # text形状: [batch_size, sequence_length]
        embedded = self.embedding(text) # 形状: [batch_size, seq_len, embed_dim]
        # LSTM处理序列,输出output包含每个时间步的隐藏状态
        # hidden是最后一个时间步的隐藏状态,通常用于分类
        output, (hidden, cell) = self.lstm(embedded)
        # 取最后一个时间步的隐藏状态
        hidden = self.dropout(hidden[-1]) # 形状: [batch_size, hidden_dim]
        return self.fc(hidden)

在实际训练时,处理变长序列是个技术活。通常我们需要先将一批句子填充到相同长度,然后告诉LSTM真实的长度是多少,让它忽略填充的部分。PyTorch的pack_padded_sequencepad_packed_sequence就是干这个的,用好它们能提升效率和精度。

2.1 超越LSTM:GRU与双向架构的抉择

LSTM虽然强大,但计算量相对较大。于是,它的一个简化变体——**门控循环单元(GRU)**流行了起来。GRU把LSTM的遗忘门和输入门合并成了一个“更新门”,同时将细胞状态和隐藏状态合并,结构更加简洁,参数更少,训练速度更快,在很多任务上效果和LSTM不相上下。选择LSTM还是GRU,没有绝对答案,通常可以作为一个超参数来尝试。

另一个重要的架构选择是双向RNN/LSTM/GRU。普通的循环网络只能利用“过去”的上下文来预测“现在”。但很多时候,理解当前词需要同时看它的前面和后面。比如句子“这个苹果不太好吃”,看到“不太”时,就需要往后看“好吃”才能正确理解其负面含义。双向RNN通过叠加一个从序列末尾向开头运行的逆向层,实现了同时利用过去和未来的信息,在文本分类、命名实体识别等任务上提升显著。

在我做的一个智能客服项目中,需要从用户历史对话中判断其当前情绪。一开始我用的是单向LSTM,效果总差强人意,经常误判。后来换成了双向LSTM,模型对用户语气转折(比如“虽然…但是…”)的理解立刻上了一个台阶,准确率提升了近8个百分点。所以,当你处理需要全局上下文理解的任务时,双向结构绝对值得一试。

3. 抛弃循环:Transformer如何用“注意力”重塑序列建模

尽管LSTM解决了长程依赖问题,但它的“序列性”本身成了瓶颈。因为要按顺序一步一步处理,它无法进行并行计算,这在数据量和模型规模日益增长的今天,严重拖慢了训练速度。直到2017年,Transformer模型的横空出世,彻底改变了游戏规则。它完全抛弃了循环结构,核心只剩下一个东西:自注意力机制

自注意力机制干了一件非常直观的事:当模型处理序列中的一个词时,它会“放眼全局”,计算这个词与序列中所有其他词(包括它自己)的关联程度(注意力分数)。关联度高的词,就对当前词的理解贡献更大。这就像我们读一句话时,大脑会自动聚焦在关键词上。比如“我吃了一个又大又红的苹果”,理解“苹果”时,“吃”、“大”、“红”这些词的注意力分数就会很高。

Transformer把这种自注意力机制升级成了多头自注意力。想象一下,你有一组专家,每个专家从不同的角度(比如语法、语义、情感)来审视同一句话,然后各自做出一份分析报告,最后把报告综合起来。多头注意力就是这个原理,它让模型能够同时关注来自不同表示子空间的信息,捕捉更丰富的关系。

Transformer模型由编码器和解码器堆叠而成。对于像机器翻译这样的任务,编码器负责理解源语言句子,解码器则根据编码器的信息和已生成的目标语言部分,逐个词地生成翻译。它的一个关键创新是位置编码。因为自注意力机制本身没有顺序概念,所以需要显式地给每个词的位置信息编码,加到词向量里,让模型知道“我”在“吃”的前面。

下面我们来看一个最核心的多头自注意力机制的简化实现,这能帮你真正理解它的计算过程:

import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super().__init__()
        assert d_model % num_heads == 0
        self.d_model = d_model # 模型总维度
        self.num_heads = num_heads # 头的数量
        self.d_k = d_model // num_heads # 每个头的维度

        # 定义生成Q, K, V的线性层
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model) # 输出的线性层

    def forward(self, query, key, value, mask=None):
        batch_size = query.size(0)

        # 1. 线性投影并分头
        Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)

        # 2. 计算缩放点积注意力
        scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k, dtype=torch.float32))
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9) # 将mask为0的位置置为负无穷
        attn_weights = F.softmax(scores, dim=-1)

        # 3. 应用注意力权重到V上
        context = torch.matmul(attn_weights, V)

        # 4. 合并多头
        context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)

        # 5. 最终线性投影
        output = self.W_o(context)
        return output, attn_weights

这段代码清晰地展示了从分头、计算注意力分数、加权求和到合并输出的全过程。mask参数在解码时至关重要,它确保了模型在预测第t个词时,只能“看到”前t-1个词,而不能偷看未来的答案,这被称为“掩码自注意力”。

3.1 Transformer训练与调优:不止是注意力

理解了原理,要把Transformer训练好,还需要注意很多细节。首先就是优化器。Transformer通常使用Adam优化器的一个变种,叫做AdamW,它正确地实现了权重衰减,能带来更好的泛化性能。学习率调度也至关重要,常见的是“热身”策略:先从一个很小的学习率开始线性增加(热身),然后再按某种策略(如余弦退火)下降。这有助于训练初期稳定参数。

其次,梯度裁剪是训练深层Transformer的标配。因为自注意力计算中可能存在较大的梯度,裁剪能防止梯度爆炸,让训练过程更平稳。

模型架构上的技巧也很多。比如残差连接层归一化,它们被添加到每一个子层(自注意力层、前馈网络层)之后,构成了一个“构建块”。残差连接让梯度可以直接回流,缓解了深度网络中的梯度消失问题;层归一化则对每一层的输出进行标准化,加速模型收敛。前馈网络通常是一个两层的MLP,中间用激活函数(如GELU)连接,它为每个位置独立地进行计算,提供了非线性变换能力。

我在复现一个文本生成模型时,曾因为没加学习率热身,模型前期震荡得非常厉害,损失一直下不去。加上仅仅20个epoch的热身后,训练曲线立刻变得平滑,最终生成质量也高了很多。另一个坑是位置编码,如果序列长度超过了训练时设定的最大长度,就需要用外推法或者训练更长的位置编码,否则模型处理长文本的能力会急剧下降。

4. 让Transformer“看见”:ViT如何将图像变成一句话

Transformer在NLP领域大杀四方,人们自然就想:能不能让它也来处理图像?但图像和文字有个根本区别:图像是高度结构化的二维网格,而Transformer吃进去的是一维序列。Vision Transformer(ViT) 的答案简单而暴力:把图像“切碎”,然后当成一个“句子”来处理。

ViT的第一步,就是把一张图片(比如224x224)切成一堆固定大小的小方块(比如16x16),每个小方块就相当于一个“词”。把这些小方块展平,通过一个线性投影层,就得到了每个“图像词”的嵌入向量。和NLP一样,我们也要加上位置编码,否则模型就不知道这些小方块原来在图片的哪个位置了。此外,ViT还会在序列开头加一个特殊的[CLS] token,这个token经过Transformer编码器后得到的向量,就代表了整张图片的全局信息,用于最后的分类。

接下来,这一串“图像词”向量(加上[CLS] token)就被送进一个标准的Transformer编码器(就是上一章讲的那个,只有编码器部分,没有解码器)。编码器里的自注意力机制开始工作,让每个图像块都能和所有其他图像块进行“交流”。这意味着,模型在判断左上角的一块是不是猫耳朵时,可以同时参考右下角可能出现的猫尾巴,实现了真正的全局建模。这与CNN层层递进、感受野逐渐扩大的方式形成了鲜明对比。

ViT的成功有一个非常重要的前提:需要在大规模数据集(如ImageNet-21k, JFT-300M)上进行预训练。因为将图像打成碎片,完全抛弃了CNN固有的归纳偏置(如局部性、平移等变性),ViT更像一张白纸,需要海量数据来学习图像的内在结构。一旦预训练完成,在下游任务(如图像分类、目标检测)上进行微调时,它的表现往往能超越同等规模的CNN模型。

下面是一个极简版的ViT模型框架代码,帮助你理解其数据流向:

class SimpleViT(nn.Module):
    def __init__(self, image_size=224, patch_size=16, num_classes=1000, dim=768, depth=12):
        super().__init__()
        num_patches = (image_size // patch_size) ** 2
        patch_dim = 3 * patch_size * patch_size # RGB通道 * 高 * 宽

        # 将图像块投影到嵌入维度
        self.patch_embedding = nn.Linear(patch_dim, dim)
        # 可学习的位置编码
        self.position_embedding = nn.Parameter(torch.randn(1, num_patches + 1, dim))
        # 可学习的 [CLS] token
        self.cls_token = nn.Parameter(torch.randn(1, 1, dim))
        # Transformer编码器
        self.transformer = nn.TransformerEncoder(
            nn.TransformerEncoderLayer(d_model=dim, nhead=8),
            num_layers=depth
        )
        # 分类头
        self.mlp_head = nn.Sequential(
            nn.LayerNorm(dim),
            nn.Linear(dim, num_classes)
        )

    def forward(self, x):
        b, c, h, w = x.shape
        # 1. 切分图像块并展平
        x = x.reshape(b, c, h//16, 16, w//16, 16).permute(0, 2, 4, 1, 3, 5).reshape(b, -1, 3*16*16)
        # 2. 线性投影
        x = self.patch_embedding(x)
        # 3. 添加 [CLS] token 和位置编码
        cls_tokens = self.cls_token.expand(b, -1, -1)
        x = torch.cat((cls_tokens, x), dim=1)
        x += self.position_embedding
        # 4. 通过Transformer编码器
        x = self.transformer(x)
        # 5. 取 [CLS] token 的输出进行分类
        x = x[:, 0]
        return self.mlp_head(x)

4.1 ViT的变体与高效部署:从Swin到MobileViT

原始的ViT把图像当成一维序列,完全忽略了其二维结构,且计算复杂度与图像块数量的平方成正比,处理高分辨率图像时开销巨大。为此,一系列改进模型被提出。

Swin Transformer 引入了“窗口”和“移位窗口”的概念。它先在局部的小窗口内计算自注意力,大大降低了计算量;然后通过层与层之间窗口的移动,让不同窗口的信息得以交互,最终实现了全局建模。这种方式既高效,又尊重了图像的二维局部性,在多项视觉任务上达到了SOTA。

MobileViT 则走了一条CNN和Transformer融合的道路。它先用轻量级CNN提取局部特征,然后在某些阶段用Transformer模块进行全局信息交互,最后再用CNN进行下采样和特征整合。这种混合架构在移动端设备上取得了极佳的精度-速度平衡。

在实际部署ViT类模型时,挑战不小。模型参数量大,对计算和内存要求高。我们可以使用模型剪枝、量化(如将FP32转为INT8)等技术来压缩模型。此外,像NVIDIA的TensorRT、英特尔的OpenVINO等推理优化工具,能针对特定硬件对模型图进行深度优化,显著提升推理速度。我在一个边缘计算项目中将一个ViT模型量化后,推理速度提升了3倍,而精度损失不到0.5%,完全在可接受范围内。

从CNN的局部感知,到RNN/LSTM的序列记忆,再到Transformer的全局注意力,最后到ViT的跨界融合,这条发展脉络清晰地展示了深度学习模型是如何一步步突破自身局限,从不同角度逼近对复杂数据的理解的。掌握这五大模型,你就拥有了解决绝大多数感知和序列问题的核心工具箱。剩下的,就是在具体的项目和数据上,去灵活运用、组合和创新了。

更多推荐