动手学DL
参考资料
线性神经网络
- 矢量化加速
在训练模型时,若希望能够 同时处理整个小批量的样本
需要对计算进行矢量化,从而利用 线性代数库
而不是在 Python 中编写开销高昂的 for 循环
|
|
- 正态分布(normal distribution)
也称为高斯分布(Gaussian distribution)
最早由德国数学家高斯(Gauss)应用于天文学研究
|
|
-
均方误差 损失函数(简称均方损失)
$\frac{1}{2}(\hat{y}-y)^{2}$
可以用于线性回归
我们假设了观测中包含 噪声,且噪声 服从正态分布
可以写出通过给定的 x 观测到特定 y 的似然(likelihood)
根据极大似然估计法,参数 w 和 b 的最优值是 使整个数据集的似然达到最大的值,根据极大似然估计法选择的估计量称为极大似然估计量
问题可以通过最大化似然对数来简化。但由于历史原因,优化通常是说最小化而不是最大化,所以可以改为 最小化负对数似然
在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计 -
读取数据集
每次抽取一小批量样本,并使用它们来更新我们的模型,叫做 batch通常,我们利用 GPU 并行运算 的优势,处理合理大小的“小批量”
每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算
GPU 可以使得 处理几百个样本所花费的时间 和 处理一个样本时 差不多 -
定义模型
-
初始化模型参数
首先初始化参数 –> 更新参数 –> 直到参数对应的模型能较好地拟合数据1 2 3 4 5 6# 随机化初始参数 # 服从均值为 0,方差为 0.01 的,形状为两行一列的正态分布的随机数 # 截距 b 初始化为张量 0 # requires_grad=True 方便在反向传播中计算梯度 w = torch.normal(0, 0.01, size=(2,1), requires_grad=True) b = torch.zeros(1, requires_grad=True)每次更新都需要计算 损失函数关于模型参数的梯度
有了这个梯度,我们就可以 向减小损失的方向 去更新每个参数 -
定义损失函数
-
定义优化算法
1 2 3 4 5 6 7 8 9 10def sgd(params, lr, batch_size): """ 小批量随机梯度下降 """ with torch.no_grad(): for param in params: # 更新参数 param -= lr * param.grad / batch_size # 在默认情况下,PyTorch会累积梯度,我们需要清除之前的值 param.grad.zero_() -
训练
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18lr = # 学习率 num_epochs = # 迭代周期个数 net = # 神经网络线性模型 loss = # 损失函数 for epoch in range(num_epochs): for X, y in data_iter(batch_size, features, labels): l = loss(net(X, w, b), y) # 因为 l 形状是(batch_size, 1),而不是一个标量 # 所以 l 中的所有元素被加到一起,并以此计算关于[w, b]的梯度 l.sum().backward() # 更新参数 sgd([w, b], lr, batch_size) with torch.no_grad(): train_l = loss(net(features, w, b), labels) print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')参数总的更新次数:batch 的个数 * 迭代周期个数
-
softmax 回归
回归可以用于预测,关注 多少 的问题
分类问题则是关注 哪一类 的问题
机器学习中通常使用的是软性类别的模型,可以 得到属于每一类的概率对于任何具有 $d$ 个输入和 $q$ 个输出的全连接层,参数开销为 $O(dq)$ ,这个数字在实践中可能高得令人望而却步
1 2 3 4 5# 有时也称为配分函数(其对数称为对数-配分函数) def softmax(X): X_exp = torch.exp(X) partition = X_exp.sum(1, keepdim=True) return X_exp / partition1 2 3 4 5 6 7 8 9 10 11 12y = torch.tensor([0, 2]) y_hat = torch.tensor([ [0.1, 0.3, 0.6], [0.3, 0.2, 0.5] ]) y_hat[[0, 1], y] # 选出 y_hat 第一个样本 [0.1, 0.3, 0.6] 的第 1 类的概率 0.1;第二个样本 [0.3, 0.2, 0.5] 的第 3 类的概率 0.5 # 定义交叉熵损失函数 def cross_entropy(y_hat, y): return -torch.log(y_hat[range(len(y_hat)), y]) -
一些代码操作
1 2 3 4 5 6 7 8 9 10 11# 生成均值为 0,方差为 1 的正态分布随机数 # 形状为 num_examples*len(w) X = torch.normal(0, 1, (num_examples, len(w))) # 计算 y=Xw+b y = torch.matmul(X, w) + b # 生成均值为 0,方差为 0.01 的正态分布随机数,形状同 y # 相当于添加了噪声 y += torch.normal(0, 0.01, y.shape) y.reshape((-1, 1))1 2 3 4 5 6 7 8 9 10# 数据迭代器 # 注意随机读取和 yield 的使用 def data_iter(batch_size, feature, label, ...): for i in range(...): ind = 随机读取 yield feature[ind], label[ind] for X, y in data_iter(...): ...使用 PyTorch 的高级 API 更简洁地实现模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22# data 模块提供了数据处理工具 # nn 模块定义了大量的神经网络层和常见损失函数 from torch.utils import data from torch import nn def load_array(data_arrays, batch_size, is_train=True): """构造一个 PyTorch数据迭代器""" dataset = data.TensorDataset(*data_arrays) return data.DataLoader(dataset, batch_size, shuffle=is_train) # shuffle=True 会让数据迭代器对象在每个迭代周期内打乱数据的顺序 batch_size = 10 data_iter = load_array((features, labels), batch_size) #调用迭代器 net = nn.Sequential(nn.Linear(2, 1)) # 以 _结尾的方法将参数替换,从而初始化参数 net[0].weight.data.normal_(0, 0.01) net[0].bias.data.fill_(0) loss = nn.MSELoss() trainer = torch.optim.SGD(net.parameters(), lr=0.03) -
问题
-
关于 torch.no_grad()
torch.no_grad() 是一个上下文管理器,用于禁用梯度计算
在 with torch.no_grad() 块内,所有计算不会记录在计算图中,PyTorch 不会跟踪这些操作的梯度
它的主要作用是减少内存消耗并加速计算,适用于那些不需要反向传播/梯度计算的场景
例如在模型推理(预测)时,你只需要前向计算得到输出,不需要计算梯度1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16import torch # 模拟一个简单的模型 model = torch.nn.Linear(10, 2) x = torch.randn(5, 10) y_true = torch.randint(0, 2, (5,)) # 训练时需要梯度 outputs = model(x) loss = torch.nn.functional.cross_entropy(outputs, y_true) loss.backward() # 推理时不需要梯度 with torch.no_grad(): test_output = model(x) print("Predictions:", test_output.argmax(dim=1)) -
随机梯度下载的随机体现在哪里
每次迭代时随机采样一个小批量计算梯度,相较于批量梯度下降,SGD 的计算速度更快,因为并没有使用全部数据计算梯度
并且随机选择的样本,计算得出的梯度可能存在噪声,可能帮助模型跳出局部最优 -
均方损失函数 $\frac{1}{2}(\hat{y}-y)^{2}$ 中前面的 1/2 有什么用
对均方损失函数求导时,平方项会产生系数 2,而 1/2 就是用于抵消这个系数简化计算
这样不影响优化结果,只是缩放损失值,不影响模型收敛性 -
这句话是什么原理?为什么 相加 求损失函数关于参数的梯度?
1l = loss(net(X, w, b), y),因为 l形状是(batch_size, 1),而不是一个标量,所以 l中的所有元素被加到一起,并以此计算关于[w, b]的梯度。PyTorch 的自动求导机制要求标量损失(loss 必须是一个标量)
l 表示的是每个样本的损失值,故求和或均值得到标量,再反向传播。梯度方向是一致mean()和sum()的区别仅在于梯度缩放:sum():梯度是单个样本梯度的总和。mean():梯度是单个样本梯度的平均(相当于总和除以 batch_size)。- 不影响优化方向:两者梯度方向相同,仅步长(学习率需相应调整)。
-
softmax 函数本身是非线性的,不改变决策边界,这句话怎么理解?
1 2$\hat{Y} = softmax(o)$ ,其中 $\hat{y}_j = \cfrac{exp(o_j)}{\sum_{k}exp(o_k)}$ 是一个非线性函数,但softmax回归的输出仍然由输入特征的仿射变换决定假设输入特征 $X$ 通过仿射变换得到输出 $o = WX + b$
Softmax 仅对 $o$ 做归一化($\hat{y}_j = \frac{e^{o_j}}{\sum_k e^{o_k}}$),不改变决策边界
分类边界还是由 $WX + b$ 决定,即超平面如果模型包含隐藏层或非线性激活(如 ReLU),则成为非线性模型
-
l.sum().backward() 反向传播后会自动计算梯度吗?
是的,这是 PyTorch 自动微分(Autograd)机制的核心功能,无需手动推导梯度公式
调用backward()后,PyTorch 会从该标量损失出发,沿着计算图(Computational Graph)反向传播,自动计算所有参与运算的张量(如w和b)的梯度
梯度会存储在张量的.grad属性中(例如w.grad和b.grad)完整流程示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20import torch # 定义模型参数(需要梯度追踪) w = torch.randn(3, requires_grad=True) # 例如:线性层的权重 b = torch.zeros(1, requires_grad=True) # 偏置 # 模拟输入和标签 X = torch.randn(5, 3) # batch_size=5, 特征维度=3 y = torch.randn(5, 1) # 标签 # 前向计算 y_pred = X @ w + b # 仿射变换 l = (y_pred - y).pow(2) # 每个样本的损失,形状 (5, 1) # 反向传播 l.sum().backward() # 关键步骤:求和后反向传播 # 查看梯度 print(w.grad) # 形状与 w 一致 (3,) print(b.grad) # 形状与 b 一致 (1,)如果多次调用
backward(),梯度会累加到.grad中(例如在 RNN 中)。如果需要清空梯度,需手动调用:1w.grad.zero_() # 清零梯度只有
requires_grad=True的张量(如模型参数w和b)会计算梯度。输入数据X默认不需要梯度。- 数学原理
设批量损失 $L = \sum_{i=1}^n l_i$,则参数 $w$ 的梯度为: $$ \frac{\partial L}{\partial w} = \sum_{i=1}^n \frac{\partial l_i}{\partial w} $$ PyTorch 的l.sum().backward()正是计算了这一求和后的梯度。
- 数学原理
-
多层感知机
隐藏层
由于线性模型可能出错,故添加隐藏层,但是还是有可能退化成线性模型 故需要激活函数,使得模型从线性到非线性,一般来说,有了激活函数,就不可能再将多层感知机退化成线性模型 输出层 隐藏层 输入层 输入层没有涉及计算,所以最简易的多层感知机的层数为 2,即一层隐藏层和一层输出层 三层之间是全连接,故参数开销很高
ReLU 图像是分段线性的 当输入为负时,ReLU 函数的导数为 0 当输入为正时,ReLU 函数的导数为 1 当输入值精确等于 0 时,ReLU 函数不可导。在此时,我们默认使用左侧的导数,即当输入为 0 时导数为 0
sigmoid 能将其输入压缩转换到区间 (0, 1)上 可以将 sigmoid 视为 softmax 的特例 通常在想要将输出视作二元分类问题的概率时使用 但是大部分时候已经被更简单、更容易训练的 ReLU 所取代
tanh 函数,双曲正切函数 能将其输入压缩转换到区间 (-1, 1)上
通常,我们选择 2 的若干次幂作为层的宽度。因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效
卷积神经网络
图像数据的每个样本都由一个 二维像素网格 组成
每个像素可能是一个或者多个数值,取决于是 黑白还是彩色 图像
多层感知机十分适合处理表格数据,其中行对应样本,列对应特征,但是缺少结构
对于图像数据,我们仅仅通过 将图像数据展平成一维向量,再将数据送入一个全连接的多层感知机中
这会忽略每个图像的 空间结构信息
这些网络特征元素的顺序是不变的,因此最优的结果是利用先验知识
即利用 相近像素之间的相互关联性,从图像数据中学习得到有效的模型
卷积神经网络(convolutional neural networks,CNN)是包含 卷积层 的一类特殊的神经网络
卷积核(convolution kernel)也称滤波器(filter),亦或简单地称之为该卷积层的 权重,通常该权重是可学习的参数
卷积神经网络的应用:图像识别、目标检测或语义分割
-
不变性
- 平移不变性(translation invariance)
不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对 相同的图像区域具有相似的反应 - 局部性(locality)
神经网络的前面几层应该只探索输入图像中的 局部区域,而不过度在意图像中相隔较远区域的关系
最终 聚合这些局部特征 以在整个图像级别进行预测
好处:参数大幅减少
代价:当图像不满足平移不变时,模型可能 难以拟合 训练数据 - 平移不变性(translation invariance)
-
卷积的定义
连续情况:$(f * g)(\mathbf{x}) = \int f(\mathbf{z}) g(\mathbf{x}-\mathbf{z}) d\mathbf{z}.$
离散情况:$(f * g)(i) = \sum_a f(a) g(i-a).$ -
通道
图像不是二维张量,而是一个由 高度、宽度和颜色 组成的三维张量
比如包含 1024x1024x3 个像素$$ [\mathsf{H}]{i,j,d} = \sum{a = -\Delta}^{\Delta} \sum_{b = -\Delta}^{\Delta} \sum_c [\mathsf{V}]{a, b, c, d} [\mathsf{X}]{i+a, j+b, c} $$
-
互相关运算
严格来说,卷积层是个错误的叫法
因为它所表达的运算其实是 互相关运算(cross-correlation),而不是卷积运算
二维卷积层的核心计算是二维互相关运算【对应元素相乘再求和】
最简单的形式是,对二维输入数据和卷积核执行互相关操作,然后添加一个偏置1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26# 二维互相关运算 def corr2d(X, K): # 获取卷积核的大小 h, w = K.shape # 计算卷积层输出的大小 Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1)) for i in range(Y.shape[0]): for j in range(Y.shape[1]): # 做互相关运算,对应元素相乘再求和 Y[i, j] = (X[i:i + h, j:j + w] * K).sum() return Y # 定义卷积层 class Conv2D(nn.Module): def __init__(self, kernel_size): super().__init__() # 对卷积核中的参数进行随机初始化 self.weight = nn.Parameter(torch.rand(kernel_size)) # 初始化偏置为 0 self.bias = nn.Parameter(torch.zeros(1)) # 前向传播,做卷积 def forward(self, x): return corr2d(x, self.weight) + self.bias注意:原输入大小为【X.shape[0], X.shape[1]】
卷积核大小为【h, w】
卷积层输出大小为【X.shape[0]-h+1, X.shape[1]-w+1】 -
卷积核参数的学习
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False) # 学习率 lr = 3e-2 for i in range(10): # 做卷积 Y_hat = conv2d(X) # 计算损失,这里是用均方差衡量 l = (Y_hat - Y) ** 2 # 梯度清零 conv2d.zero_grad() # 向后传播计算梯度 l.sum().backward() # 梯度下降法更新卷积核的参数 conv2d.weight.data[:] -= lr * conv2d.weight.grad if (i + 1) % 2 == 0: # 第偶数次更新时,打印结果 print(f'epoch {i+1}, loss {l.sum():.3f}') -
feature map
卷积层的输出,一个输入映射到下一层的 空间维度的转换器
网络越深,检测输入特征中的区域越广 -
padding 和 stride
-
填充
卷积的 输出形状 取决于 输入形状 和 卷积核的形状
在应用了连续的卷积之后,我们最终得到的输出远小于输入大小
这会导致原始图像的边界丢失了许多有用信息
填充是解决此问题最有效的方法 -
步幅
有时希望大幅降低图像的宽度和高度
例如原始的输入分辨率存在很多冗余

一个特征图尺寸为 6x6 的输入,卷积核大小为 3x3,步幅为 2,填充为 1
输出的尺寸 $=(6-3+2*1)/2+1$ 向下取整得到 3
即:(输入大小-卷积核大小+2*padding)/步幅+1,再向下取整
当填充数为:(卷积核大小-1)/2 且当步幅为 1 时,可以使输入和输出具有相同的高度和宽度 -
-
池化层
最大、最小、平均池化1 2 3 4 5# 设定汇聚层大小为 2x3 # 步幅也为 2x3 # padding=(0, 1) 表示仅对 X 的两侧进行填充 pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1)) pool2d(X) -
LeNet(LeNet-5)
-
卷积编码器
两个卷积层 -
全连接层密集块
三个全连接层1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30net = nn.Sequential( #卷积 + 激活函数 #输入通道数为 1,输出通道数为 6,卷积核大小为 5x5,填充为 2 nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(), #平均池化 #池化层大小为 2x2,步幅为 2 nn.AvgPool2d(kernel_size=2, stride=2), #卷积 + 激活函数 #输入通道数为 6,输出通道数为 16,卷积核大小为 5x5 nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(), #平均池化 #池化层大小为 2x2,步幅为 2 nn.AvgPool2d(kernel_size=2, stride=2), #展平为向量 nn.Flatten(), #全连接层 + 激活函数 nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(), #全连接层 + 激活函数 nn.Linear(120, 84), nn.Sigmoid(), #全连接层 nn.Linear(84, 10)) -
具体过程
输入为 (1, 1, 28, 28)
经过第一个卷积层,通道数变为 6,大小还是 $28=(28-5+22)/1+1$,经过激活函数大小不变
再经过第一个池化层,通道数不变,由于两倍降采样,大小变为 $14=28/2$
再经过第二个卷积层,通道数变为 16,大小变为 $10=(14-5+20)/1+1$,经过激活函数大小不变
再经过第二个池化层,通道数不变,由于两倍降采样,大小变为 $5=10/2$
16 个 5x5 大小的特征图展平变为长 $400=1655$ 的向量
经过第一个全连接层变为长 $120$ 的向量
经过第二个全连接层变为长 $84$ 的向量
经过第三个全连接层变为长 $10$ 的向量
-
-
一些代码操作
1 2 3# 张量转置 X = torch.ones((6, 8)) X.t()1 2 3 4conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2) # 前面两个 1 分别表示输入的通道数,和希望输出的通道数 # padding=1 填充 1 行或 1 列,因此整张图片总共添加了 2 行或 2 列 # stride=2 两倍降采样
大模型的自注意力机制如何工作?
自注意力机制是Transformer架构的核心,它让模型能像人类阅读时自然关注关键词那样,动态计算句子中每个词与其他所有词的关联强度。这种机制通过查询(Query)、键(Key)、值(Value) 三个向量的交互实现,类比图书馆检索系统:Q是你想找的书的主题(如“机器学习”),K是每本书的目录标签,V是书的具体内容。
一、核心原理:从向量变换到权重计算
-
生成QKV向量
输入句子中的每个词(如“我爱你”)先通过嵌入层转为向量,再与三个可训练的权重矩阵($W_Q, W_K, W_V$)相乘,得到对应的Q、K、V向量。例如,“我”的向量经$W_Q$变换后成为Q,代表它需要“查询”的信息;经$W_K$变换为K,作为被查询的“索引”;经$W_V$变换为V,包含实际语义内容。 -
计算注意力得分
用Q与所有词的K做点积(内积),得到相似度矩阵。例如“我”的Q与“爱”的K点积值越高,说明两者关联越强。为避免维度过高导致数值溢出(如768维向量点积可能很大),需除以$\sqrt{d_k}$($d_k$是K的维度),这一步叫“缩放”,能稳定softmax函数的梯度。公式如下:
$$\text{Attention Scores} = \frac{QK^T}{\sqrt{d_k}}$$ -
归一化与加权求和
将得分矩阵通过softmax转为概率分布(权重和为1),再与V矩阵相乘,得到每个词的上下文向量。例如“爱”的权重高,其V向量在“我”的输出中占比更大,最终“我”的表示就融合了“爱”的语义。完整公式:
$$\text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$
二、多头注意力:平行捕捉多维度关系
单头注意力可能只关注一种关联(如语法),而多头注意力将QKV拆分成多个子空间(如8个头),每个头独立计算注意力,最后拼接结果。例如:
- 头1专注“主谓关系”(如“我”→“爱”)
- 头2专注“动宾关系”(如“爱”→“你”)
这种机制类似集成学习,让模型从不同角度理解语言,最终通过线性变换合并输出。公式为:
$$\text{MultiHead}(Q,K,V) = \text{Concat}(\text{head}_1,…,\text{head}_h)W^O$$
三、实例:“我爱你”的注意力计算
假设“我爱你”三个词的嵌入向量为$X_1,X_2,X_3$,经矩阵变换后得到:
- $Q = [Q_1, Q_2, Q_3]$(每个词的查询向量)
- $K = [K_1, K_2, K_3]$(每个词的键向量)
- $V = [V_1, V_2, V_3]$(每个词的值向量)
- 计算得分矩阵:$QK^T$得到3×3矩阵,其中$(i,j)$元素是$Q_i$与$K_j$的相似度。
- 缩放与softmax:除以$\sqrt{d_k}$后归一化,假设得到权重矩阵$\begin{bmatrix}0.1&0.8&0.1\0.3&0.5&0.2\0.2&0.7&0.1\end{bmatrix}$,表示“我”对“爱”的权重(0.8)最高。
- 加权求和:每个词的输出 = 权重 × V向量,例如“我”的输出 = $0.1V_1 + 0.8V_2 + 0.1V_3$。
四、关键设计:为何需要缩放与掩码?
- 缩放因子$\sqrt{d_k}$:当K维度(如512)增大时,点积结果方差会膨胀,导致softmax输出接近0或1(梯度消失)。除以$\sqrt{d_k}$可将方差控制为1,保证训练稳定。
- 掩码机制:在文本生成(如GPT)中,需用“因果掩码”确保预测第i个词时仅关注前i-1个词,避免“未来信息泄露”。实现方式是将得分矩阵上三角置为负无穷,使softmax后权重为0。
自注意力机制的本质是用数据驱动的权重替代人工设计的规则,让模型自动学习“哪些词需要重点关注”。它不仅支撑了大语言模型的崛起,还启发了图像(如ViT)、推荐系统等领域的注意力应用。理解它的核心,就掌握了Transformer的“灵魂”。
