1. 神经网络基础到底在讲什么
1.1 从“阶段三模块2”这个定位说起
如果你正在看某个课程体系里的“阶段三 模块 2:神经网络基础”,大概率说明你已经过了编程入门和数据处理那一关,开始正式接触深度学习了。这个模块的定位非常明确:它是从传统机器学习跨入深度学习的门槛。很多人学到这里会卡住,不是因为代码写不出来,而是因为脑子里没有建立起“张量流动”的直觉。
神经网络基础这个模块,核心就四件事:前馈神经网络的结构、激活函数的选择、损失函数的设计、优化器的迭代逻辑。再加上一个贯穿始终的正则化思想。这五个东西串起来,就是一条完整的训练链路:数据进来,经过一层层加权求和与非线性变换,输出预测值,拿预测值和真实值算损失,再通过优化器把损失反传回去更新权重,正则化则在旁边防止模型“学过头”。
我见过太多人学完这个模块之后,能跑通MNIST手写数字识别,但换一个数据集就不知道从哪下手。问题出在他们只记住了model.fit()这个调用,没理解里面到底发生了什么。所以这篇内容我会把这五个核心点拆开讲透,同时补充一些实际训练中才会遇到的坑。
1.2 适合谁来读,读完能获得什么
这篇内容适合三类人:第一类是在校学生,正在跟着课程体系走,需要把“阶段三模块2”的知识点串成线;第二类是转行做算法的工程师,之前可能做后端或数据分析,现在要补深度学习的基础;第三类是做传统机器学习的人,想搞清楚神经网络和SVM、XGBoost到底在思路上有什么不同。
读完你应该能做到:手写一个两层的前馈神经网络,不依赖高级API;能根据任务类型选对激活函数和损失函数;能解释Adam优化器为什么比朴素SGD收敛快;能说清楚L1和L2正则化的区别以及各自适用的场景。这些不是纸上谈兵,是我在实际项目中反复用过的东西。
2. 前馈神经网络:从单个神经元到多层堆叠
2.1 单个神经元其实就是一个加权求和加阈值判断
前馈神经网络(Feedforward Neural Network)是最基础的神经网络结构,也叫全连接网络或MLP(多层感知机)。它的“前馈”两个字指的是信息单向流动,从输入层到隐藏层再到输出层,没有回路。这和RNN循环神经网络形成对比,RNN是有反馈连接的。
单个神经元的计算逻辑非常朴素:把输入向量x的每个分量乘以对应的权重w,求和,加上偏置b,然后过一个激活函数。用公式写就是y = f(w·x + b)。你可以把它理解成一个带权重的投票机制:每个输入特征对最终决策的贡献不一样,权重大的特征说话分量重,偏置则决定了这个神经元“默认倾向于输出什么”。
为什么需要偏置b?如果没有偏置,当所有输入都是0的时候,神经元输出恒为0,这个神经元就废了。偏置给了神经元一个可以平移的决策边界。在实际写代码的时候,nn.Linear(in_features, out_features)这个层就同时包含了权重矩阵和偏置向量,权重初始化通常用Kaiming初始化或Xavier初始化,偏置一般初始化为0。
2.2 隐藏层到底在“隐藏”什么
隐藏层之所以叫“隐藏”,是因为它的输出不直接暴露给外界,只在网络内部传递。一个前馈神经网络至少有一个隐藏层,否则它就退化成线性分类器了。隐藏层的作用是做特征变换:把原始输入空间映射到一个更容易分类或回归的新空间。
举个例子,假设你要做一个二分类任务,数据在二维平面上是一个圆环形状,内圈是一类,外圈是另一类。用线性分类器怎么都分不开。但如果加一个隐藏层,用两个神经元分别学习“到原点的距离”和“角度”,再在输出层做组合,就能轻松分开。这就是隐藏层的价值:它自动学习出了“到原点距离”这个特征,而这个特征是你没有手动提供的。
隐藏层的层数和每层的神经元数量是超参数。层数越多,模型容量越大,能拟合的函数越复杂,但也越容易过拟合。实际项目中,我通常从1到2个隐藏层开始试,每层64到256个神经元。如果数据量很大且特征复杂,再考虑加到3层以上。但要注意,全连接网络的参数量是爆炸式增长的:输入维度1000,隐藏层1000,光这一层就有100万个权重参数。
2.3 前向传播的计算过程与维度变化
前向传播就是数据从输入到输出的计算过程。假设输入是一个batch的数据,形状是(batch_size, in_features)。经过第一层nn.Linear(in_features, hidden_size)之后,形状变成(batch_size, hidden_size)。再经过激活函数,形状不变。再经过第二层nn.Linear(hidden_size, out_features),形状变成(batch_size, out_features)。
这里有一个新手经常搞混的点:nn.Linear的权重矩阵形状是(out_features, in_features),计算的时候是x @ W.T + b。所以如果你手动实现前向传播,代码是这样的:
import torch import torch.nn as nn class TwoLayerNet(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.fc1 = nn.Linear(in_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, out_dim) self.relu = nn.ReLU() def forward(self, x): h = self.relu(self.fc1(x)) out = self.fc2(h) return out这段代码里,fc1把输入从in_dim维映射到hidden_dim维,relu做非线性变换,fc2再把hidden_dim维映射到out_dim维。注意最后一层通常不加激活函数,因为损失函数会处理输出值的范围。
注意:如果你在做多分类任务,最后一层输出维度等于类别数,且不要加Softmax,因为PyTorch的
CrossEntropyLoss内部已经包含了LogSoftmax。加了反而会导致数值不稳定。
3. 激活函数:给网络注入非线性
3.1 为什么没有激活函数,多层网络等于一层
这是神经网络基础里最容易被忽略但最重要的问题。假设你有两层线性变换:第一层y1 = W1·x + b1,第二层y2 = W2·y1 + b2。把第一层代入第二层,得到y2 = W2·(W1·x + b1) + b2 = (W2·W1)·x + (W2·b1 + b2)。这仍然是一个线性变换,等价于单层网络y = W·x + b。所以如果没有激活函数,堆再多层也没有意义,模型表达能力没有本质提升。
激活函数的作用就是打破这种线性组合。它必须是非线性的,而且通常要求可微(因为要反向传播求梯度)。早期用Sigmoid和Tanh,现在主流用ReLU及其变体。选择哪个激活函数,直接影响训练速度和最终效果。
3.2 ReLU为什么成为默认选择
ReLU(Rectified Linear Unit)的定义是f(x) = max(0, x)。它的优点非常明显:计算简单,导数在正区间恒为1,不存在梯度消失问题(至少在正区间)。相比Sigmoid在饱和区导数趋近于0的问题,ReLU让深层网络的训练成为可能。
但ReLU也有缺点:负区间的导数为0,导致一部分神经元可能“死亡”,即永远输出0,权重不再更新。这个问题在学习率设置过大时尤其严重。为了解决这个问题,出现了LeakyReLU、ELU、Swish等变体。LeakyReLU在负区间给一个很小的斜率(如0.01),保证梯度不为0。Swish是f(x) = x * sigmoid(x),在深层网络中表现有时优于ReLU,但计算量稍大。
实际选型建议:默认用ReLU,如果发现训练过程中损失不下降且大量神经元输出为0,换LeakyReLU或ELU试试。Swish在Transformer类模型中更常见,普通MLP用ReLU就够了。
3.3 输出层激活函数怎么选
输出层的激活函数和任务类型强相关,选错了会导致训练完全跑偏。我整理了一个对照表:
| 任务类型 | 输出层激活函数 | 损失函数 | 说明 |
|---|---|---|---|
| 二分类 | Sigmoid | BCELoss | 输出一个0到1之间的概率值 |
| 多分类 | 无(接CrossEntropyLoss) | CrossEntropyLoss | 内部包含Softmax |
| 多标签分类 | Sigmoid | BCEWithLogitsLoss | 每个标签独立判断 |
| 回归 | 无 | MSELoss | 输出实数 |
| 回归(有界) | Sigmoid/Tanh | MSELoss | 需要缩放到目标范围 |
这里有一个常见的坑:做多分类时,有人手动在最后一层加Softmax,然后用NLLLoss。这样也能跑,但数值稳定性不如直接用CrossEntropyLoss。因为CrossEntropyLoss内部用LogSumExp技巧计算,避免了指数溢出。
提示:如果你在做回归任务,且目标值范围是[0, 1],可以在输出层加Sigmoid。但如果目标值范围是[0, 100],加Sigmoid会导致模型很难拟合到接近100的值,因为Sigmoid在接近1时梯度极小。这时候应该不加激活函数,让模型直接输出实数。
4. 损失函数:衡量预测与真实的差距
4.1 损失函数是优化的“指南针”
损失函数(Loss Function)定义了模型预测值和真实值之间的差距。训练的目标就是最小化这个差距。损失函数的选择直接决定了模型学到的“好”是什么样子。比如MSE(均方误差)对大误差惩罚重,适合回归;交叉熵关注概率分布的差异,适合分类。
很多人把损失函数和评价指标混为一谈。评价指标是给人看的,比如准确率、F1分数;损失函数是给优化器用的,必须可微。准确率不可微,所以不能直接拿来做损失。这是两个不同的东西,不要搞混。
4.2 交叉熵损失:分类任务的首选
交叉熵损失来自信息论,衡量两个概率分布之间的距离。对于多分类任务,假设真实标签是one-hot编码,模型输出经过Softmax后的概率分布为p,交叉熵损失为-sum(y_i * log(p_i))。因为y是one-hot的,只有真实类别对应的项非零,所以实际上就是-log(p_true),即真实类别的预测概率的负对数。
这个损失函数的直觉是:如果模型对真实类别的预测概率越接近1,损失越小;越接近0,损失越大,而且惩罚是指数级的。这迫使模型不仅要分类正确,还要“自信地正确”。
在PyTorch中,nn.CrossEntropyLoss接受的是未经过Softmax的logits,内部会自动做LogSoftmax和NLLLoss。所以你的模型最后一层不要加Softmax。如果你手动加了Softmax再用CrossEntropyLoss,相当于做了两次Softmax,结果会不对。
4.3 回归损失:MSE、MAE和Huber怎么选
回归任务的损失函数选择更讲究。MSE(均方误差)对异常值敏感,因为误差被平方了。MAE(平均绝对误差)对异常值鲁棒,但在0点不可导,且梯度恒定,可能导致训练后期不稳定。Huber损失是两者的折中:误差小于阈值delta时用平方,大于时用线性。
实际项目中,如果数据干净、异常值少,用MSE;如果数据有噪声或异常值,用Huber。Huber的delta参数需要调,通常取1.0。我做过一个房价预测的项目,数据里有几套豪宅价格是普通房子的几十倍,用MSE训练时模型被这几套房子带偏了,换成Huber后效果明显改善。
4.4 特殊损失函数:从YOLO到GAN
不同任务有专门设计的损失函数。YOLO系列目标检测用的损失函数包含三部分:边界框回归损失、置信度损失和分类损失。早期YOLO用MSE做边界框回归,后来发现IoU-based损失更好,比如GIoU、DIoU、CIoU。YOLOv8用的就是CIoU损失加DFL(Distribution Focal Loss)。
GAN的损失函数更特殊,它是对抗性的:生成器希望骗过判别器,判别器希望分辨真假。原始GAN用JS散度,训练不稳定。Wasserstein GAN用Wasserstein距离替代JS散度,配合梯度惩罚,训练稳定了很多。InfoNCE损失则用于对比学习,通过最大化正样本对的相似度、最小化负样本对的相似度来学习表示。
这些特殊损失函数背后的数学不简单,但核心思想都是:让模型学到的表示或输出更符合任务需求。你不需要从头推导,但要知道什么任务用什么损失。
5. 优化器:从SGD到Adam的进化
5.1 朴素SGD和它的三个问题
随机梯度下降(SGD)是最基础的优化器:每次用一小批数据计算梯度,然后沿着梯度反方向更新权重。公式是w = w - lr * grad。它简单、内存占用小,但有几个问题:第一,学习率固定,容易在峡谷形损失面上震荡;第二,对所有参数用同一个学习率,稀疏特征更新慢;第三,容易陷入局部最优或鞍点。
为了解决这些问题,出现了动量法(Momentum):引入一个速度变量v,累积历史梯度,v = beta * v + grad,w = w - lr * v。这就像给梯度加了一个“惯性”,在方向一致的维度上加速,在震荡的维度上抵消。beta通常取0.9。
5.2 Adam:自适应学习率的集大成者
Adam(Adaptive Moment Estimation)结合了动量法和RMSProp的思想。它同时维护梯度的一阶矩(均值)和二阶矩(方差)的指数移动平均,然后对每个参数自适应地调整学习率。公式看起来复杂,但核心思想是:梯度大的参数学习率小一点,梯度小的参数学习率大一点。
Adam的默认参数是lr=0.001,betas=(0.9, 0.999),eps=1e-8。在实际项目中,Adam通常是首选,因为它对学习率不敏感,收敛快。但Adam也有缺点:在某些任务上泛化能力不如SGD+Momentum。有研究表明,Adam找到的解往往在损失面上比较“尖锐”,而SGD找到的解更“平坦”,平坦解泛化更好。
我的经验是:快速实验用Adam,追求极致效果时试试SGD+Momentum。如果数据量很大、任务复杂,AdamW(Adam with weight decay)比Adam更好,因为它把权重衰减和自适应学习率解耦了。
5.3 学习率调度:让优化器更聪明
固定学习率往往不是最优的。训练初期需要大学习率快速下降,后期需要小学习率精细调整。常见的学习率调度策略有:StepLR(每隔固定epoch降一次)、CosineAnnealing(余弦退火)、ReduceLROnPlateau(验证损失不降时降)。
我常用的是CosineAnnealingWarmRestarts,它周期性地把学习率从大到小再跳回大,有助于跳出局部最优。配合Adam使用,效果通常比固定学习率好。但要注意,学习率调度的参数需要根据总训练轮数来设,不能随便填。
6. 正则化:防止模型“学过头”
6.1 过拟合的本质和正则化的作用
过拟合是指模型在训练集上表现很好,但在验证集或测试集上表现差。本质是模型记住了训练数据的噪声和细节,而没有学到泛化规律。正则化就是给模型加约束,让它不要那么“自由”。
常见的正则化手段包括:L1/L2正则化、Dropout、Batch Normalization、数据增强、早停。这些方法从不同角度限制模型容量或增加数据多样性。
6.2 L1和L2正则化:稀疏性与平滑性
L2正则化在损失函数里加上权重的平方和:Loss = original_loss + lambda * sum(w^2)。它让权重趋向于小值但不为零,使模型更平滑。L1正则化加上权重的绝对值和:Loss = original_loss + lambda * sum(|w|)。它倾向于让部分权重变为零,产生稀疏解。
为什么L1产生稀疏解?从几何角度看,L1的约束区域是菱形,损失函数的等高线更容易在菱形的顶点处相切,而顶点处某些坐标为零。L2的约束区域是圆形,相切点通常不在坐标轴上。软阈值算子之所以是L1正则化的解,就是因为L1的次梯度在零点附近有一个“死区”,小于阈值的权重直接被压到零。
实际选型:如果要做特征选择,用L1;如果只是防止过拟合,用L2。弹性网(Elastic Net)结合两者,适合特征相关性强的情况。
6.3 Dropout和Batch Normalization的实战要点
Dropout在训练时随机将一部分神经元的输出置零,相当于每次训练一个子网络,推理时用全部神经元但输出乘以保留概率。这迫使网络不依赖某些特定神经元,增强鲁棒性。Dropout率通常设0.2到0.5,输入层可以低一些,隐藏层高一些。
Batch Normalization(BN)对每一层的输入做标准化,使其均值为0、方差为1,然后再做可学习的缩放和平移。BN加速训练、允许更大学习率、有一定正则化效果。但BN对batch size敏感,batch太小时统计量不准。这时候可以用Layer Normalization或Group Normalization。
注意:Dropout和BN同时用时,通常把Dropout放在BN之后。另外,推理阶段一定要调用
model.eval(),否则Dropout仍然生效,BN用的是当前batch的统计量,结果会不对。
7. 实操:从零搭建一个完整训练流程
7.1 数据准备与预处理
以MNIST手写数字识别为例,数据是28x28的灰度图。预处理包括:转成Tensor、归一化到[0,1]或[-1,1]、打乱顺序、分batch。归一化很重要,因为如果输入值范围差异大,梯度下降会走“之”字形。
from torchvision import datasets, transforms from torch.utils.data import DataLoader 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)这里的0.1307和0.3081是MNIST的全局均值和标准差,直接用就行。其他数据集需要自己算。
7.2 模型定义与参数初始化
定义一个两层MLP:输入784维,隐藏层256维,输出10维。权重用Kaiming初始化,偏置初始化为0。
import torch.nn as nn import torch.nn.init as init class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 256) self.fc2 = nn.Linear(256, 10) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.3) init.kaiming_normal_(self.fc1.weight, nonlinearity='relu') init.zeros_(self.fc1.bias) init.kaiming_normal_(self.fc2.weight, nonlinearity='relu') init.zeros_(self.fc2.bias) def forward(self, x): x = x.view(x.size(0), -1) x = self.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return xKaiming初始化适合ReLU激活函数,它让每一层的输出方差保持一致,避免梯度消失或爆炸。
7.3 训练循环与验证
训练循环包括:前向传播、计算损失、反向传播、更新权重。每个epoch结束后在验证集上评估。
import torch.optim as optim model = MLP() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10) for epoch in range(10): model.train() for batch_idx, (data, target) in enumerate(train_loader): optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for data, target in val_loader: output = model(data) pred = output.argmax(dim=1) correct += (pred == target).sum().item() total += target.size(0) print(f'Epoch {epoch}, Val Acc: {correct/total:.4f}')注意optimizer.zero_grad()必须在loss.backward()之前调用,否则梯度会累积。model.eval()和torch.no_grad()在验证时都要用,前者关闭Dropout和BN的训练行为,后者节省内存。
7.4 训练过程中的监控与调参
训练时除了看损失和准确率,还要关注梯度范数。如果梯度范数突然变得很大,说明可能发生了梯度爆炸,需要加梯度裁剪。如果梯度范数一直很小,说明梯度消失,可能需要换激活函数或调整初始化。
total_norm = 0 for p in model.parameters(): if p.grad is not None: total_norm += p.grad.data.norm(2).item() ** 2 total_norm = total_norm ** 0.5如果验证损失开始上升而训练损失还在下降,说明过拟合了,该加正则化或早停。早停就是当验证损失连续几个epoch不下降时停止训练,保存验证损失最低的模型。
8. 常见问题与排查技巧实录
8.1 损失不下降的排查思路
损失不下降是最常见的问题。排查顺序:第一,检查数据标签是否对应正确,有没有打乱;第二,检查学习率是否太大或太小,太大导致震荡,太小导致下降慢;第三,检查损失函数和输出层是否匹配,比如多分类用了MSE;第四,检查梯度是否存在,如果梯度全为0,说明网络断了。
我遇到过一次损失完全不降的情况,排查了半天发现是数据预处理时把图像归一化到了[0,1]但标签没有转成LongTensor,导致CrossEntropyLoss报错被吞掉了。所以一定要看控制台有没有警告信息。
8.2 过拟合与欠拟合的判断和应对
训练损失和验证损失都高,说明欠拟合,需要增加模型容量、减少正则化、训练更久。训练损失低但验证损失高,说明过拟合,需要增加正则化、增加数据、减少模型容量。
有一个经验法则:如果训练准确率和验证准确率差距超过5个百分点,基本可以判定过拟合。这时候先加Dropout和权重衰减,如果还不够,考虑数据增强或收集更多数据。
8.3 梯度消失和梯度爆炸的处理
梯度消失表现为靠近输入层的参数几乎不更新,梯度爆炸表现为损失变成NaN。梯度消失的解决方案:用ReLU激活函数、用Batch Normalization、用残差连接。梯度爆炸的解决方案:梯度裁剪、降低学习率、用权重归一化。
梯度裁剪在PyTorch里一行代码:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)。放在loss.backward()之后、optimizer.step()之前。
8.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 损失为NaN | 学习率太大、梯度爆炸 | 降低学习率、梯度裁剪 |
| 损失不下降 | 学习率太小、数据有问题 | 调大学习率、检查数据 |
| 验证损失上升 | 过拟合 | 加正则化、早停、数据增强 |
| 训练速度慢 | batch太小、模型太大 | 增大batch、简化模型 |
| 准确率震荡 | 学习率太大、batch太小 | 降低学习率、增大batch |
| 某些类别准确率低 | 类别不平衡 | 加权损失、重采样 |
9. 一些容易被忽略的细节
9.1 权重初始化的影响被严重低估
很多人随便用默认初始化,结果训练不动。PyTorch的nn.Linear默认用均匀分布初始化,范围是[-1/sqrt(in_features), 1/sqrt(in_features)]。这个初始化在浅层网络还能用,深层网络就不行了。Kaiming初始化和Xavier初始化是更科学的选择:Xavier适合Sigmoid和Tanh,Kaiming适合ReLU。
9.2 Batch Size不是越大越好
大batch训练稳定,但泛化能力可能下降。小batch引入噪声,有正则化效果,但训练慢。经验值:GPU显存允许的情况下,从64或128开始试。如果用了BatchNorm,batch不要小于16,否则统计量不准。
9.3 随机种子的设置
为了结果可复现,需要固定随机种子:
import torch import numpy as np import random def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意cudnn.deterministic = True会降低训练速度,但保证结果可复现。如果追求速度,可以设为False。
9.4 模型保存与加载
保存模型有两种方式:保存整个模型(torch.save(model, path))和只保存参数(torch.save(model.state_dict(), path))。推荐后者,因为前者依赖具体的类定义,换环境可能加载失败。
torch.save(model.state_dict(), 'model.pth') model = MLP() model.load_state_dict(torch.load('model.pth')) model.eval()加载后一定要调用model.eval(),否则Dropout和BN的行为不对。
10. 从基础到进阶的延伸方向
学完这个模块,你已经掌握了神经网络的核心组件。接下来可以往几个方向延伸:卷积神经网络用于图像任务,核心是卷积层和汇聚层,汇聚层做下采样,减少空间维度;循环神经网络用于序列任务,核心是隐藏状态的传递;Transformer用于长序列和注意力建模,核心是自注意力机制。
如果你对理论感兴趣,可以看邱锡鹏的《神经网络与深度学习》,这本书对BP算法的推导讲得很清楚。如果对PINN(物理信息神经网络)感兴趣,核心思想是把物理方程的残差加入损失函数,让网络在拟合数据的同时满足物理约束。Neural ODE则是把网络层看作连续时间的微分方程,用ODE求解器做前向传播。
这些进阶方向都建立在今天讲的基础之上。激活函数、损失函数、优化器、正则化这四件套,在任何新架构里都会出现,只是形式可能变化。把基础打牢,后面学什么都不会慌。
我个人在实际操作中的体会是:不要急着追新架构,先把MLP在几个标准数据集上训到收敛,把损失曲线、梯度范数、权重分布都看一遍。这个过程比看十篇论文都管用。踩过几次坑之后,你对“模型为什么不work”会有直觉,这种直觉才是真正值钱的东西。