从线性回归到MLP——动手学AI笔记(Chapter 3~5)

前言
这篇正式进入深度学习,从线性层到最简单的神经网络——多层感知机。主要对应书中的第三、四章,第五章是与代码实践关联更紧密的,也放进来了。
3. 线性神经网络
3.1 线性回归
- 回归:预测一个数值的问题
- 线性回归的基本元素:
- 线性模型:给定一个特征集合(数据集)
, ,模型可以表示为: - 损失函数:
- 单个样本:
这里加入1/2的系数的原因是求导之后系数变为1。 - 整个数据集:
- 单个样本:
- 解析解:
- 结果:
- 推导过程:
- 目的是最小化
- 令上式对
求偏导,并令其得0:
注意这一步中,和 互为转置,然而可以发现,这两个值都是标量,于是可以合并。( )
对 求偏导:
由此可解得解析解的结果。
- 目的是最小化
- 大部分深度学习问题不存在解析解。
- 结果:
- 随机梯度下降:
- 由于大部分问题没有解析解,所以需要用梯度下降法来逐渐逼近一个近似解。
- 计算损失函数(标量)关于模型参数的导数——即梯度。在实际应用中只采用小批量样本计算梯度。
是 learning rate, 是 batch size
- 预测:这里书中提到了一个很有意思的说法,即在深度学习中,预测(predict)和推断(inference)往往是一个意思;然而在统计学中,指的是从数据集中估计参数或者做出关于总体的结论。这通常包括参数估计(如估计一个分布的均值或方差)和假设检验(如检验两个样本是否来自同一个分布)。
- 线性模型:给定一个特征集合(数据集)
- 正态分布与平方损失:
正态分布的概率密度公式:
图像以均值为对称轴,增加方差会降低图像峰值(分散分布)现实问题中往往不是简单的线性拟合,需要引入噪声,假设噪声符合正态分布:
其中,所以,
代入正态分布的概率密度公式:
极大似然估计:经典求解极大似然估计的问题,转换成对数后求导:
是常数,所以上面式子的解只取决于 和
这一部分的目的是为了证明:在高斯噪声的假设下,最小化均方误差等价于对线性模型的极大似然估计。
3.2 线性回归的从零开始实现
虽然这一章在现在的深度学习框架中就一行nn.Linear()
,但手推就是这部书最大的魅力,还是得记录一下源码的阅读心得。
当然,后续就不会大费周章地在笔记里手撕了,不过每一行代码都要仔细阅读。
- yield:在开始之前,先巩固一下python中yield关键字的用法,在之后的代码中会用到
当一个函数中包含 yield 语句时,这个函数就变成了一个生成器函数。调用这个函数并不会立即执行函数体,而是返回一个生成器对象(generator object),这个对象可以用来逐个获取 yield 返回的值。
yield 与 return 的区别:
- return:会立即结束函数并返回一个值。
- yield:会暂停函数,保存当前状态(包括局部变量、执行位置等),下次调用时从暂停的地方继续执行。
yield最大的优势:节省内存,只有在需要时才生成下一个值,因此非常适合处理大量数据或无限序列。
下面开始手撕线性回归:
1 | # 模拟数据 |
3.3 线性回归的简洁实现
上一小节主要是感动自己,这一小节才是重点,这套模版最好能背下来,大部分训练流程都是一样的。
1 | import numpy as np |
3.4 softmax回归
上面几节讲了全连接网络在回归中的用处,下面是分类问题。这小节着重讲解softmax归一化与交叉熵函数。
- 分类问题中,输出一般是一个概率,如果不对输出做任何处理,那么输出总和不为1,并且有可能是负数,违背了概率基本公理。
- softmax:概率非负、总和为1、可导
- 从极大似然过渡到交叉熵(这部分原书讲的很不清晰,结合评论区大佬的讲解才理解):
- 我们要求的是给定一个输入向量
,求出该向量对应的标签是多少的概率,如 ,对应到数据集来说就是 。 - 根据最大似然估计,最大化
相当于最小化负对数似然:
那么是怎么到交叉熵的呢?
根据评论区的解答,首先,的值是 , 的值为
而,代表的含义是给定输入 ,求出能输出对应的独热码向量 的概率,也就是 中 不为0的那个位置 。 这是本人花了很久才弄懂的,书中写的确实不清楚,个人认为理解的核心在于搞懂每个变量都代表什么
例如,代表第i个样本预测类别为m的概率。此外,粗体是向量,否则是值 是什么便开始求值,细心的朋友可以发现,让 与 点积最后的结果就是$\hat{y}m^{i} $ -\log P(\mathbf{y}^{(i)} \mid \mathbf{x}^{(i)}) = -\log \sum{j=1}^q \textbf{y}^{(i)} · \hat{\mathbf{y}}^{(i)} = -\log \hat{y}_m^{(i)} = -1 · \log \hat{y}_m^{(i)} = -y_m^{(i)}\log \hat{y}m^{(i)} = -\sum{j=1}^q -y_j^{(i)}\log \hat{y}_j^{(i)} $$
最后一步是由于其他项都是0,加上去不会有影响。最终可以得到交叉熵公式。
- 我们要求的是给定一个输入向量
- 将该交叉熵公式对
求导。
这里书中对于该求导结果的描述很有意思,特此引用:导数是我们softmax模型分配的概率与实际发生的情况(由独热标签向量表示)之间的差异。 从这个意义上讲,这与我们在回归中看到的非常相似, 其中梯度是观测值
和估计值 之间的差异
3.5 图像分类数据集
教你使用MNIST数据集,略过
3.6 softmax的从零开始实现
只记录一些关键信息:
1 | def softmax(X): |
3.7 softmax的简洁实现
定义模型并初始化:
1 | # PyTorch不会隐式地调整输入的形状。因此, |
softmax的计算不稳定问题:在实际计算中,softmax会产生数值不稳定的问题。
- 上溢(Overflow):求e的指数幂可能会导致overflow,使分子分母变为inf。解决措施是使指数部分减去一个最大值,最终结果是不变的。如下面的公式所示:
$$\hat{y}j = \frac{\exp(o_j - \max(o_k)) \exp(\max(o_k))}{\sum{k} \exp(o_k - \max(o_k)) \exp(\max(o_k))}= \frac{\exp(o_j - \max(o_k))}{\sum_{k} \exp(o_k - \max(o_k))}.$$ - 下溢(Underflow):在上述步骤之后,可能有些
具有较大的负值,其e的指数次幂就会接近零,再求log就会趋向于负无穷。解决方法是套上log后进行一些数学上的恒等变换。如下面的公式所示:
$$\begin{aligned}
\log(\hat{y}j) &= \log \left( \frac{\exp(o_j - \max(o_k))}{\sum{k} \exp(o_k - \max(o_k))} \right) \
&= \log(\exp(o_j - \max(o_k))) - \log \left( \sum_{k} \exp(o_k - \max(o_k)) \right) \
&= o_j - \max(o_k) - \log \left( \sum_{k} \exp(o_k - \max(o_k)) \right).
\end{aligned}
$$
4. 多层感知机
4.1 多层感知机
4.1.1 隐藏层
- 线性网络的局限性:上一章讲解了线性回归,但只有很少的问题可以用线性方程去拟合。所以需要通过在网络中加入隐藏层(Hidden layer)的方式来突破线性模型的限制。于是就有了多层感知机(Multilayer Perceptron, MLP)。
- 引入激活函数:然而如果只是线性层,加多少层都相当于是一层,因为一层的mlp已经能拟合全部的线性函数了。具体证明参考原书,大概就是写两个线性层,最后能推到其实就是一个线性层。
- 激活函数:activation function,
激活函数保证模型不会退化为线性层,使模型具有更强的表达能力。 - 通用近似能力:理论上讲,mlp可以对任意函数进行建模。但实际是和困难的,这里引用一个书中的比喻,比较有趣
神经网络有点像C语言。 C语言和任何其他现代编程语言一样,能够表达任何可计算的程序。 但实际上,想出一个符合规范的程序才是最困难的部分。
4.1.2 激活函数
- ReLU(Rectified Linear Unit):经典激活函数
- 公式:
注意输入为负时,ReLU的导数为0,输入为正,导数为1。注意,输入为0不可导,此时默认使用左侧的导数。我们可以忽略这种情况,因为输入可能永远都不会是0。 这里引用一句古老的谚语,“如果微妙的边界条件很重要,我们很可能是在研究数学而非工程”, 这个观点正好适用于这里。
- 评价:求导表现得特别好——要么让参数消失,要么让参数通过。
- 公式:
- Sigmoid:将输入变换为区间 (0, 1) 上的输出。
- 公式:
- 评价:平滑、可微。但现在已经被更容易训练的ReLU取代了
- 公式:
- tanh:将输入变换为区间 (-1, 1) 上。
4.2 多层感知机的从零开始实现
- 初始化模型参数
1
2
3
4
5
6
7
8
9
10
11num_inputs, num_outputs, num_hiddens = 784, 10, 256
# 通常选择2的指数幂作为隐藏层的神经元个数,因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
W1 = nn.Parameter(torch.randn(
num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2] - ReLU:
1
2
3def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a) - 模型:
1
2
3
4def net(X):
X = X.reshape((-1, num_inputs))
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
return (H@W2 + b2)
4.3 多层感知机的简洁实现
1 | net = nn.Sequential(nn.Flatten(), |
4.4 模型选择、欠拟合与过拟合
经典知识点,很熟悉了,跳过。
4.5 权重衰减
- 解释:在更新权重矩阵
的同时试图将 的大小缩小到0。 - 作用:防止过拟合。模型的参数,也就是权重矩阵,如果模型的参数过多或过大,就会产生过拟合问题。
思考:为什么模型参数越多或越大,会导致过拟合问题?
- 首先模型参数多会过拟合这个应该没有什么疑问,越大的模型拟合训练数据的能力就越好,就越容易记住,导致过拟合。
- 但模型权重分量的大小越大为什么会导致过拟合呢?
- 模型敏感:首先假设有一个线性模型
,如果 非常大,那么 有轻微扰动, 也会产生剧烈变化,这样模型的泛化性就会变得很差。 - 强行拟合噪声:除此之外,当模型参数变得很大时,模型可能在强行拟合训练中的噪声和异常点。比如模型为了把某个异常样本正确分类,模型可能会把某个权重调得非常大,从而让这个样本被“拉”到正确的一侧。
- 函数震荡:从函数空间角度看,参数值大的模型往往对应着更复杂、更不平滑的决策边界或函数形状。(摘自ai)
- 模型敏感:首先假设有一个线性模型
- 公式:
- 这里选用
范数的平方是因为这样导数容易计算。 - 之所以选择使用
范数而不是 范数,是因为 范数只惩罚权重向量的大小,而 范数会使模型部分特征的权重完全变为0。 范数偏向于在大量特征上均匀分布权重的模型。 原因:因为
范数的导数是变量的一次方,而原变量小了之后导数也变小了,趋于零的速度变慢了
但的导数是1,原变量趋于零的速度始终不变,所以可以实现特征选择(feature selection)
- 这里选用
- 简洁实现:
1
2
3torch.optim.SGD([
{"params":net[0].weight,'weight_decay': wd},
{"params":net[0].bias}], lr=lr)
4.6 Dropout(暂退法)
- 作用:同样也是防止过拟合问题。一个好的预测模型应当是简单、且对微小扰动不敏感的,于是便有学者研究如何在网络的前向传播中引入噪声。
- 解释:在实践上,每次迭代中,括在计算下一层之前将当前层中的一些节点置零。这样相当于引入了噪声。
- 无偏向(unbiased)的噪声:这里的unbiased是指,每一层的期望值等于没有噪音时的值。要做到这一点,每个中间活性值
以暂退概率 由随机变量 替换
替换,:
之所以这个公式能奏效,是因为以下推导:
这是Dropout后每层的期望公式,可以看到如果第二项不除以,那么就不等于原值。 - 手撕:可以关注一下计算mask的那一行,比较巧妙。
1
2
3
4
5
6
7
8
9
10
11def dropout_layer(X, dropout):
assert 0 <= dropout <= 1
# 在本情况中,所有元素都被丢弃
if dropout == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if dropout == 0:
return X
# 比较精妙的操作
mask = (torch.rand(X.shape) > dropout).float()
return mask * X / (1.0 - dropout) - 简洁实现:
1
2
3
4
5
6
7
8
9
10net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
# 在第一个全连接层之后添加一个dropout层
nn.Dropout(dropout1),
nn.Linear(256, 256),
nn.ReLU(),
# 在第二个全连接层之后添加一个dropout层
nn.Dropout(dropout2),
nn.Linear(256, 10))
4.7. 前向传播、反向传播和计算图
4.7.1 前向传播
本小节通过最简单的神经网络(输入层、隐藏层、输出层)来一步一步解析前向传播的机制。前向的原理比较简单,主要是记一下维度之类的。
- 输入:
- 输入进隐藏层,得到中间变量
:
其中, - 经过激活函数,得到隐藏变量
: - 经过输出层:
其中 - 计算单个样本的损失:
- 添加正则化项:
- 最终的目标函数是上两式之和。
4.7.3 反向传播
比前向复杂,涉及到多层复合函数求导以及链式法则。但其实也还好,主要就是要清楚目标是什么,然后按照前向的步骤一层层反推回去。
- 目标:首先要明确,反向传播的目的是计算目标函数对模型所有参数的梯度结果。还是拿单隐藏层神经网络举例,该网络的参数是
和 。那么反向传播的目的就是计算:
其中,J是总的目标函数 - 按照前向的步骤相反着推,由于
,于是先求出: - 计算关于输出层变量
的梯度: - 计算正则化项相对于两个参数的梯度:
- 现在可以计算目标函数对输出层权重矩阵的梯度了:
这个式子较原书稍有改动,加入了一些自己的理解,但应该是一样的。
到此,已经完成了目标一半的工作,剩下就是求关于第一层权重矩阵的梯度。 - 为了获取关于
的梯度,继续沿着输出层到隐藏层反向传播。求关于隐藏变量 的梯度: - 接着求隐藏层中间变量
的梯度:
注意,在求时,由于激活函数是逐元素计算的,所以导数也需要用逐元素乘法。 - 最后可以得到关于隐藏层权重矩阵的梯度:
最终结果并未完全代入成已知变量。注意:书中的推导思路个人认为是不太顺的,正常的推导思路应该是先把目标式子列出来,然后一点一点替换其中的位置变量,但书中这种思路似乎是能预知到推导过程都要用到什么变量,所以初次看时会有一些疑惑。不过我用两种方式在草稿纸上都推了一遍,是能够印证上的,具体过程就不在笔记里记录了,在纸上很快就推出来了。
除此之外,这小节的推导我对向量的维度,包括哪里该做转置都没细究,第一遍看先这样了。
4.7.4 训练神经网络
- 实际训练时,前向传播与反向传播交替进行,相互依赖。
- 前向传播需要用到模型参数
与 的值,来计算出损失函数与正则项。 与 的值根据最近一次反向传播得出结果。 - 反向传播期间参数的梯度计算,取决于由前向传播给出的隐藏变量
的值。 - 训练过程中,需要保存中间变量的值,这是训练比推理更占显存的原因。
4.8 数值稳定性和模型初始化
这一小节的知识不仅来源于书,还来源于该知乎帖子:详解深度学习中的梯度消失、爆炸原因及其解决方法
4.8.1 数值稳定性
- 解释
- 梯度消失:在反向传播过程中,靠近输入层的权重所接收到的梯度非常小,几乎为零。这些权重更新得非常缓慢,甚至完全停止更新,导致网络的学习速度大大降低,最终可能无法有效学习数据中的模式。
- 梯度爆炸:在反向传播过程中,某些权重的梯度过大,导致权重更新幅度过大,使得网络无法收敛,甚至导致数值溢出错误。
- 注意:在实际训练过程中,梯度消失更常见一些。
- 原因:
- 假设有一个四层的全连接神经网络,第i层激活后的输出为
, 是激活函数,并且有如下式子(为表达简洁,省略偏置项): - 在反向传播过程中,通过式子
来更新参数,给定学习率 ,有 。现在假设我们要更新第二个隐藏层的权重,有如下公式(为表达简洁,省略学习率):
可以看到,这部分就是 。但 、 都是对激活函数进行求导(对激活函数求导在4.7.3中有所体现)。如果此部分大于1,随着层数增多就会梯度爆炸;反之,则会梯度消失。总而言之,激活函数的选择对于梯度爆炸/消失至关重要。 - 一些常见的激活函数,如sigmoid、tanh,他们的导数只在一个有限的区间内是大于0的,在绝大多数情况下都趋于0,所以很容易导致梯度消失,无法收敛。这也是为什么现在的网络一般都用ReLU及其变体。
这部分可以参考函数原图像与导数图像,导数图像有点像正态分布的图像
- 假设有一个四层的全连接神经网络,第i层激活后的输出为
- 解决方案:
- 预训练 + 微调:先逐层“预训练”,每次训练一层隐节点;预训练完成后再用bp算法进行微调。Hinton提出,现在已经没人用了。
- 梯度裁剪、正则:主要是针对梯度爆炸。当梯度超过某一设定好的阈值时,就将梯度裁剪。正则就指的是前文所说的
正则化,限制权重矩阵的分量大小。 - ReLU及其变体:
- ReLU:正的部分导数为1,可以缓解梯度消失与爆炸。但ReLU有以下两个缺点:(1)由于负数部分恒为0,会导致一些神经元的梯度永远是0,不会被更新;(2)输出不是以0为中心,意味着更新方向永远只有一个。
- LeakyReLU:为了解决ReLU的零区间,公式如下
是一个小的常数。
- Batchnorm:对神经网络每一层的输入进行批标准化,把输入修正为标准正态分布。优点是通过这种方式可以使输入不会过大或过小,从而使得sigmoid、tanh这样的激活函数的梯度不近似于0。
- 残差:后面会讲ResNet
- LSTM
4.8.2 参数初始化
除了上述从知乎链接中学习到的防止梯度消失/爆炸的方法外,书中还介绍了一种方法,就是选取合适的初始化。其中重点介绍的,就是Xavier初始化。
- Xavier初始化的两个假设:
- 每一层的输入和输出的方差应该相同
- 网络是线性的,或者至少是在激活函数的线性区域内操作(例如使用ReLU激活函数时,这可能不完全成立)。
- Xavier的公式:具体推导省略了,详情看书。对于一个给定的层,其权重
可以从均匀分布或正态分布中抽取。 - 均匀分布:
- 正态分布:
推导这些公式的核心就是保证每一层的输入输出方差相同。
- 均匀分布:
4.9 环境和分布偏移
分布偏移通俗易懂的理解就是所用的训练集和测试集对不上,导致可能在训练时效果非常好的模型一到测试就抓瞎了。
分布偏移主要分为以下几种类型:
* 协变量偏移:指 的分布发生了变化,但条件概率 没有改变。通俗理解就是数据变了,但数据和标签的映射关系没变。
> 举例:就比如一个图像分类器利用白天拍的照片训练,然后在晚上的照片测试,这会导致输入数据的分布发生变化。
* 标签偏移:也叫先验概率偏移,指标签 的分布发生了变化,但条件概率 不变。换句话说,就是每个类别的比例发生了改变,但给定类别下的数据特征分布没有变。
> 举例:在训练集中,正类和负类的比例可能是70%对30%,但在测试集或实际应用环境中,这个比例变成了50%对50%。这种情况下,尽管对于每一个具体的样本,其对应的输入数据特征没有变化,但是由于整体上正类和负类的比例改变了,这会影响模型的表现,尤其是在依赖于类别平衡的评估指标下。
* 概念偏移:条件概率 发生变化。即标签与数据的映射关系变了,或者说数据标注的方式变了。
> 举例:消费者对某种产品的偏好随时间而变化。
5. 深度学习计算
这章主要是一个过渡,教你一些基本的代码模版,没啥参考价值,可以略过。
5.1 层和块
自定义块:可以作为模板参考
1 | import torch |
顺序块:仿照torch的Sequential
类实现了一个自定义的顺序块
1 | class MySequential(nn.Module): |
5.2 参数管理
- 参数访问:当通过Sequential定义模型时,可以通过索引来访问模型的任意层,就像列表一样
1
2
3
4
5
6
7
8
9
10# 访问第2层的全部参数,返回一个OrderedDict
print(net[2].state_dict())
# 访问单个参数
print(type(net[2].bias))
print(net[2].bias)
print(net[2].bias.data) # 值
# 访问所有层的参数
print(*[(name, param.shape) for name, param in net.named_parameters()]) - 参数初始化:
- pytorch内置
1
2
3
4
5
6
7
8
9if type(m) == nn.Linear:
# 正态分布初始化
nn.init.normal_(m.weight, mean=0, std=0.01)
# 常数初始化
nn.init.constant_(m.weight, 1)
# xavier初始化
n.init.xavier_uniform_(m.weight)
nn.init.zeros_(m.bias) - 同样,这里可以自定义一个初始化:
用以下分布为例子:
可以这样设计代码:1
2
3
4
5
6if type(m) == nn.Linear:
print("Init", *[(name, param.shape)
for name, param in m.named_parameters()][0])
nn.init.uniform_(m.weight, -10, 10)
# 这里的操作比较玄妙
m.weight.data *= m.weight.data.abs() >= 5
- pytorch内置
- 参数绑定: 在反向传播中,共享参数的梯度会叠加在一起,这些累加的梯度会被用来一次性地更新这一组共享的参数
1
2
3
4
5
6# 我们需要给共享层一个名称,以便可以引用它的参数
shared = nn.Linear(8, 8)
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(),
shared, nn.ReLU(),
shared, nn.ReLU(),
nn.Linear(8, 1))
优点:节省内存,并且在CNN、RNN等模型具有特定的好处例如对于RNN,它在序列的各个时间步之间共享参数,因此可以很好地推广到不同序列长度的示例。
5.3 延后初始化
有些时候我们难以确定网络的输入维度,或者难以知道前一层的输出维度,就没法确定下一层的输入。这种情况下,就需要使用延后初始化。这种技术使我们不用手动指定输入维度,在写CNN时尤其有用,因为不用再手算每层的输出维度了。
代码如下所示,区别就在于LazyLinear
只接受一个参数即神经元个数。
1 | import torch |
5.4 自定义层
5.1讲了自定义块(也就是模型),这一小节讲了如何自定义一个层。
1 | class MyLinear(nn.Module): |
这里利用了nn.Parameter
类, 该类提供了一些基本的管理功能,比如管理访问、初始化、共享、保存和加载模型参数。
5.5 读写文件
- 加载/存储张量
1
2torch.save(x, 'filename')
x = torch.load('filename') - 加载/存储模型
1
2
3
4
5
6# save
torch.save(net.state_dict(), 'mlp.params')
# load
clone = MLP()
clone.load_state_dict(torch.load('mlp.params'))
5.6 GPU
一些基本的gpu操作,没啥可记的。
- 标题: 从线性回归到MLP——动手学AI笔记(Chapter 3~5)
- 作者: jkm
- 创建于 : 2025-08-09 16:38:37
- 更新于 : 2025-08-09 18:23:16
- 链接: https://goldenkm.github.io/2025/08/09/D2L-Note-Chapter3-5/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。