1. 为什么NLP的第一课是神经网络而不是Transformer我在整理NLP课程笔记时翻开第一章第一小节就是神经网络简介配套的实战项目是PyTorch手写数字识别。很多同学的第一反应是学NLP不是应该直接上Transformer、BERT、GPT这些大模型吗为什么珍贵的入门章节要从一个图像分类任务开始这个安排其实非常讲究。我自己的体会是NLP赛道这些年变化太快今天的主流模型可能明年就被替代了但神经网络本身的运行机制——前向传播、反向传播、梯度更新、损失函数——是所有模型的公共底座。RNN、LSTM、Attention、Transformer不管名字多花哨底层都是同一套线性变换加非线性激活的组合拳。如果一上来就啃Transformer注意力机制的Q、K、V矩阵变换会让你一头雾水但如果你先在手写数字识别上把一个简单的全连接网络从零跑通后面很多概念就是顺水推舟的事。这篇笔记适合两类人一类是准备系统学习NLP但还没有深度学习基础的同学另一类是看过不少理论但始终没有亲手跑完一个项目的理论党。我会按课件第一章的编排逻辑把神经网络的核心机制、PyTorch代码实现、手写数字识别完整项目、训练过程中常见的坑逐一拆开讲最后再聊聊这个图像项目到底为NLP埋下了哪些伏笔。顺便说一句手写数字识别被称作深度学习领域的Hello World不是没有原因的。它数据量小、任务直观、训练速度快CPU就能轻松搞定能把你的全部注意力集中在理解神经网络本身而不是被数据预处理和环境配置拖住。我当年第一次跑通这个项目时看着训练损失一点点下降、测试准确率爬到98%那种原来这就是深度学习的通透感至今记忆犹新。2. 神经网络的核心机制从感知机到多层全连接网2.1 一个神经元到底做了什么在展开代码之前先花点时间把神经网络的最小组件搞清楚。一个神经元做的事情用一句话概括就是把输入做加权求和再加上偏置然后过一个非线性函数。数学上长这样y activation(W * x b)其中 x 是输入向量W 是权重矩阵b 是偏置activation 是激活函数。你可以把这个过程类比成一个人做决策每个输入信息都有一定的分量权重有的信息重要就给大权重有的信息干扰大就给小权重甚至负权重加权汇总后还要加上一些个人固有倾向偏置比如天生对某个结果更偏好最后用激活函数决定这个神经元是否被激活、以什么强度响应。如果是二分类任务早期神经网络感知机就是一个神经元加一个阶梯函数加权结果大于零输出1否则输出0。但单个感知机只能处理线性可分问题连异或这种简单模式都搞不定。解决办法就是你肯定听说过的多层。2.2 多层结构为什么必须配非线性激活函数把多个神经元堆叠成多层理论上可以拟合任意复杂的函数但这里有个关键前提层与层之间必须插入非线性激活函数。否则你堆多少层都是白搭——多个线性变换连续作用数学上仍然等价于一个线性变换网络表达能力没有任何提升全连接层再多也只是个加深版的线性回归。所以激活函数是神经网络的灵魂。手写数字识别里最常见的两个选择ReLUmax(0, x)计算快、梯度不衰减是如今默认选项。Sigmoid1 / (1 exp(-x))把输出压缩到0到1之间适合作为最后一层的概率输出但中间层用多了会有梯度消失问题因为它的导数在两端趋近于零。MNIST任务中我用ReLU做隐藏层激活输出层配合交叉熵损失时不需要额外接Sigmoid或SoftmaxPyTorch的CrossEntropyLoss内部已经帮你算好了Softmax。2.3 MNIST任务如何映射到网络结构MNIST手写数字识别任务本身非常简单给定一张28x28像素的灰度图判断它是0到9中的哪个数字。这张图映射到神经网络时是这样拆解的28x28像素展开成一维向量就是784个输入特征。每个特征对应一个像素的灰度值范围0到255。输出层需要10个节点每个节点对应一个数字类别输出值代表该图像是某个数字的置信度。中间隐藏层负责从像素组合中提取特征比如笔画的边缘、弧线的位置、整体形状等信息。输入层784维、隐藏层若干维、输出层10维这就是一个标准的多层全连接网络也叫多层感知机。图像从输入层进入后经过隐藏层的非线性变换最终在输出层产生10个数值再通过Softmax变成10个概率概率最高的那个类别就是预测结果。而训练的过程本质上就是在不断调整7840个以一层256个隐藏单元为例到几万个权重参数让网络输出的概率分布尽可能贴近真实标签。判断贴不贴近靠的是损失函数调整参数靠的是反向传播和梯度下降。3. PyTorch环境准备与数据加载跑通代码前的关键细节3.1 环境搭建与硬件选择PyTorch的安装我这里不展开太多只提醒几个我认为新手最容易忽略的点。第一版本选择一定要区分CPU版和CUDA版。手写数字识别这个项目全连接网络的计算量极小纯CPU跑完全没有问题。我实测下来在普通笔记本的CPU上一个epoch遍历全部60000张训练图大约只需要一两分钟5个epoch十几分钟完事。所以第一遍学习时不必追求GPU省去驱动和CUDA配置的麻烦。等后续学习卷积神经网络做图像任务时再考虑GPU也不迟。第二Python版本建议3.8以上PyTorch使用当前稳定版即可不必追新。很多网上教程跑不通问题往往出在torchvision版本和数据集接口的匹配上而非代码本身。第三用conda建一个独立环境别把PyTorch直接装进系统Python里。我见过太多同学因为环境混乱最后包冲突到无法收拾只好全部重装。3.2 数据加载dataset与dataloader的配合MNIST数据集是公开数据集PyTorch的torchvision库封装好了下载和读取接口。直接上代码import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)这里有两个非常关键的细节。第一个是transform里的ToTensor和Normalize。ToTensor会把PIL图像转成PyTorch张量同时自动把像素值从0到255缩放到0到1之间。这一步本身已经做了归一化。但我又加了一层Normalize让数据以均值0.1307、标准差0.3081做标准化处理。这么做的原因是MNIST数据集的灰度值分布整体偏暗直接以0到1区间喂给网络输入分布不是以0为中心的会导致梯度更新不够稳定、收敛变慢。标准化之后数据分布近似标准正态分布训练更加平稳。这个0.1307和0.3081是官方基于训练集预先算好的均值和标准差属于现成的经验参数直接用即可。第二个细节是DataLoader的两个参数。batch_size64意思是每次从数据集中随机抽64张图喂进网络而不是一张一张地喂也不是一下子喂60000张。逐个样本更新参数噪音太大一次全量更新又计算量过大且容易陷入局部最优。64这个数值是实践中的常见选择在训练速度和梯度稳定性之间取了一个平衡点。shuffleTrue则保证每个epoch训练时样本顺序被打乱避免模型学到样本顺序上的假关联。3.3 先看看数据长什么样写代码之前有个很好的习惯先可视化一批数据看看自己到底在跟什么东西打交道。这里用matplotlib展示几张训练图像及其标签import matplotlib.pyplot as plt images, labels next(iter(train_loader)) fig, axes plt.subplots(2, 5, figsize(12, 6)) for i in range(10): ax axes[i // 5][i % 5] ax.imshow(images[i].squeeze(), cmapgray) ax.set_title(flabel: {labels[i].item()}) ax.axis(off) plt.tight_layout() plt.show()注意images[i].squeeze()这一步。DataLoader输出的图像张量形状是(batch_size, 1, 28, 28)中间那个1是通道数——灰度图只有一个通道。squeeze()把该维度去掉变成(28, 28)二维数组imshow才能正常显示。我见过不少同学在这一步卡壳报错说图像数据维度不对其实就是忘了处理通道维度。另外如果你的数据集下载失败或超时可以手动下载MNIST的四个压缩包放进./data目录对应位置再设置downloadTrue让代码自动识别已存在的文件。国内网络环境下载这个数据集经常抽风手动放文件是最稳妥的办法。4. 手写数字识别网络搭建与训练代码逐段拆解4.1 定义网络结构数据准备好之后开始搭建网络。我之前想过要不要用几行代码快速写一个模型敷衍了事但后来发现把每个组件拆开理解对后续调参和扩展帮助很大。class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 256) self.fc2 nn.Linear(256, 128) self.fc3 nn.Linear(128, 10) def forward(self, x): x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) x self.fc3(x) return x网络结构只有三层全连接784维输入映射到256维隐藏层再映射到128维隐藏层最后输出10维。中间两个隐藏层都接ReLU激活。参数第一层784x256约20万个权重第二层256x128约3.3万个第三层128x10约1280个总共约24万个参数。为什么隐藏层选256和128这里不是严格的理论推导而是工程权衡。层宽越大模型表达能力越强但参数增多容易过拟合且训练变慢层宽太小则可能欠拟合。256到128的递减结构让信息从784维逐步压缩到128维的特征摘要最后映射到10个类别这种逐步降维的设计思路在全连接网络里很常见。forward函数里第一行x.view(x.size(0), -1)把输入张量从(batch_size, 1, 28, 28)展平成(batch_size, 784)。这一步很多新手容易漏掉。如果不做展平nn.Linear(784, 256)会直接报维度不匹配错误因为Linear期望的输入是二维矩阵形如(batch_size, 784)。view的第一个参数x.size(0)是batch大小传进来多少就是多少-1表示自动推算第二个维度28x28x1784所以最终展平成(batch_size, 784)。4.2 损失函数和优化器的选择逻辑模型定义好之后需要指定损失函数和优化器。模块化设计让我可以随意替换不同的配置、直观对比效果这对理解原理很有帮助。criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)损失函数我选了CrossEntropyLoss交叉熵损失。手写数字识别是一个10分类任务交叉熵是分类任务当仁不让的默认选择。它的一个关键特性是内部已经包含Softmax操作所以网络最后一层fc3直接输出原始数值即可不需要手动加Softmax。如果非要手动加一层Softmax再传给CrossEntropyLoss反而会等于在Softmax上再做一次Softmax结果跑偏。从原理上理解交叉熵对于一张标签为7的图像网络输出10个数值后交叉熵会把这个分布与标签7位置为1、其余为0的理想分布做比较。真实类别的预测概率越接近1损失值越小。数学表达式是L -log(p_y)其中p_y是正确类别的Softmax概率。这个设计有一个很好的性质当p_y越接近0梯度越大模型会重点修正错误严重的样本而p_y接近1时梯度趋近于0模型趋于稳定。优化器方面我选了Adam学习率0.001。虽然网上关于SGD加动量训出来的模型更稳、Adam更容易过拟合的讨论一直存在但对一个只有24万参数的全连接网络来说Adam的自动自适应学习率机制能让你免去大量学习率调参的折腾几乎开箱即用地把测试准确率跑到98%以上。如果你想体验传统的训练方式把优化器换成SGDmomentum0.9再跑一遍你会发现它需要更长的时间才能收敛到同等水平这对加深理解很有帮助。4.3 训练循环内部发生了什么训练一个epoch的代码看起来简单但我想把每行背后的逻辑讲清楚因为Deep Learning的很多常见错误恰恰源于对这个循环理解的模糊。def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss 0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss total_loss / len(loader) accuracy correct / total return avg_loss, accuracy逐行拆解optimizer.zero_grad()必须放在每次backward之前。PyTorch的设计是梯度默认累积到张量的.grad属性里不会自动清零。如果不清零上次计算出的梯度就会加到这次的梯度上导致参数更新方向被污染。很多调了好久不收敛的例子根因就是这个。outputs model(images)就是前向传播数据从输入层流到输出层。loss criterion(outputs, labels)计算当前批次预测与真实标签的差距。loss.backward()触发反向传播PyTorch自动计算损失对每个参数的梯度存放在.grad里。optimizer.step()则根据梯度更新所有参数。你可能会问为什么阈值是一批而不是一个样本或整个训练集这就是batch size的设计考量。每个batch内损失是64个样本损失的均值梯度因此更平滑、更代表整体方向。batch太小时梯度噪声大、训练不稳定太大时虽然梯度方向更准确但单个batch消耗内存大、更新次数少收敛反而变慢。64是推荐的起点后续可以尝试32和128做个对比实验。train()函数在计算准确率时torch.max(outputs, 1)返回每行最大值和索引我们取索引作为预测类别然后与真实标签比较统计等于的总数除以总数得到准确率。4.4 评估函数为什么必须用no_grad训练完成后单独写一个评估函数在测试集上检验模型泛化能力。def evaluate(model, loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: 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() accuracy correct / total return accuracy这里有两个习惯性动作新手往往不理解model.eval()和torch.no_grad()。model.eval()是切换模型到评估模式。模型里如果有Dropout或BatchNorm层时它们在训练和评估两种模式下行为完全不同Dropout在训练时随机丢弃一部分神经元在评估时保持全部神经元工作BatchNorm在训练时使用当前批次统计量评估时则使用训练阶段累计的全局统计量。虽然本例的网络里没有这两个层但养成切换模式的习惯很重要后面用CNN或Transformer处理NLP任务时这一行代码会频繁出现。torch.no_grad()则是明确告诉PyTorch这个代码块内不需要计算梯度。评估阶段只做前向传播和预测统计不需要反向传播这样可以节省大量内存和计算时间。纯CPU跑时可能感觉不明显但换成大模型动辄几百兆显存的场景漏掉no_grad会导致显存溢出。4.5 完整训练脚本五分钟跑通全流程把所有组件拼接在一起就是一个完整的可运行脚本。我习惯把device检测放在最前面能用GPU就用GPU没有就退回CPU这样脚本在什么环境下都能跑。import torch import torch.nn as nn import torch.optim as optim import torchvision import torchvision.transforms as transforms from torch.utils.data import DataLoader import matplotlib.pyplot as plt # 1. 设备检测 device torch.device(cuda if torch.cuda.is_available() else cpu) print(using device:, device) # 2. 数据加载 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset torchvision.datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse) # 3. 网络定义 class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 256) self.fc2 nn.Linear(256, 128) self.fc3 nn.Linear(128, 10) def forward(self, x): x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) x self.fc3(x) return x model SimpleNet().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) # 4. 训练与评估函数 def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss 0 correct 0 total 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() avg_loss total_loss / len(loader) accuracy correct / total return avg_loss, accuracy def evaluate(model, loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: 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() return correct / total # 5. 训练多个epoch并记录 epochs 5 train_losses [] train_accs [] for epoch in range(epochs): avg_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer) test_acc evaluate(model, test_loader) train_losses.append(avg_loss) train_accs.append(train_acc) print(fEpoch {epoch 1}/{epochs}, fLoss: {avg_loss:.4f}, Train Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f}) # 6. 保存模型 torch.save(model.state_dict(), mnist_mlp.pth)这段脚本在CPU上跑5个epoch大概十分钟内能完成。训练结束后测试集准确率应该在98%以上。看到这个数字你的第一个深度学习项目就算真正跑通了。5. 训练结果怎么判断损失曲线、准确率与掉过的那些坑5.1 别只看最终准确率要看动态过程训练结束后损失值和准确率只是两个冷冰冰的数字。为了真正理解训练发生了什么我强烈建议把每个epoch的损失和准确率画出来。plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.plot(range(1, epochs 1), train_losses, markero) plt.xlabel(epoch) plt.ylabel(train loss) plt.title(Training Loss Curve) plt.grid(True) plt.subplot(1, 2, 2) plt.plot(range(1, epochs 1), train_accs, markero) plt.xlabel(epoch) plt.ylabel(train accuracy) plt.title(Training Accuracy Curve) plt.grid(True) plt.tight_layout() plt.show()一个健康的训练过程应该呈现损失在每个epoch稳定下降准确率稳步上升并且训练集和测试集的准确率差距不大。第一轮epoch结束训练准确率基本能到95%以上第二轮就能到97%以上后面几轮在98%到99%之间缓慢爬升。这种前期猛涨、后期微调的曲线形态非常典型早期梯度大模型快速调整方向后期接近局部最优更新幅度自然变小。除了看曲线我还会随机挑一批预测错误的测试样例可视化看看模型到底在哪些数字上犯迷糊。MNIST上最常见的错误是4和9、3和8这类形近字之间的混淆。这个信息非常宝贵它说明网络已经学到笔画的边缘结构但可能对某些局部的细微差别不够敏感。对于手写数字任务这已经够用但对后续更复杂的任务这类分析能直接指导你怎么调整网络结构或增加数据。5.2 我实际踩过的几个坑整理课件时我带着几个学生亲手跑了很多遍这个项目把遇到的高频问题汇总了一下这些坑可以说每一个我都亲眼见过。第一个坑忘记展平输入。报错信息往往是一大段RuntimeError说某个维度期望是784但实际是28。解决就是forward里加x.view(x.size(0), -1)或者更清楚地写成x x.reshape(x.size(0), -1)。我建议养成在forward第一行就处理输入形状的习惯后面图像、文本、序列各种数据格式五花八门统一在forward入口处理是最清晰的做法。第二个坑数据没做归一化直接训练。我做过对照实验一个用标准化的数据另一个只做ToTensor不标准化同样训练5个epoch标准化版本的测试准确率能达到98.5%以上而不标准化的版本只能徘徊在92%到94%之间。原因在于MNIST像素值虽然被ToTensor缩放到0-1区间但没有做均值方差标准化输入分布整体偏向0.13附近导致网络第一层梯度更新时存在系统性偏移。0.1307和0.3081这两个数值看起来不起眼却是关键细节。第三个坑学习率设置严重不当。我把学习率从0.001调到0.1再跑损失的下降完全失控甚至直接变成nan。原因是学习率过大参数更新一步跨过了最优点梯度在计算中溢出。调试时如果看到loss变成nan优先检查学习率。而学习率太小的表现则是损失下降极慢——我曾经用0.0001跑过5个epoch后测试准确率只有50%出头让人误以为是数据或模型的问题。第四个坑评估时忘了model.eval()和torch.no_grad()。当前这个网络里没有Dropout和BatchNorm少这两行代码结果也能对。但后面学卷积网络或Transformer时漏掉model.eval()会让Dropout层在评估时继续随机丢弃神经元导致同一批数据每次预测的结果都不一样。我在这个阶段就反复强调是为了让肌肉记忆先形成。第五个坑batch size设得过大。有同学第一次跑就把batch_size设成1024结果一个epoch只有59次参数更新5个epoch后准确率只到90%出头。这倒不是bug而是更新次数太少导致欠拟合。全连接网络显存占用不大但batch size影响的不只是显存更是训练的收敛节奏。5.3 过拟合的苗头与应对手段5个epoch的全连接MNIST模型训练准确率通常在99%以上而测试准确率约98.5%存在一个小的差距。这说明模型已经开始轻微过拟合——太好了这个阶段就能看到过拟合现象反而是学习的好机会。如果你想体验更明显的过拟合把隐藏层从256改成2048网络参数暴增训练准确率很快到99.9%以上而测试准确率可能在98.5%附近徘徊甚至略微下降。做这个对照实验的意义在于从始至终从最简单的网络开始你就能直面深度学习最核心的课题之一——在模型复杂度和泛化能力之间找平衡。应对过拟合的三个经典手段我按使用频率排个序增加训练数据多样性MNIST可以引入小幅度的随机旋转、平移甚至弹性畸变让模型见过更多形态的数字。torchvision的transforms.RandomAffine就够用。降低模型容量把隐藏层维度降下来比如256降到128。对于MNIST这种简单任务128维隐藏层完全够用测试准确率不会下降多少但更稳。正则化与Dropout在隐藏层之间加入nn.Dropout(0.2)训练时随机丢弃20%的神经元强迫网络学到更鲁棒的特征。我建议新手把Dropout的对照实验也做一次加Dropout后训练准确率会略微下降但测试准确率可能保持不变甚至略高。这能非常直观地让你感受到训练和测试准确率的差距才是泛化能力的指标。6. 从手写数字识别到NLP这一章为后面埋了什么伏笔6.1 文本和图像的输入本质上都是张量跑完手写数字识别后一个很自然的问题出现了这个和NLP有什么关系答案是在你的训练循环里数据究竟是什么形态根本不重要。模型只认张量。图像输入是(batch_size, 1, 28, 28)的张量展平成(batch_size, 784)后进入全连接层。而文本输入经过预处理后同样可以变成一个(batch_size, sequence_length, feature_dim)的张量——每个词用一个向量表示整句话变成向量序列。以最简单的文本分类为例假设要做垃圾评论识别文本这部电影太棒了经过分词后每个词查到一个预训练的词向量比如300维因此这句话变成一个(1, 6, 300)的张量。你可以把它平均池化成一个300维的向量再接一层全连接分类器。你会发现这不过是把MNIST的784维输入换成了300维输入后面的训练流程一行都不用动。我第一次意识到这个张量统一论时对深度学习的理解一下清晰了很多。不同任务之间的差异主要体现在数据预处理和网络结构设计上而训练框架、损失计算、反向传播这些核心机制完全是通用的。6.2 词向量把离散词变成连续特征NLP与计算机视觉一个重大差异是图像像素天然是连续数值可以直接喂进网络文本则是离散符号无法直接做加权求和。所以NLP的第一步实验几乎总是如何把词映射成向量。最简单的方案是one-hot向量——词典里有三万个词每个词就是一个30000维的向量只有自己位置是1。但one-hot向量有两个致命问题维度灾难和语义鸿沟——猫和猫咪的向量完全不重叠无法表达相似性。后续课程会引入word2vec、GloVe这些词嵌入技术再往后是BERT这类预训练模型的动态词向量。但本质上词嵌入层nn.Embedding就像是一个可学习的查表操作为词典中每个词维护一个向量训练中不断调整。这与你现在训练的784维像素权重在原理上是相通的——把高维的稀疏输入映射成低维的稠密特征。你现在把全连接网络吃透了后面理解嵌入层就只是用索引去表格里取一行向量的问题而不会觉得是一个全新概念。6.3 Transformer没有你想象的那么神秘回到开头那个问题为什么NLP课程第一章不讲TransformerTransformer的核心组件拆开来看无非就是词嵌入层把词索引变成向量。多头注意力机制本质上是三个线性变换生成Q、K、V加上一组加权平均运算。前馈层每个位置过一个全连接网络结构和你现在写的SimpleNet大同小异只是换了输入输出维度。残差连接、层归一化、位置编码这些是为了解决训练稳定性和序列顺序问题是工程上精巧的设计。换句话说Transformer里你不会看到什么全新的数学工具。线性变换、激活函数、深层网络训练、梯度问题——这些概念的直觉你都在手写数字识别里建立了。区别只在于数据从28x28的图像变成了变长的词序列结构从普通全连接层变成了带自注意力的复杂编解码器。所以学到这里你已经有资格去挑战Transformer了。我用两句话概括这种进阶路径先用最简单的任务跑通最核心的训练流程理解损失、梯度、优化器这些通用机制再去接触先进结构时把每个新组件拆成输入什么张量、经过什么变换、输出什么张量来理解。6.4 给自学者的一条建议动手推导微型网络的梯度最后我想分享一个整理课件时深有体会的建议。有不少同学跑通代码之后觉得反向传播已经理解了因为代码就是一行loss.backward()。但我觉得真正把反向传播刻进脑子里应该做一次微型网络的纸面推导。找一个只有3个输入、1个隐藏层2个神经元、2个输出的微型网络手动计算一次前向传播然后用手写推导出每个参数关于损失的梯度表达式再用梯度下降手动更新一轮权重。这个过程不用写代码拿纸和笔做一遍。大概需要半小时但它带来的收益远大于刷十遍教程。因为只有亲手推导过链式法则在神经网络中的展开你才能记住为什么梯度会逐层相乘、为什么激活函数选ReLU能缓解梯度消失、为什么学习率过大梯度会爆炸。这些直觉在未来的NLP学习里会反复救你。我之前带过一位同学他做完这个推导之后跟我感叹原来backward不是魔法它只是在自动做自己手工做过的运算。从那之后他看任何模型的源码都不再恐惧——因为所有模型都是一堆线性变换和非线性激活再加上它们各自的偏置、参数和设计巧思而已。