CMU10-414/714 Deep Learning Systems 第一次作业解析

最近要开始刷CMU的 10-414/714: Deep Learning Systems 这门课了,之前其实就看了个开头,因为自己就是希望未来可以多研究研究关于System方面的内容,因此这门课可以说是必须刷的了。目前只看了前面5节的内容,已经足以做第一次的作业了。因此开了个仓库用来管理作业了,官方的是每次作业都单独一个库,自己学的话还是放一块比较方便,而且因为现在官方已经不提供评分系统的注册渠道了,因此作业里面的关于分数提交的内容已经没用了,本人在作业模板里把相关的代码都删掉了,这样只保留实际作业相关的题目,会比较适合阅读但实际并不影响代码测试。除此之外我还将作业里面的内容翻译成了中文,也是为了方便学习的时候看着方便。

准备

第一次作业的入口在 homeworks/hw0/hw0.ipynb 这里,推荐使用 colab 平台来完成,可以省去很多环境上的麻烦,我这里把第一个单元格的代码调整成clone我自己的仓库的内容,如果想自己学习的可以fork后换成自己的仓库地址:

# Code to set up the assignment
from google.colab import drive
drive.mount('/content/drive')

# %cd /content/drive/MyDrive/
# !mkdir -p 10714
# %cd /content/drive/MyDrive/10714
# !git clone https://github.com/careywyr/dlsyscourse.git

%cd /content/drive/MyDrive/10714/dlsyscourse/homeworks/hw0

这里有几行注释掉的代码,首次使用的时候执行就可以了,之后再次继续写作业的话就不用执行了,这里只是创建文件夹和clone仓库,因此后面再进来的时候直接cd到作业根目录即可。

这个 hw0.ipynb 虽然是作业的入口,但实际上写作业的位置是src下面的两个文件,一个是python一个是cpp,我们要做的就是修改这两个文件的内容,然后在 notebook里面运行对应的cell 查看结果。

问题1就是用来测试一下当前环境是否ok,实现一个add方法,只需要在 simple_ml.py 文件里找到 add(x, y) 这个方法,补全代码实现后,运行对应的测试单元格就可以看到成功的结果了。

!python3 -m pytest -k "add"
======================================= test session starts ======================================== 
platform linux -- Python 3.10.12, pytest-7.4.4, pluggy-1.5.0 
rootdir: /content/drive/MyDrive/10714/dlsyscourse/homeworks/hw0 
plugins: typeguard-4.3.0, anyio-3.7.1 
collected 6 items / 5 deselected / 1 selected 
tests/test_simple_ml.py . [100%] 
================================= 1 passed, 5 deselected in 0.40s ==================================

本次作业的主要目标是实现一个基础的 softmax 回归算法,以及一个简单的两层神经网络。 使用的数据集是 MNIST 手写数字识别的数据集,数据集的读取也需要自己实现,在 parse_mnist 方法中,这个数据集的读取方法比较特殊,推荐是使用 struct库,但也可以直接使用gzip和numpy实现。这里代码我就不贴了,不是算法的主要内容,输出的参数有两个:

  • X 是形状为 (num_images, 784) 的图像矩阵,归一化为 [0.0, 1.0]
  • y 是形状为 (num_labels,) 的标签数组,表示每张图像的类别。

接下来的内容是作业里面主要的题目,建议自己先看看题目动手试试再来阅读哦,代码我也不会全部贴在文章中,主要是讲一下解题的逻辑。

Softmax Loss

问题3是要实现 softmax_loss 方法,即计算Softmax的损失,输入的两个参数分别是

  • Z:  logits 的二维数组,维度是(batch_size, num_classes)
  • y: 对应的真实标签的一维数组(batch_size, )

输出是整个批次的平均 softmax 损失。

题目描述中其实已经给出了计算公式:

$$
\begin{equation}
\ell_{\mathrm{softmax}}(z, y) = \log\sum_{i=1}^k \exp z_i - z_y.
\end{equation}
$$

直接根据公式写代码自然是简单的,想写好看点就直接用一行代码:

return np.mean(np.log(np.sum(np.exp(Z), axis=1)) - Z[np.arange(Z.shape[0]), y])

这里的 np.arange() 方法是个很好用同时也是经常会用到的方法,它可以用于生成一个包含等间隔数值的数组。其名称来源于 "array range"。

np.arange([start, ]stop, [step, ]dtype=None)
  • start(可选):数组生成的起始值。默认为 0。
  • stop:数组生成的终止值(不包括此值)。
  • step(可选):数组中数值之间的步长,默认为 1。
  • dtype(可选):生成数组的类型。如果不指定,将根据起始值和步长的类型自动推断。

那在我们这行代码里起到的作用就是:

  • np.arange(Z.shape[0]) 生成一个从 0 到 Z.shape[0] - 1 的数组(即每个样本的索引)。
  • Z[np.arange(Z.shape[0]), y] 的作用是从 logits 矩阵 Z 中取出每一行中真实标签 y 所对应的 logit 值。这是通过行索引 np.arange(Z.shape[0]) 和列索引 y 实现的。

讲完了代码,也要思考下,也不要忘了想想这个公式是怎么来的,我们知道,Softmax的公式如下:

$$
\text{softmax}(z_i) = \frac{\exp(z_i)}{\sum_{j=1}^k \exp(z_j)}
$$

那么结合交叉熵损失(-log(p_{y_i}))的公式如下:

$$
L_{\mathrm{cross_entropy}}(z, y) = -\log \left( \frac{\exp(z_y)}{\sum_{i=1}^k \exp(z_i)} \right)
$$

其中 z_y 表示预测中与真实标签 y 对应的 logit 值。这是通过先计算 softmax 后取 log 再结合交叉熵损失的标准公式。这个公式就可以化简为:

$$
L_{\mathrm{cross_entropy}}(z, y) = \log \sum_{i=1}^k \exp(z_i) - z_y
$$

这就是题目中给出的公式了。

softmax 回归的随机梯度下降

接下来第四题,要实现的是 softmax_regression_epoch 方法,即一个进行 softmax 回归的随机梯度下降(SGD)单轮次训练的方法。简单来说,就是循环每个batch,计算梯度,更新 theta。

像这种要手动实现底层的一些算法的时候,很需要注意的一点就是每个参数的shape,弄错了可能就无法进行计算了,其实就是下面这三个:

  • X: (num_examples, input_dim)
  • y: (num_examples,)
  • theta: (input_dim, num_classes)

所以计算logits的时候,虽然题干上面写着:
h(x) = \Theta^T x
但仔细看我们theta和X的维度,别直接就这么写了,实际上是 x\theta ,维度是(num_examples, num_classes),写代码的时候注意顺序。

那么第二步就是带入Softmax公式,不过为了防止数值溢出,在计算 softmax 概率之前可以先减去每一行的最大值,是一种常见的数值稳定技巧,这里不多赘述。

梯度计算

然后我们来求梯度,梯度本质上就是这个batch的loss对权重theta的导数,我们这里的h(x)=x\theta, y转换成独热编码e_y, 刚刚输出的logits定义为z, 那么要计算的梯度其实就是:

$$
\begin{align}
\frac{\partial}{\partial\theta}\ell_{ce}(z,y) = \frac{\partial \ell_{ce}}{\partial z} \frac{\partial z}{\partial \theta}
\end{align}
$$

那么我们先求\frac{\partial \ell_{\text{ce}}}{\partial z} :

根据交叉熵损失函数的定义,可以表示为:

$$
\ell_{\text{ce}} = -\log(\text{softmax}(z_y)) = -\log \left( \frac{\exp(z_y)}{\sum_{k=1}^{K} \exp(z_k)} \right)
$$

这里的 z_y 是对应于真实类别 y 的 logit。我们定义p_y=softmax(z_y) ,那么:

$$
\ell_{ce} = -log(p_y)
$$

我们希望计算交叉熵损失函数对 logits $zj的导数,即\frac{\partial \ell{\text{ce}}}{\partial z_j}$ , 推理过程如下:

Step 1: 对真实类别 z_y 求导

先考虑当 j = y 时,也就是对真实类别 z_y 求导:

$$
\ell_{\text{ce}} = -\log \left( p_y \right)
$$

z_y 求导时,我们使用链式法则:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_y} = - \frac{1}{p_y} \cdot \frac{\partial p_y}{\partial z_y}
$$

因此,整个导数为:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_y} = - \frac{\sum_{k=1}^{K} \exp(z_k)}{\exp(z_y)} \cdot \frac{\exp(z_y) \cdot \sum_{k=1}^{K} \exp(z_k) - \exp(z_y) \cdot \exp(z_y)}{\left( \sum_{k=1}^{K} \exp(z_k) \right)^2}
$$

化简后,我们可以得到:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_y} = - \left( 1 - \frac{\exp(z_y)}{\sum_{k=1}^{K} \exp(z_k)} \right) = \frac{\exp(z_y)}{\sum_{k=1}^{K} \exp(z_k)} - 1
$$

即:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_y} = p_y - 1
$$

Step 2: 对非真实类别 z_j 求导

接下来,考虑当 j \neq y 时,对非真实类别 z_j 求导, 同样使用链式法则, 由于 j \neq y,分子 \exp(z_y) 的导数为 0,唯一的变化发生在分母上:

$$
\frac{\partial}{\partial z_j} \sum_{k=1}^{K} \exp(z_k) = \exp(z_j)
$$

因此,导数为:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_j} = \frac{\exp(z_j)}{\sum_{k=1}^{K} \exp(z_k)} = p_j
$$

3. 两个类别总结

综上所述,交叉熵损失对 logits z_j 的导数可以表示为:

  • 对于真实类别 j = y

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_y} = p_y - 1
$$

  • 对于非真实类别 j \neq y

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_j} = p_y
$$

这可以统一写为:

$$
\frac{\partial \ell_{\text{ce}}}{\partial z_j} = p_j - e_{y_j}
$$

其中,$e_{yj}是一个独热编码向量,当j = ye{yj} = 1,否则e{y_j} = 0$。

4. 乘以z对theta的偏导

计算了乘法的左边,别忘了还有个\frac{\partial z}{\partial \theta} , 因为 z=x\theta, 所以这里等于x。汇总一下就是:

$$
\begin{align}
\frac{\partial}{\partial\theta}\ell_{ce}(z,y) &= \frac{\partial \ell_{ce}}{\partial z} \frac{\partial z}{\partial \theta} \
&= (p - e_y)x \
&= x^T(p-e_y)
\end{align}
$$

最后梯度计算完成后就是更新权重theta,即减去学习率乘以梯度。这样一个batch里面的逻辑就完成了。

用于两层神经网络的随机梯度下降

接下来是第五题,实现 nn_epoch 这个方法,即用于两层神经网络的随机梯度下降。总的思路很简单,就是先前向传播求输出,然后再反向传播求梯度更新权重。题目给出的第一层的激活是ReLU,最终的输出依然是softmax。

前向传播和y转成one-hot很简单就不多说了,我们直接看反向传播。

实际上反向传播的第一步在上面一题我们已经解决了,已知:

$$
\frac{\partial \ell_{ce}}{\partial z} = p - e_y
$$

其中 z 就是对应于真实类别 y 的 logit ,那么G2实际上就是 所有样本梯度的平均值,即:

$$
G2 = \frac{p-e_y}{batch}
$$

接下来求第一层的梯度G1,因为这里的反向传播考虑的事ReLU的导数,而ReLU的导数是:

$$
\frac{d \text{ReLU}(z)}{dz} =
\begin{cases}
1 & \text{if } z > 0 \
0 & \text{if } z \leq 0
\end{cases}
$$

所以G1的公式为:

$$
G1 = (G2 \cdot W2^T) \cdot \mathbb{I}(Z1 > 0)
$$

代码表示为: G1 = (G2 @ W2.T) * (Z1 > 0)

之后就是正常的更新权重W2和W1,但这里要记得,先计算出G2和G1的值,再进行更新,而不是计算一个更新一个。

G2 = (softmax_output - y_one_hot) / batch
G1 = (G2 @ W2.T) * (Z1 > 0)

# 计算梯度并更新W2
grad_W2 = Z1.T @ G2
W2 -= lr * grad_W2

# 计算梯度并更新W1
grad_W1 = X_batch.T @ G1
W1 -= lr * grad_W1

本次作业的最后一题是用c++重写softmax_regression_epoch 方法,我不怎么会C++,用的ChatGPT进行的转写,因此就不介绍如何实现的了,但学习这门课我应该还是得去补习一下C++的知识,希望后面能独立完成C++部分的作业。

总结

第一次作业的内容主要还是对于神经网络的前向反向传播的一个简单的回顾,只能说是热身活动,这门课程的后续目标实际上是实现一个与 PyTorch 略为相似的深度学习库 needle , 难度会越来越高,我也会随着学习进度的推进继续更新作业解析系列,课程里的重要知识点也可能会单独写文章来进行分享。除此之外,最近也在读鱼书四件套,算是为了给自己把深度学习这块的知识进行一个完整的归纳总结,不然总感觉自己哪里都会一点,但真的细说起来又讲不好,目前已经粗略的过了一遍第一本,这里埋个坑,后面我会针对这套书以及此前学习过的课程来尝试汇总成一套知识框架,方便自己来打造深度学习技术方面的思维体系,同时也会分享出来,希望大家继续关注我的博客更新。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇