线性回归的从零开始实验

既然搭好环境了,就开始正式学习深度学习
首先就从线性回归的从零开始实验开始

运行源代码

打开linear-regression-scratch.ipynb,在这一次,我们需要从0开始实现一个线性回归器,不使用框架

生成数据集

为了简单起见,我们将根据带有噪声的线性模型构造一个人造数据集。我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。
我们将使用低维数据,这样可以很容易地将其可视化。

我们生成的数据格式是这样的:

\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon.

其中\epsilon可以视为模型预测和标签时的潜在观测误差。
在这里我们认为标准假设成立,即\epsilon服从均值为0的正态分布。
为了简化问题,我们将标准差设为0.01。
下面的代码生成合成数据集。我们生成一个包含1000个样本的数据集,
每个样本包含从标准正态分布中采样的2个特征。

对应代码如下

def synthetic_data(w, b, num_examples):  #@save
    """生成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)

这里的ture_w和ture_b就是模型真正的参数,我们通过对模型假设正态分布的误差,得到生成的数据集:

\begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_{1000} \end{bmatrix} = \begin{bmatrix} x_{11} & x_{12} \\ x_{21} & x_{22} \\ \vdots & \vdots \\ x_{1000,1} & x_{1000,2} \end{bmatrix} \begin{bmatrix} 2 \\ -3.4 \end{bmatrix} + 4.2 \begin{bmatrix} 1 \\ 1 \\ \vdots \\ 1 \end{bmatrix} + \begin{bmatrix} \epsilon_1 \\ \epsilon_2 \\ \vdots \\ \epsilon_{1000} \end{bmatrix}

对于第i个样本,有:

y_i = 2x_{i1} - 3.4x_{i2} + 4.2 + \epsilon_i

观察一下生成的散点图(这是对第二个分量的,即x2):可以直观观察到两者之间的线性关系

alt text

读取数据

接下来,我们需要读取数据。

回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。
由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数,该函数能打乱数据集中的样本并以小批量方式获取数据。

源代码中定义了一个data_iter函数

def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    random.shuffle(indices)  # 样本的读取顺序是随机的
    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]

这个函数的作用是将数据集打乱,并以小批量的形式返回数据
源代码中还演示了如何使用这个函数

batch_size = 10
for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break

初始化参数

初始化参数对于深度学习来说是一个重要的环节

在下面的代码中,我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重,
并将偏置初始化为0。

w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。
每次更新都需要计算损失函数关于模型参数的梯度。
有了这个梯度,我们就可以向减小损失的方向更新每个参数。
因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。

这里其实没太听懂,不过大概意思是说,初始化参数之后,我们需要通过计算损失函数关于模型参数的梯度来更新这些参数,从而让模型更好地拟合数据

定义模型

定义模型是深度学习的核心

回想一下,要计算线性模型的输出,
我们只需计算输入特征\mathbf{X}和模型权重\mathbf{w}的矩阵-向量乘法后加上偏置b
注意,上面的\mathbf{Xw}是一个向量,而b是一个标量。
回想一下 subsec_broadcasting中描述的广播机制:
当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。

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

ps : 这里的matmul是矩阵乘法.
#@save 表示这个函数会被保存到d2l包中,方便以后调用

现在我们的模型就有了,我们假设他是线性的,即y=Xw+b。之后我们会把这个模型应用到数据集上,看看效果如何

定义损失函数

损失函数是深度学习的灵魂,他评价了模型的好坏

源代码中使用了平方损失函数(MSE)

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

这里的y.reshape(y_hat.shape)将标签y的形状调整为与预测y_hat相同。
这里就是在计算\frac{1}{2}(y_{pred}-y_{true})^2,注意这里没有做平均

定义优化算法

优化算法,说白了就是最小化损失函数,从而让模型更好地拟合数据

源代码中使用了小批量随机梯度下降(SGD)

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

该函数接受模型参数集合、学习速率和批量大小作为输入。每
一步更新的大小由学习速率lr决定。
因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size
来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。

SGD我目前还没搞明白,但是我知道核心的一步就是

param -= lr * param.grad / batch_size

这里的param.grad就是损失函数关于模型参数的梯度,lr是学习率,batch_size是批量大小
这一步的意思就是用梯度下降法来更新模型参数,从而让损失函数更小

开始训练!

来看看我们现在都准备了什么:

  • 一个数据集 :features, labels
  • 一个模型 :linreg
  • 一个损失函数 :squared_loss
  • 一个优化算法 :sgd

这四个就是深度学习的四大组件,我们通过数据集来训练模型,通过损失函数来评价模型的好坏,通过优化算法来更新模型参数,从而让模型更好地拟合数据
训练的流程大致如下:

  1. 用数据集输入模型
  2. 模型输出与真实标签比较,通过损失函数计算误差
  3. 通过反向传播计算梯度
  4. 优化算法使用这些梯度更新模型参数
  5. 重复以上步骤直到收敛

下面是训练的流程图:

2025-10-09T20:53:21-kqwrhibz.png

训练中,还有一个很重要的就是超参数,比如学习率,批量大小,迭代次数等

这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设为3和0.03。
设置超参数很棘手,需要通过反复试验进行调整。
我们现在忽略这些细节,以后会在chap_optimization中详细介绍。

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

这里的net和loss就是我们之前定义的模型和损失函数
超参数lr和num_epochs分别是学习率和迭代次数,也就是说我们会用数据集训练模型3次,每次用的学习率是0.03

下面我们就开始训练

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}')
epoch 1, loss 0.040638
epoch 2, loss 0.000159
epoch 3, loss 0.000054

训练3个迭代周期后,我们的损失非常小,这表明我们学到了一个很好的模型。

ps: loss 是一个标量,表示模型的好坏,loss越小,模型越好

因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。
因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度
事实上,真实参数和通过训练学到的参数确实非常接近。

w的估计误差: tensor([-2.8324e-04,  2.6226e-05], grad_fn=<SubBackward0>)
b的估计误差: tensor([0.0007], grad_fn=<RsubBackward1>)

可以看到,模型学到的参数和真实参数非常接近,这表明我们的训练是成功的!

小结

通过这一节,我们了解了线性回归的基本原理,并通过从零开始实现一个线性回归器,掌握了深度学习的四大组件:数据集,模型,损失函数,优化算法
通过这个实验,我们也了解了深度学习的基本流程:用数据集输入模型,模型输出与真实标签比较,通过损失函数计算误差,通过反向传播计算梯度,优化算法使用这些梯度更新模型参数,从而让模型更好地拟合数据
最后,我们通过比较模型学到的参数和真实参数,验证了我们的训练是成功的。
不过,你可能会问,线性回归实际上是有解析解的,为什么还要用梯度下降法来训练呢?\

这是因为对于更复杂的模型,比如深度神经网络,解析解是不存在的,所以我们需要用梯度下降法来训练模型。

练习

我们来看看课后练习

如果我们将权重初始化为零,会发生什么。算法仍然有效吗?

这里的权重初始化为零,指的是将初始化中的w改为全为0的形式,我们来试试:

w = torch.zeros((2, 1), requires_grad=True)

先看看loss

epoch 1, loss 0.040649
epoch 2, loss 0.000149
epoch 3, loss 0.000053

可以看到,loss依然很小,说明模型依然有效
再看看参数误差

w的估计误差: tensor([-3.8171e-04,  7.9632e-05], grad_fn=<SubBackward0>)
b的估计误差: tensor([0.0003], grad_fn=<RsubBackward1>)

从结果来看,参数误差依然很小,说明模型依然有效
但是根据教科书的说法,权重初始化为零会导致模型无法学习到有用的特征,因为所有的神经元都会学习到相同的特征,导致模型的表达能力下降
不过在这个线性回归的例子中,权重初始化为零并没有导致模型无法学习到有用的特征,可能是因为线性回归本身的表达能力有限,权重初始化为零并没有太大影响?

假设试图为电压和电流的关系建立一个模型。自动微分可以用来学习模型的参数吗?

可以,自动微分可以用来学习模型的参数
假设电压和电流的关系是线性的,即V=IR+b,其中V是电压,I是电流,R是电阻,b是偏置
我们可以通过线性回归来学习R和b
具体来说,我们可以将电流I作为输入特征,将电压V作为标签,通过线性回归来学习R和b
自动微分可以用来计算损失函数关于R和b的梯度,从而更新R和b的值
通过不断迭代,我们可以学习到一个较好的R和b,从而建立电压和电流的关系模型

能基于普朗克定律使用光谱能量密度来确定物体的温度吗?

可以,普朗克定律描述了黑体辐射的光谱能量密度与温度之间的关系
具体来说,普朗克定律给出了在不同波长下,黑体辐射的光谱能量密度与温度之间的关系
通过测量物体在不同波长下的光谱能量密度,我们可以使用线性回归来学习温度
具体来说,我们可以将光谱能量密度作为输入特征,将温度作为标签,通过线性回归来学习温度
自动微分可以用来计算损失函数关于温度的梯度,从而更新温度的值
通过不断迭代,我们可以学习到一个较好的温度,从而确定物体的温度

计算二阶导数时可能会遇到什么问题?这些问题可以如何解决?

计算二阶导数时,可能会遇到梯度消失或梯度爆炸的问题
这是因为在计算二阶导数时,梯度会被多次乘以链式法则中的导数,如果这些导数的值很小或很大,就会导致梯度消失或梯度爆炸
为了解决这些问题,可以使用以下方法:

  1. 使用更合适的激活函数,比如ReLU,可以缓解梯度消失的问题
  2. 使用梯度裁剪(gradient clipping),可以防止梯度爆炸
  3. 使用更合适的初始化方法,比如Xavier初始化,可以缓解梯度消失和梯度爆炸的问题
  4. 使用更合适的优化算法,比如Adam,可以缓解梯度消失和梯度爆炸的问题

为什么在squared_loss函数中需要使用reshape函数?

squared_loss函数中,reshape函数的作用是将标签y的形状调整为与预测y_hat相同
这是因为在计算损失时,需要将预测y_hat和标签y进行比较,如果它们的形状不同,就会导致计算错误
通过使用reshape函数,可以确保y的形状与y_hat相同,从而避免计算错误

尝试使用不同的学习率,观察损失函数值下降的快慢。

原始的是0.03,我们可以尝试0.1和0.001

  • 学习率为0.1
epoch 1, loss 0.000053
epoch 2, loss 0.000054
epoch 3, loss 0.000054

额外快,基本上第一轮就收敛了

  • 学习率为0.001
epoch 1, loss 0.000053
epoch 2, loss 0.000053
epoch 3, loss 0.000053

emm 感觉和0.03差不多,可能是因为迭代次数太少了?
那我们调大迭代次数到10

  • 学习率为0.001,迭代次数为10
epoch 1, loss 0.000053
epoch 2, loss 0.000053
epoch 3, loss 0.000053
epoch 4, loss 0.000053
epoch 5, loss 0.000053
epoch 6, loss 0.000053
epoch 7, loss 0.000053
epoch 8, loss 0.000053
epoch 9, loss 0.000053
epoch 10, loss 0.000053
  • 学习率为0.1,迭代次数为10
epoch 1, loss 0.000053
epoch 2, loss 0.000054
epoch 3, loss 0.000054
epoch 4, loss 0.000054
epoch 5, loss 0.000054
epoch 6, loss 0.000054
epoch 7, loss 0.000054
epoch 8, loss 0.000056
epoch 9, loss 0.000054
epoch 10, loss 0.000053
  • 学习率为0.03,迭代次数为10
epoch 1, loss 0.000054
epoch 2, loss 0.000057
epoch 3, loss 0.000056
epoch 4, loss 0.000056
epoch 5, loss 0.000060
epoch 6, loss 0.000054
epoch 7, loss 0.000054
epoch 8, loss 0.000056
epoch 9, loss 0.000057
epoch 10, loss 0.000055

我们看10轮的结果,发现0.1和0.03差不多,基本上都在0.00005左右徘徊,并且产生了震荡
而0.001则是0.000053,和0.03差不多
从结果来看,学习率为0.1时,损失函数下降最快,基本上第一轮就收敛了
学习率为0.03时,损失函数下降较快,在前几轮下降较快,之后趋于平稳
学习率为0.001时,损失函数下降较慢,在前几轮下降较慢,之后趋于平稳
光这样分析感觉看不出来什么,我们可以对比分析:

lrs = [0.5, 0.3, 0.1, 0.01]
num_epochs = 10
net = linreg
loss = squared_loss··

batch_size = 10

all_lrs = []
for lr in lrs:
    train_lrs = []
    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)
            train_lrs.append(float(train_l.mean()))
    all_lrs.append(train_lrs)
epochs = np.arange(1, num_epochs+1)
d2l.plot(epochs, all_lrs, xlabel='epoch num', ylabel='loss', 
         legend=[f'learn rate {lr}' for lr in lrs],
         figsize=(6, 4))

ps: 代码来源这里

alt text
根据上述试验结果,可得到如下结论:

  • 学习率过大前期损失值下降快,但是后面不容易收敛,甚至发散
  • 学习率太小,损失函数下降慢

如果样本个数不能被批量大小整除,data_iter函数的行为会有什么变化?

如果样本个数不能被批量大小整除,data_iter函数在最后一个批量中会包含剩余的样本
比如,假设样本个数为1003,批量大小为100,那么前10个批量中每个批量包含100个样本,而最后一个批量中只包含3个样本
我们来试试

batch_size = 10
features, labels = synthetic_data(true_w, true_b, 1003)
for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)

当训练时,最后一个批量的大小会小于其他批量的大小,在这种情况下,我们只需忽略该批次中多余的样本即可。