刚刚写完了第一篇文章,可能是因为刚刚开始写,能够感受到强烈的正反馈,所以不妨趁热打铁,再写一篇。
需要说明的是,笔者用的是pytorch版本的书籍,所以自然也是用pytorch来完成以后的学习。
有个小插曲在上一篇文章中的练习部分忘记说了,也不想再去修改它,就在这里写一写吧:因为笔者使用的是vscode编程,所以copilot无处不在。令笔者尤其震惊的是,在写练习的过程中才刚刚打了几个字母,copilot就已经想好题目怎么做了,所以后面的编程就似变成临摹,也不知对于初学者是好是坏。不过效率的提高的确是显著的,或许在以后的不断尝试中能够找到与工具相处的平衡模式。
还有一个可能引起疑惑的点,上一篇还是2.1,这里已经是3.1了——这当然是笔者跳过了很多内容,而且编号也只是一个编号而已,与书上的编号不一定一致,只能说大体顺序是和书上一样的。如果因此引起部分读者不适,烦请见谅。
言归正传,线性回归的从零开始实现。
生成数据集
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))
这里是想要根据一个线性模型反向构造数据集,在这里只需要知道这个人造数据集是用来演示的即可。中途因为有不太明白的地方问了下GPT,结果发现没笔者什么事了,以下是它的回答:
def synthetic_data(w, b, num_examples)::
-
定义了一个名为 synthetic_data 的函数,接受三个参数 w、b、和 num_examples。
"""生成y=Xw+b+噪声""":
-
函数的文档字符串,用于简要描述函数的作用。在这里,描述了该函数的目的是生成合成数据,满足模型 y = Xw + b + 噪声。
X = torch.normal(0, 1, (num_examples, len(w))):
-
生成一个服从正态分布(均值为0,标准差为1)的随机张量 X,形状为 (num_examples, len(w))。这里 len(w) 表示生成的随机矩阵的列数。
y = torch.matmul(X, w) + b:
-
根据线性回归模型 y = Xw + b,计算目标变量 y,其中 w 是权重,b 是偏置,X 是特征矩阵。
y += torch.normal(0, 0.01, y.shape):
-
向目标变量 y 添加服从正态分布(均值为0,标准差为0.01)的噪声,以模拟真实数据中的随机误差。
return X, y.reshape((-1, 1)):
-
返回生成的特征矩阵 X 和目标变量 y。注意,y.reshape((-1, 1)) 将 y 变换为列向量,确保返回的目标变量形状正确。
这个函数的主要目的是生成模拟数据,可以用于测试和验证机器学习模型,特别是在涉及线性回归的情况下。
这里是想要根据一个线性模型反向构造数据集,在这里只需要知道这个人造数据集是用来演示的即可。中途因为有不太明白的地方问了下GPT,结果发现没笔者什么事了,以下是它的回答:
def synthetic_data(w, b, num_examples)::
-
定义了一个名为 synthetic_data 的函数,接受三个参数 w、b、和 num_examples。
"""生成y=Xw+b+噪声""":
-
函数的文档字符串,用于简要描述函数的作用。在这里,描述了该函数的目的是生成合成数据,满足模型 y = Xw + b + 噪声。
X = torch.normal(0, 1, (num_examples, len(w))):
-
生成一个服从正态分布(均值为0,标准差为1)的随机张量 X,形状为 (num_examples, len(w))。这里 len(w) 表示生成的随机矩阵的列数。
y = torch.matmul(X, w) + b:
-
根据线性回归模型 y = Xw + b,计算目标变量 y,其中 w 是权重,b 是偏置,X 是特征矩阵。
y += torch.normal(0, 0.01, y.shape):
-
向目标变量 y 添加服从正态分布(均值为0,标准差为0.01)的噪声,以模拟真实数据中的随机误差。
return X, y.reshape((-1, 1)):
-
返回生成的特征矩阵 X 和目标变量 y。注意,y.reshape((-1, 1)) 将 y 变换为列向量,确保返回的目标变量形状正确。
这个函数的主要目的是生成模拟数据,可以用于测试和验证机器学习模型,特别是在涉及线性回归的情况下。
true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)
依据书上的提醒,要注意到features每一行是包含两个数据一维张量,labels每一行为只有一个数据的一维张量。
书上举例:
print('features:', features[0],'\nlabel:', labels[0])
数据可视化:
d2l.set_figsize()
d2l.plt.scatter(features[:,1 ].detach().numpy(), labels.detach().numpy(), 1)
GPT对代码的详解:
d2l.set_figsize():
-
这个函数设置图形的大小。具体来说,它可能是用于设置 Matplotlib 图形的大小,以确保图形在显示时具有合适的尺寸。通常,这种函数会在绘图之前被调用。
d2l.plt.scatter(features[:,1].detach().numpy(), labels.detach().numpy(), 1):
-
d2l.plt.scatter 是一个散点图的绘制函数,通常用于可视化数据。
-
features[:,1].detach().numpy():这部分代码提取了 features 中的第二列数据,并将其转换为 NumPy 数组。detach() 是 PyTorch 中用于创建新的张量,该张量与原始张量共享相同的数据,但不具有梯度的方法。
-
labels.detach().numpy():同样,这部分代码提取了 labels 的数据,并将其转换为 NumPy 数组。
-
1:这个参数表示散点图中点的大小。
所以,这行代码的作用是在散点图上绘制 features 中第二列数据和 labels,其中点的大小为 1。这样的散点图通常用于可视化特征和标签之间的关系。
也可以稍作修改,考察features第一个特征与labels的关系:
读取数据集
def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))#构建大小为num_examples的索引列表,相当于数组
# 这些样本是随机读取的,没有特定的顺序
random.shuffle(indices)#将列表中的数字打乱
for i in range(0, num_examples, batch_size): #每次取batch_size个样本,即为批量
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])#最后一次可能不足一个batch
yield features[batch_indices], labels[batch_indices]
因为要使用小批量随机梯度下降来优化模型参数,所以这里定义一个生成小批量的函数。这里说是可以利用GPU并行计算的优势,让每个小批量并行地进行计算梯度以节省时间,但是笔者并不清楚原理,只能先承认这件事情。
写到这里不妨讲点题外话:笔者是数学专业的,学习数学一般要求严谨,但是即使是数学学习也需要先承认一些事实,等到学习到一定成熟阶段回过头来自然能有一个整体的理解。如果一开始就要求把所有的东西理解清楚是不现实的,因为理论的发展本身就有一个螺旋上升的阶段,很多的理论基础都是在经历了时间洗礼之后总结下来的,初学者想要获得全面的理解几乎是不可能的。就像笔者刚开始学习线性代数,对于矩阵的引入感到十分不解,虽然老师从线性方程组的变量代换的观点来解释矩阵乘法的Well-defined,笔者还是不能完全信服。知道后来系统地学习线性映射以及线性映射的矩阵表示等理论后才理解矩阵的优美,其化抽象为具体的功能令人叹为观止。这里也只是举了一个小例子,也不能代表笔者对这些对象理解得怎么样。写下这些题外话也许只是提醒笔者自己,有一些东西先放在这里,等到哪天要遇到相关问题了,或者自信觉得有新的理解了,要再回来看看。
值得一提的是函数最后用的yield,还是先上GPT:
使用 yield 而不是 return 的关键在于生成器(Generator)的性质。
-
生成器的延迟计算: 使用 yield 创建的函数是一个生成器函数。生成器的特点是它可以延迟计算,即每次调用 yield 时,函数的执行会暂停,并返回一个值给调用者,但函数的状态会保留。当再次调用生成器时,它会从上一次暂停的地方继续执行,直到再次遇到 yield。
-
节省内存: 使用生成器的一个主要优势是它可以在需要时按需生成值,而不是一次性生成所有值。这在处理大量数据时可以节省内存。在这个例子中,data_iter 函数是一个数据迭代器,通过 yield 逐批次产生数据,而不需要将所有数据加载到内存中。
-
方便迭代: 使用生成器可以方便地在迭代过程中产生数据,尤其是当数据太大而无法一次性载入内存时。生成器可以在每次迭代中生成新的一批数据,而不必一开始就将所有数据加载到内存中。
-
无需保存中间结果: 使用 yield 时,不需要将所有中间结果存储在内存中。每个批次的数据被生成后,它不再占用额外的内存。
总的来说,yield 是一种生成器函数的声明方式,适用于需要逐批次生成数据的情况,同时节省内存并能够在迭代过程中动态生成数据。
容易看出这个函数调用一次返回一个批量,而yield的使用保证了在循环中调用这个函数时下一次调用返回下一批。还是举书上的例子:
batch_size = 10
for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y)
break
可以看出只返回了一个批次,因为运行一次就break了。我们可以做点小小修改:
batch_size = 4
i = 0
for X, y in data_iter(batch_size, features, labels):
print(X, '\n', y)
i += 1
if i == 3:
break
可以看出返回了三个不同的批次,与预期是一致的。
初始化参数模型
书上采用的是通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重, 并将偏置初始化为0。但是笔者觉得不妨设为0,刚好这也是一道习题。
w = torch.zeros(2, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
之后就是通过小批量随机梯度下降来更新参数。笔者认为只有在先验地知道参数的一个大致范围时去设置初值才是比较合理的,否则取任何初值其实区别不是很大。当然,从经验风险(即损失函数在数据集上的均值)最小化的目的出发,不同的参数初值的确可能带来不同的局部最优解,所以笔者也承认对于一般的问题参数初值的设置是不能妄下定论的。(当然,这也可能是笔者还有很多东西没有学过所以无法给出合理解释。)所以笔者武断地说:不妨设为0。所幸后面的实验也表明这样的设置的确没什么问题。笔者猜测可能和这只是个简单的线性回归有关。
定义损失函数
def squared_loss(y_hat, y):
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2
定义优化算法
def sgd(params, lr, batch_size):
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()
还是先由GPT逐行解释一遍:
def sgd(params, lr, batch_size)::
-
定义了一个名为 sgd 的函数,接受三个参数:params,表示需要更新的参数;lr,表示学习率;batch_size,表示小批量的大小。
with torch.no_grad()::
-
这是一个上下文管理器,用于禁用梯度计算。在这个上下文中,所有的梯度计算都将被禁用,这对于参数更新步骤是有用的,因为在更新参数时我们不需要计算梯度。
for param in params::
-
遍历所有需要更新的参数。
param -= lr * param.grad / batch_size:
-
对每个参数进行梯度下降更新。param 是一个模型参数,param.grad 存储了该参数的梯度。这一行代码通过梯度下降的更新规则,用学习率 lr 乘以梯度除以批量大小 batch_size 来更新参数。
param.grad.zero_():
-
将参数的梯度清零。这是因为在 PyTorch 中,梯度在每次反向传播后会被累积,而在每个批次更新后,我们需要将梯度清零,以免对下一个批次的梯度计算造成影响。
需要注意的是,书本上对于梯度除以批量大小的解释为“我们计算的损失是一个批量样本的总和,所以我们用批量大小(`batch_size`)来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。”
但是笔者无法信服。梯度是一个局部的概念,其是对于函数局部线性化的体现,而将不同点的梯度做平均笔者不知道其合理性何在。如果有读者能给予指导,笔者感激不尽。笔者自己的理解源自于统计决策理论。虽然笔者目前并不能严格地理解该理论,但是笔者更愿意先承认其正确性。依据笔者对该理论的浅薄认识,损失函数是一种随机变量(在这里是关于X,y_hat的),而损失函数的期望为决策风险,最优决策函数为使得决策风险最小化的X到y_hat之间的函数。而在实际过程中,常常用损失函数在数据集上的平均——即经验风险来近似决策风险。而梯度下降求的也其实是经验风险的梯度,这里取小批量再取平均,笔者以为是用小批量上的期望来近似经验风险。虽然这样也不能说明小批量风险上的期望的梯度就可以近似经验风险的梯度,但是笔者认为这样的解释是符合直觉的,更严谨的理解或许得等到笔者多学点东西才能回过头来看了。
训练
由于是线性回归模型,所以和感知机的训练过程其实蛮像的,但是笔者今天没有精力继续写了,不知不觉时光飞逝。
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}')
现在可以看一下训练得到的参数和原本设计的参数差多少:
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')
可以看出,确实是不妨设为0的。
习题
1. 如果我们将权重初始化为零,会发生什么。算法仍然有效吗?
2. 假设试图为电压和电流的关系建立一个模型。自动微分可以用来学习模型的参数吗?
3. 能基于[普朗克定律](https://en.wikipedia.org/wiki/Planck%27s_law)使用光谱能量密度来确定物体的温度吗?
4. 计算二阶导数时可能会遇到什么问题?这些问题可以如何解决?
5. 为什么在`squared_loss`函数中需要使用`reshape`函数?
6. 尝试使用不同的学习率,观察损失函数值下降的快慢。
7. 如果样本个数不能被批量大小整除,`data_iter`函数的行为会有什么变化? 笔者关心的习题已经体现在上文中,所以就不另外写了。如果有任何错漏之处或者观点不同之处,还请广大读者不吝赐教。