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 多层结构为什么必须配非线性激活函数
把多个神经元堆叠成多层,理论上可以拟合任意复杂的函数,但这里有个关键前提:层与层之间必须插入非线性激活函数。否则你堆多少层都是白搭——多个线性变换连续作用,数学上仍然等价于一个线性变换,网络表达能力没有任何提升,全连接层再多也只是个"加深版的线性回归"。
所以激活函数是神经网络的灵魂。手写数字识别里最常见的两个选择:
- ReLU:max(0, x),计算快、梯度不衰减,是如今默认选项。
- Sigmoid:1 / (1 + exp(-x)),把输出压缩到0到1之间,适合作为最后一层的概率输出,但中间层用多了会有梯度消失问题,因为它的导数在两端趋近于零。
MNIST任务中,我用ReLU做隐藏层激活,输出层配合交叉熵损失时不需要额外接Sigmoid或Softmax,PyTorch的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', train=True, transform=transform, download=True ) test_dataset = torchvision.datasets.MNIST( root='./data', train=False, transform=transform, download=True ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)这里有两个非常关键的细节。
第一个是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_size=64,意思是每次从数据集中随机抽64张图喂进网络,而不是一张一张地喂,也不是一下子喂60000张。逐个样本更新参数噪音太大,一次全量更新又计算量过大且容易陷入局部最优。64这个数值是实践中的常见选择,在训练速度和梯度稳定性之间取了一个平衡点。shuffle=True则保证每个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(), cmap='gray') ax.set_title(f'label: {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目录对应位置,再设置download=True让代码自动识别已存在的文件。国内网络环境下载这个数据集经常抽风,手动放文件是最稳妥的办法。
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表示自动推算第二个维度,28x28x1=784,所以最终展平成(batch_size, 784)。
4.2 损失函数和优化器的选择逻辑
模型定义好之后,需要指定损失函数和优化器。模块化设计让我可以随意替换不同的配置、直观对比效果,这对理解原理很有帮助。
criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.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%以上。如果你想体验传统的训练方式,把优化器换成SGD(momentum=0.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', train=True, transform=transform, download=True ) test_dataset = torchvision.datasets.MNIST( root='./data', train=False, transform=transform, download=True ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False) # 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(), lr=0.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(f'Epoch {epoch + 1}/{epochs}, ' f'Loss: {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, marker='o') 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, marker='o') 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课程第一章不讲Transformer?
Transformer的核心组件拆开来看,无非就是:
- 词嵌入层:把词索引变成向量。
- 多头注意力机制:本质上是三个线性变换(生成Q、K、V)加上一组加权平均运算。
- 前馈层:每个位置过一个全连接网络,结构和你现在写的SimpleNet大同小异,只是换了输入输出维度。
- 残差连接、层归一化、位置编码:这些是为了解决训练稳定性和序列顺序问题,是工程上精巧的设计。
换句话说,Transformer里你不会看到什么全新的数学工具。线性变换、激活函数、深层网络训练、梯度问题——这些概念的直觉你都在手写数字识别里建立了。区别只在于数据从28x28的图像变成了变长的词序列,结构从普通全连接层变成了带自注意力的复杂编解码器。
所以学到这里,你已经有资格去挑战Transformer了。我用两句话概括这种进阶路径:先用最简单的任务跑通最核心的训练流程,理解损失、梯度、优化器这些通用机制;再去接触先进结构时,把每个新组件拆成"输入什么张量、经过什么变换、输出什么张量"来理解。
6.4 给自学者的一条建议:动手推导微型网络的梯度
最后我想分享一个整理课件时深有体会的建议。有不少同学跑通代码之后,觉得"反向传播"已经理解了,因为代码就是一行loss.backward()。但我觉得,真正把反向传播刻进脑子里,应该做一次"微型网络的纸面推导"。
找一个只有3个输入、1个隐藏层(2个神经元)、2个输出的微型网络,手动计算一次前向传播,然后用手写推导出每个参数关于损失的梯度表达式,再用梯度下降手动更新一轮权重。这个过程不用写代码,拿纸和笔做一遍。大概需要半小时,但它带来的收益远大于刷十遍教程。因为只有亲手推导过链式法则在神经网络中的展开,你才能记住为什么梯度会逐层相乘、为什么激活函数选ReLU能缓解梯度消失、为什么学习率过大梯度会爆炸。这些直觉在未来的NLP学习里会反复救你。
我之前带过一位同学,他做完这个推导之后跟我感叹:原来backward不是魔法,它只是在自动做自己手工做过的运算。从那之后,他看任何模型的源码都不再恐惧——因为所有模型都是"一堆线性变换和非线性激活",再加上它们各自的偏置、参数和设计巧思而已。