上周调一个小型人脸识别网络,又撞见了RuntimeError: mat1 and mat2 shapes cannot be multiplied。这种报错做深度学习这几年我见了不下几十次,每次根源都一样:对张量形状的变化轨迹心里没数。这篇是Python 神经网络与深度学习实用指南的第三篇。前两篇我们把环境、基础概念和第一个训练脚本跑通了,这篇换个思路——从张量的形状与维度变化入手,把前向传播、反向传播、卷积神经网络(CNN)这些看似高深的东西,全部落到代码级的具体细节上。
适合已经能跑通简单模型、但总觉得"原理懂了却写不出代码"的读者,也适合那些训练效果忽好忽坏、想弄清背后原因的人。说白了,这篇的目标就是帮你从"会调库"变成"真懂网络"。
1. 为什么很多人的网络训练效果差:先从张量形状说起
1.1 一个报错背后隐藏的四个维度
新手最常问的问题是:图像到底是几维的数据?答案其实不复杂——对 PyTorch 和 TensorFlow 这类框架来说,一张彩色图片在进入神经网络前,默认是四维张量,形状写作(batch, channel, height, width)。
以 64x64 的 RGB 图片为例:
batch:一次喂进去多少张图,比如 32;channel:颜色通道数,RGB 就是 3,灰度图是 1;height、width:图像的高和宽,这里都是 64。
所以一批数据张量的形状就是(32, 3, 64, 64)。我一直建议初学者先记住这个格式,因为后面所有报错,十有八九都是这四个数在某个环节对不上导致的。
1.2 展平操作最容易被忽略的坑
全连接层(Fully Connected Layer,简称 FC)是神经网络里最古老的组成部分,它要求输入是一个二维矩阵(batch, feature),也就是说图片必须先从三维(3, 64, 64)展平成一维12288(3×64×64),变成一个(batch, 12288)的矩阵。
问题来了:很多人展平的时候,只算了64*64=4096,忘了把通道数乘进去。这就会导致输入维度和权重矩阵第一维对不上,直接抛维度不匹配异常。
提示:遇到
mat1 and mat2 shapes cannot be multiplied这类报错,第一件事不是去搜代码,而是把所有张量当前的 shape 都打印出来,逐个环节检查。
我经常在代码里临时加一行print(x.shape)来定位问题,调完再删掉。这不是笨办法,而是最快速的排查路径。
2. 用 numpy 手写一个三层前馈网络:从图像到高维向量
2.1 为什么建议你手写一次前向传播
很多人一上来就用nn.Sequential堆网络,看着是挺方便,但对网络内部的数据流动完全是黑盒。我强烈建议至少手写一次前向传播,哪怕是很小的网络,因为这会逼着你搞清楚每一层的输入输出到底是什么形状。
这里拿 MNIST 手写数字识别举例。每张图是 28x28 的灰度图,展平后是 784 维向量。我构建一个三层网络:
- 输入层:784 个神经元;
- 隐藏层 1:128 个神经元,激活函数用 ReLU;
- 隐藏层 2:64 个神经元,激活函数用 ReLU;
- 输出层:10 个神经元,对应 0~9 十个类别。
2.2 矩阵乘法里的形状习惯:行向量还是列向量
写代码之前必须统一一个习惯:权重矩阵到底怎么放。我推荐用W的形状为(当前层神经元数, 上一层神经元数),这样z = W @ x + b算出来的z形状是(当前层神经元数, batch_size)。
不过 PyTorch 的nn.Linear(in_features, out_features)内部权重形状是(out_features, in_features),x形状是(batch, in_features),计算方式是x @ W.T + b。两种写法都行,但自己手写时一定要固定一种,别混着来,否则维度两天就对不上了。
2.3 前向传播的完整实现
用 numpy 实现一个简化版的 MNIST 前向传播,代码如下:
import numpy as np def relu(x): return np.maximum(0, x) class ThreeLayerNet: def __init__(self, in_dim=784, hidden1_dim=128, hidden2_dim=64, out_dim=10): # 初始化权重和偏置 # 这里用 He 初始化,适合 ReLU 激活函数 self.W1 = np.random.randn(hidden1_dim, in_dim) * np.sqrt(2.0 / in_dim) self.b1 = np.zeros((hidden1_dim, 1)) self.W2 = np.random.randn(hidden2_dim, hidden1_dim) * np.sqrt(2.0 / hidden1_dim) self.b2 = np.zeros((hidden2_dim, 1)) self.W3 = np.random.randn(out_dim, hidden2_dim) * np.sqrt(2.0 / hidden2_dim) self.b3 = np.zeros((out_dim, 1)) def forward(self, x): # x: (in_dim, batch_size) self.z1 = self.W1 @ x + self.b1 self.a1 = relu(self.z1) self.z2 = self.W2 @ self.a1 + self.b2 self.a2 = relu(self.z2) self.z3 = self.W3 @ self.a2 + self.b3 # 输出层先不接 softmax,后面算损失时用 return self.z3 # 模拟一批 8 张 28x28 的图片 x_batch = np.random.randn(784, 8) net = ThreeLayerNet() out = net.forward(x_batch) print(out.shape) # (10, 8),每列是一张图的分类得分2.4 "从图像到高维向量"到底是什么意思
运行完上面的代码你会看到,网络的输出形状是(10, 8):8 是批量大小,10 是对应 10 个类别的得分。这就是热词里反复出现的"人脸识别图像进入神经网络到输出高维度向量的过程"。
人脸识别里的做法其实一样,只是输出维度不再是 10,而是故意设计成一个高维向量,比如 128 维、512 维。训练目标是让同一个人的两张人脸照片映射到高维空间中距离很近,不同人的照片距离很远。这个思路叫度量学习,核心就在于神经网络的最后一层特征向量,而不是分类结果本身。
3. 反向传播不是黑魔法:两层网络逐步手算与代码对照
3.1 链式法则是整个深度学习的地基
反向传播的本质是链式法则。损失函数对某个权重求梯度,需要从损失函数出发,一层一层往回乘。很多人觉得它难,是因为把符号推导搞复杂了;实际上只要拿一个具体的小网络,手算一遍就通了。
我先用最简洁的例子说明。设损失函数是均方误差:
L = 0.5 * (y_pred - y_true)^2
最后一层是线性输出,激活函数是 sigmoid,隐藏层也是 sigmoid。那么损失对 W2 的梯度是:
dL/dW2 = (y_pred - y_true) * sigmoid_derivative(z2) * a1
这里y_pred - y_true是误差项,sigmoid_derivative是激活函数的导数,a1是上一层输出。所有反向传播代码,本质上都是在这种链式乘法里打转。
3.2 一个具体数字的完整传播过程
我设计一个极小的网络:输入层 2 个神经元,隐藏层 2 个神经元,输出层 1 个神经元。权重如下:
- W1 = [[0.5, -0.2], [0.3, 0.8]]
- W2 = [[0.6, -0.4]]
- 偏置都为 0
- 输入 x = [0.5, 0.1]
- 真实值 y_true = [1.0]
先做前向传播:
- z1 = W1 @ x = [0.25, 0.23]
- a1 = sigmoid(z1) = [0.5622, 0.5573]
- z2 = W2 @ a1 = 0.3374 - 0.2229 = 0.1145
- y_pred = sigmoid(z2) = 0.5286
- L = 0.5 * (0.5286 - 1)^2 = 0.1111
再做反向传播:
- dL/dz2 = (y_pred - y_true) * sigmoid_derivative(z2) = (0.5286-1) * 0.5286*(1-0.5286) ≈ -0.1177
- dL/dW2 = dL/dz2 * a1 = [-0.0662, -0.0656]
- dL/dz1 = W2.T * sigmoid_derivative(z1) * dL/dz2,得到关于两个神经元的梯度
- 逐项更新权重
完整的代码对照可以放进调试器里逐行验算。建议你用 pytest 写一个断言,验证自己手算的结果和代码结果一致,这个习惯对理解深刻程度有天壤之别。
3.3 梯度消失的早期信号
上面用了 sigmoid,它有个致命问题:导数最大值只有 0.25。多层反向传播时,每一层都要乘以一个不超过 0.25 的数,连乘三层就衰减到 0.0156,五层之后就接近 0 了。这就是梯度消失——浅层权重几乎得不到更新。
这解释了为什么现代网络都用 ReLU,ReLU 的导数在正半轴是 1,连乘不会衰减。我第一次意识到这个问题时非常震撼,原来一个激活函数的选择,就能决定深度网络能不能训练起来。
4. 从玩具模型到真实项目:用 PyTorch 把流程工程化
4.1 环境准备:别再被 Python 版本坑了
PyTorch 版本和 Python 版本有对应关系,装错版本会出现DLL load failed或者No module named torch。我用 conda 管理的习惯是:
conda create -n dl python=3.10 conda activate dl pip install numpy matplotlib # 根据你的 CUDA 版本安装对应 PyTorch,去官网选对应命令 pip install torch torchvision这里有个常见问题:很多人装 numpy 装失败,多半是 pip 和 conda 混用导致的依赖冲突。我的原则是:conda负责创建环境,pip负责装 Python 包,但不在同一个环境里反复交替使用两种安装方式。
4.2 DataLoader 的两个关键参数:batch_size 与 shuffle
真实项目里数据量大,不能一次性把全部数据喂进网络。PyTorch 的DataLoader就是干这个的:
from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root="./data", train=True, download=True, transform=transform ) train_loader = DataLoader( train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, )shuffle=True在训练集里很重要,它会打乱样本顺序,避免模型学到样本排列的规律。num_workers是并行加载数据的进程数,Windows 上有时得设为 0,否则会因为多进程启动问题报错。
4.3 训练循环的标准写法与断点续训
一个标准的训练循环其实就五件事:前向计算、算损失、梯度清零、反向传播、参数更新。
import torch import torch.nn as nn import torch.optim as optim model = Net() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(10): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}") # 每个 epoch 存一次 checkpoint,便于断点续训 torch.save({ "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "epoch": epoch, "loss": avg_loss, }, f"checkpoint_epoch_{epoch+1}.pth")我踩过最大的坑是忘了加optimizer.zero_grad()。PyTorch 的梯度是累积的,如果不每轮清零,梯度会一直叠加,导致 loss 忽高忽低,训练完全乱套。新手看到 loss 震荡第一反应是调学习率,但我第一个排查项永远是梯度有没有清零。
断点续训的写法也很关键。重启训练时,要同时加载模型参数和优化器状态,还要把 epoch 数接上,否则学习率调度器会从零开始算,影响训练节奏:
checkpoint = torch.load("checkpoint_epoch_5.pth") model.load_state_dict(checkpoint["model_state_dict"]) optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) start_epoch = checkpoint["epoch"] + 15. 全连接网络的天花板:为什么人脸识别必须上 CNN
5.1 全连接层处理图像的两个致命问题
全连接层处理图像有两个硬伤。第一是参数爆炸:一张 224x224 的 RGB 图片展平后是 15 万维,第一层隐藏层如果有 1024 个神经元,光这一层的参数量就是 1.5 亿,单层就能把显卡显存吃爆。第二是丢失空间结构:展平之后,图片里"左上角是眼睛""右下角是嘴巴"这种空间关系全没了,模型需要重新从零学习位置信息,样本效率极低。
5.2 卷积层、池化层、特征图的维度变化全过程
卷积的思路是:用一个小的卷积核(比如 3x3)滑过整张图片,每个位置做一次局部加权求和,得到一张新的特征图。核很小,参数少;局部操作保留了空间关系;同一张特征图的所有位置共享同一个核,参数再次大幅减少。
特征图尺寸的计算公式是:
output_size = (input_size - kernel_size + 2 * padding) / stride + 1
拿 MNIST 举例,输入是 28x28 的单通道图,完整走一遍:
- 卷积层 Conv1:kernel=3,padding=0,stride=1。尺寸计算:(28-3)/1+1=26。输出特征图 26x26,通道数从 1 变成 16;
- 池化层 Pool1:2x2 最大池化,尺寸减半,变成 13x13;
- 卷积层 Conv2:kernel=3,输出尺寸 (13-3)/1+1=11,通道数从 16 变成 32,特征图变为 32 张 11x11;
- 池化层 Pool2:再减半,变成 32 张 5x5;
- 展平:32×5×5=800,变成 800 维向量;
- 全连接层:800 维映射到 10 个类别。
这一番操作下来,参数量比起直接用全连接网络少了几个数量级,而准确率反而明显提升。
5.3 一个人人都能敲出来的 CNN 实现
用 PyTorch 实现上面的结构,代码其实很短:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Linear(32 * 5 * 5, 10) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x训练 MNIST 时,这种简单 CNN 用 CPU 跑几个 epoch 就能到 98% 以上的准确率。你把它和全连接网络对比一下,就能直观感受到卷积在图像任务上的碾压优势。
5.4 显存占用的粗略估算方法
做项目时还得会估算模型占用多大的显存。模型显存大致由三块组成:参数本身、梯度、优化器状态,外加中间激活值。粗略估算时,可以用torchinfo这个库:
from torchinfo import summary model = SimpleCNN() summary(model, input_size=(64, 1, 28, 28))它会直接给出参数量和每层的输出尺寸。实际训练时,显存占用主要被中间变量(激活值)吃掉,所以 batch size 设得太大、图片分辨率太高,显存最容易爆。爆显存第一招是减小 batch size,第二招是降低分辨率,而不是随便换更大的显卡。
6. 训练中那些看不见的坑:正则化、学习率与多任务 loss
6.1 L2 正则化的 PyTorch 代码与权重衰减的关系
很多人不知道,PyTorch 优化器里的weight_decay参数就是 L2 正则化。它做的事情是:在损失函数里加一项所有权重的平方和,抑制权重过大,从而降低过拟合。
用代码看更清楚。手动实现 L2 的损失是:
l2_lambda = 1e-4 l2_norm = sum(p.pow(2).sum() for p in model.parameters()) loss = cross_entropy_loss + l2_lambda * l2_norm对应到优化器上,就是一句:
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)两者效果基本等价,用内置的weight_decay就好。这里有个细节:weight_decay对偏置(bias)一般不生效,这对偏置来说其实是合理的,因为偏置的主要作用是平移,不让它参与正则化能更稳定。
6.2 学习率:过大震荡、过小绝望
学习率是深度学习里最影响训练结果的一个超参数。设置太大,loss 会剧烈震荡甚至发散;设置太小,训练半天 loss 纹丝不动。
我的实用调参顺序是这样的:
- 先用固定学习率 0.001 跑 5 个 epoch,观察 loss 曲线;
- 如果 loss 震荡,除以 10,比如降到 0.0001;
- 如果 loss 降得很慢,乘以 3 或 5,比如升到 0.003;
- 训练后期切换成余弦退火或按步长衰减。
PyTorch 自带的调度器很好用,比如每 10 个 epoch 学习率减半:
from torch.optim.lr_scheduler import StepLR scheduler = StepLR(optimizer, step_size=10, gamma=0.5) # 每个 epoch 结束后调用 scheduler.step()6.3 多任务学习时如何调整多个 loss 的比例
多任务学习里的经典问题:模型同时做分类和回归,两个 loss 都在反向传播,比例怎么调?无脑都是 1:1 往往导致一个任务压过另一个。
我试过两种可行方案,比较推荐第二种。
第一种是手动加权:先用固定权重跑,观察两个 loss 的数量级差异。如果分类 loss 在 0.5 左右、回归 loss 在 2 左右,直接对回归 loss 乘以一个 0.1 到 0.3 的系数,把两者拉到相近量级。
第二种是不确定性加权:让网络自己学习每个任务的不确定性,据此自动调整权重,PyTorch 实现思路如下:
log_var_classification = torch.nn.Parameter(torch.tensor(0.0)) log_var_regression = torch.nn.Parameter(torch.tensor(0.0)) # 训练循环中 loss = 0.5 * loss_cls / torch.exp(log_var_classification) \ + 0.5 * loss_reg / torch.exp(log_var_regression) \ + 0.5 * log_var_classification + 0.5 * log_var_regression这套来自多任务深度学习的经典做法,核心逻辑是:一个任务的噪声大(不确定性高),就让它在总 loss 里占少一点。如果你还不太放心权重的初始值,建议先锁定前 10 个 epoch 的权重,后面再放开让参数自适应。
7. 从这篇之后往哪走:序列模型、图数据与边缘推理
7.1 文本和时序数据:LSTM 的输入格式与使用要点
如果你的数据不是图像,而是股价、录音、文本这类序列,LSTM(长短期记忆网络)是经典入门选择。LSTM 的输入是三维张量,形状为(sequence_length, batch_size, input_size),注意 PyTorch 的默认布局是seq_len放最前面,很多人在这里反复踩坑。
一个简单的用法:
import torch.nn as nn lstm = nn.LSTM(input_size=10, hidden_size=64, num_layers=2, batch_first=True) # 此时输入形状为 (batch, seq_len, input_size) x = torch.randn(32, 20, 10) out, (h_n, c_n) = lstm(x) # out 形状: (batch, seq_len, hidden_size) # h_n 形状: (num_layers, batch, hidden_size)LSTM 的门控机制让它能记住长期信息,训练时比普通 RNN 稳定得多。不过要注意梯度裁剪,梯度爆炸在序列模型里很常见:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)7.2 人脸表情识别与图神经网络:为什么图上的卷积不同
普通 CNN 处理的是规则网格数据,但有些场景的数据天然是图结构,比如社交网络关系、分子结构,甚至人脸关键点构成的拓扑图。人脸表情识别里,把脸部关键点建模成图,用图卷积网络(GCN)能捕捉关键点之间的空间依赖关系,效果比单纯用 CNN 更细腻。
图神经网络的思路是:每个节点通过聚合邻居节点的特征来更新自己的表示,这其实和 CNN 的局部感受野概念很像,只不过邻居关系由图的边结构定义,不依赖规则网格。
PyTorch 生态里常用torch_geometric实现 GCN,这类高级方向不建议新手一上来就冲,先把 CNN 和 LSTM 的基本功练扎实,再往图神经网络爬。
7.3 边缘计算与推理优化:从训练好的模型到真正落地
模型训练好了只是第一步,真正落地部署才是项目的终点。边缘设备算力有限,模型要经过一系列优化才能流畅跑起来。
我现在最常做的优化链路是:训练好的 PyTorch 模型导出成 ONNX,再做量化。量化的核心是把 FP32 浮点数变成 INT8 整数,参数量直接缩小四倍,推理速度显著提升,代价是精度略微下降。
量化后的模型在小计算设备上可以跑到几十毫秒每帧,这个速度对人脸识别这样的实时应用来说勉强够用。做这一块时建议先用自带脚本把模型导出来,测量原始推理耗时和量化后的耗时对比,再根据业务场景决定要不要用稀疏化或者知识蒸馏做进一步压缩。
从手写前向传播到工程化训练,再到模型部署,这条路每一步都需要把维度变化和参数流动搞透。这篇涉及的代码量不算大,但每一行都值得亲手敲一遍,尤其是反向传播的手算对照和 CNN 的维度推算。把这些基础扎稳了,后面不管碰强化学习、生成模型还是多智能体系统,都不会再被底层的数据流搞到崩溃。