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

jkm Lv3

前言

这篇正式进入深度学习,从线性层到最简单的神经网络——多层感知机。主要对应书中的第三、四章,第五章是与代码实践关联更紧密的,也放进来了。

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
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# 模拟数据
def synthetic_data(w, b, num_examples):
"""生成y=Xw+b+噪声"""
X = torch.normal(0, 1, (num_examples, len(w)))
y = torch.matmul(X, w) + b
y += torch.normal(0, 0.01, y.shape)
return X, y.reshape((-1, 1))

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

# 读取数据
def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))
# shuffle
random.shuffle(indices)
# 取出一个batch的数据
for i in range(0, num_examples, batch_size):
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])
yield features[batch_indices], labels[batch_indices]

# 初始化模型参数
# 从均值0、标准差0.01的正态分布中采样来初始化权重
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
# 偏置初始化为0,size=1是由于广播机制
b = torch.zeros(1, requires_grad=True)

# 定义模型
def linreg(X, w, b): #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

# 定义损失函数
def squared_loss(y_hat, y): #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

# 定义优化算法(小批量随机梯度下降)
def sgd(params, lr, batch_size): #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()

# Training
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和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}')

3.3 线性回归的简洁实现

上一小节主要是感动自己,这一小节才是重点,这套模版最好能背下来,大部分训练流程都是一样的。

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
30
31
32
33
34
35
36
37
38
import numpy as np
import torch
from torch.utils import data
from torch import nn


# dataloader加载数据
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)

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)

# 经典的pytorch训练流程,最好能背下来
num_epochs = 3
for epoch in range(num_epochs):
for X, y in data_iter:
l = loss(net(X) ,y)
trainer.zero_grad()
l.backward()
trainer.step()
l = loss(net(features), labels)
print(f'epoch {epoch + 1}, loss {l:f}')

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True) # dim=1,相当于按列求和(每一行都加起来)
return X_exp / partition # 这里应用了广播机制


def cross_entropy(y_hat, y):
# 注意下面这行代码,用了一种高级索引的方式,意思是在y_hat中,第i行取出y[i]对应的位置。
# 例如假如y是[0, 2],那么就在y_hat中第0行取第0个,第1行取第1个。
return - torch.log(y_hat[range(len(y_hat)), y])


def accuracy(y_hat, y):
"""计算预测正确的数量"""
# 如果y_hat是矩阵,即第二个维度存储了预测概率,就用argmax获取每行最大元素的索引
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
y_hat = y_hat.argmax(axis=1)
# 由于“==”对数据类型很敏感, 因此将y_hat的数据类型转换为与y一致。
cmp = y_hat.type(y.dtype) == y
return float(cmp.type(y.dtype).sum()) / len(y)

3.7 softmax的简洁实现

定义模型并初始化:

1
2
3
4
5
6
7
8
9
# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights)

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
    11
    num_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
    3
    def relu(X):
    a = torch.zeros_like(X)
    return torch.max(X, a)
  • 模型:
    1
    2
    3
    4
    def net(X):
    X = X.reshape((-1, num_inputs))
    H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
    return (H@W2 + b2)

4.3 多层感知机的简洁实现

1
2
3
4
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))

4.4 模型选择、欠拟合与过拟合

经典知识点,很熟悉了,跳过。

4.5 权重衰减

  • 解释:在更新权重矩阵 的同时试图将 的大小缩小到0。
  • 作用:防止过拟合。模型的参数,也就是权重矩阵,如果模型的参数过多或过大,就会产生过拟合问题。

    思考:为什么模型参数越多或越大,会导致过拟合问题?

    • 首先模型参数多会过拟合这个应该没有什么疑问,越大的模型拟合训练数据的能力就越好,就越容易记住,导致过拟合。
    • 但模型权重分量的大小越大为什么会导致过拟合呢?
      1. 模型敏感:首先假设有一个线性模型 ,如果 非常大,那么 有轻微扰动, 也会产生剧烈变化,这样模型的泛化性就会变得很差。
      2. 强行拟合噪声:除此之外,当模型参数变得很大时,模型可能在强行拟合训练中的噪声和异常点。比如模型为了把某个异常样本正确分类,模型可能会把某个权重调得非常大,从而让这个样本被“拉”到正确的一侧。
      3. 函数震荡:从函数空间角度看,参数值大的模型往往对应着更复杂、更不平滑的决策边界或函数形状。(摘自ai)
  • 公式:
    • 这里选用 范数的平方是因为这样导数容易计算。
    • 之所以选择使用 范数而不是 范数,是因为 范数只惩罚权重向量的大小,而 范数会使模型部分特征的权重完全变为0。 范数偏向于在大量特征上均匀分布权重的模型。

      原因:因为 范数的导数是变量的一次方,而原变量小了之后导数也变小了,趋于零的速度变慢了
      的导数是1,原变量趋于零的速度始终不变,所以可以实现特征选择(feature selection)

  • 简洁实现:
    1
    2
    3
    torch.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
    11
    def 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
    10
    net = 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及其变体。

      这部分可以参考函数原图像与导数图像,导数图像有点像正态分布的图像

  • 解决方案:
    1. 预训练 + 微调:先逐层“预训练”,每次训练一层隐节点;预训练完成后再用bp算法进行微调。Hinton提出,现在已经没人用了。
    2. 梯度裁剪、正则:主要是针对梯度爆炸。当梯度超过某一设定好的阈值时,就将梯度裁剪。正则就指的是前文所说的 正则化,限制权重矩阵的分量大小。
    3. ReLU及其变体:
      • ReLU:正的部分导数为1,可以缓解梯度消失与爆炸。但ReLU有以下两个缺点:(1)由于负数部分恒为0,会导致一些神经元的梯度永远是0,不会被更新;(2)输出不是以0为中心,意味着更新方向永远只有一个。
      • LeakyReLU:为了解决ReLU的零区间,公式如下

        是一个小的常数。
    4. Batchnorm:对神经网络每一层的输入进行批标准化,把输入修正为标准正态分布。优点是通过这种方式可以使输入不会过大或过小,从而使得sigmoid、tanh这样的激活函数的梯度不近似于0。
    5. 残差:后面会讲ResNet
    6. LSTM

4.8.2 参数初始化

除了上述从知乎链接中学习到的防止梯度消失/爆炸的方法外,书中还介绍了一种方法,就是选取合适的初始化。其中重点介绍的,就是Xavier初始化。

  • Xavier初始化的两个假设:
    • 每一层的输入和输出的方差应该相同
    • 网络是线性的,或者至少是在激活函数的线性区域内操作(例如使用ReLU激活函数时,这可能不完全成立)。
  • Xavier的公式:具体推导省略了,详情看书。对于一个给定的层,其权重 可以从均匀分布或正态分布中抽取。
    • 均匀分布:
    • 正态分布:

      推导这些公式的核心就是保证每一层的输入输出方差相同。

4.9 环境和分布偏移

分布偏移通俗易懂的理解就是所用的训练集和测试集对不上,导致可能在训练时效果非常好的模型一到测试就抓瞎了。
分布偏移主要分为以下几种类型:
* 协变量偏移:指 的分布发生了变化,但条件概率 没有改变。通俗理解就是数据变了,但数据和标签的映射关系没变。
> 举例:就比如一个图像分类器利用白天拍的照片训练,然后在晚上的照片测试,这会导致输入数据的分布发生变化。
* 标签偏移:也叫先验概率偏移,指标签 的分布发生了变化,但条件概率 不变。换句话说,就是每个类别的比例发生了改变,但给定类别下的数据特征分布没有变。
> 举例:在训练集中,正类和负类的比例可能是70%对30%,但在测试集或实际应用环境中,这个比例变成了50%对50%。这种情况下,尽管对于每一个具体的样本,其对应的输入数据特征没有变化,但是由于整体上正类和负类的比例改变了,这会影响模型的表现,尤其是在依赖于类别平衡的评估指标下。
* 概念偏移:条件概率 发生变化。即标签与数据的映射关系变了,或者说数据标注的方式变了。
> 举例:消费者对某种产品的偏好随时间而变化。

5. 深度学习计算

这章主要是一个过渡,教你一些基本的代码模版,没啥参考价值,可以略过。

5.1 层和块

自定义块:可以作为模板参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
from torch import nn
from torch.nn import functional as F


class MLP(nn.Module):
# 用模型参数声明层。这里,我们声明两个全连接的层
def __init__(self):
# 调用MLP的父类Module的构造函数来执行必要的初始化。
# 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍)
super().__init__()
self.hidden = nn.Linear(20, 256) # 隐藏层
self.out = nn.Linear(256, 10) # 输出层

# 定义模型的前向传播,即如何根据输入X返回所需的模型输出
def forward(self, X):
# 注意,这里我们使用ReLU的函数版本,其在nn.functional模块中定义。
return self.out(F.relu(self.hidden(X)))

顺序块:仿照torch的Sequential类实现了一个自定义的顺序块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class MySequential(nn.Module):
def __init__(self, *args):
super().__init__()
for idx, module in enumerate(args):
# 这里,module是Module子类的一个实例。我们把它保存在'Module'类的成员
# 变量_modules中。_module的类型是OrderedDict
self._modules[str(idx)] = module

def forward(self, X):
# OrderedDict保证了按照成员添加的顺序遍历它们
for block in self._modules.values():
X = block(X)
return X

# 调用方式如下,如nn.Sequential一致
net = MySequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10))
net(X)

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
      9
      if 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
      6
      if 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
  • 参数绑定:
    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
2
3
4
import torch
from torch import nn

net = nn.Sequential(nn.LazyLinear(256), nn.ReLU(),nn.Linear(256,10))

5.4 自定义层

5.1讲了自定义块(也就是模型),这一小节讲了如何自定义一个层。

1
2
3
4
5
6
7
8
class MyLinear(nn.Module):
def __init__(self, in_units, units):
super().__init__()
self.weight = nn.Parameter(torch.randn(in_units, units))
self.bias = nn.Parameter(torch.randn(units,))
def forward(self, X):
linear = torch.matmul(X, self.weight.data) + self.bias.data
return F.relu(linear)

这里利用了nn.Parameter类, 该类提供了一些基本的管理功能,比如管理访问、初始化、共享、保存和加载模型参数。

5.5 读写文件

  • 加载/存储张量
    1
    2
    torch.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 进行许可。
评论
此页目录
从线性回归到MLP——动手学AI笔记(Chapter 3~5)