HW1
Date (online)
2023年2月10日
Instructor
Slides
Video
本次作业包含六个问题:
- 问题一为实现基本运算的前向计算,基本调用numpy的api较为容易实现;
- 问题二为实现基本运算的梯度计算,需要注意reshape, transpose, broadcast_to, summation 等操作的梯度计算。
- 问题三为实现拓扑排序。
- 问题四为实现计算图的反向传播过程。
- 问题五位实现softmax损失函数的设计;
- 问题六为实现两层神经网络的设计。
总结一下,这节课的作业其实就是在numpy的基础上,实现一个自动微分机制
问题一Transpose_forward问题二reshape_backwardtranspose_backwardbroadcast_to_backwardsummation_backwardmatmul_backward问题三问题四 Implementing reverse mode differentiation问题五 Softmax loss问题六 SGD for a two-layer neural network注意事项知识点
问题一
实现基础运算的正向计算,即ops.py文件中每个class中的 compute 函数
前向计算的时候,待编写的函数的数据类型均为Ndarray,这一块不涉及节点的创建。直接调用 numpy的api即可。不过,需要注意的是,transpose函数的定义和numpy中Transpose的定义不一样,在实现的时候需要注意。
Transpose_forward
ndl中,transpose的定义是交换给定的两个轴, 即参数axes里面最多两个数值,
例如 axes = [1, 2],则交换第 1 和 2 轴。而且,如果axes为空,则默认交换最后两个轴。
而numpy中对transpose 的参数axes的描述如下:
axes tuple or list of ints, optional
If specified, it must be a tuple or list which contains a permutation
of [0,1,…,N-1] where N is the number of axes of a. The i’th axis of the
returned array will correspond to the axis numbered axes[i] of the input.
If not specified, defaults to range(a.ndim)[::-1], which reverses the
order of the axes.
即首先 参数axes的长度需要与 a.shape 的长度一致, 其次,axes中的某个数值表示了return值在该位置的轴,即参数中的数值代表transpose之后的新的位置,例如 axes = [2,1,0],则表示transpose之后,2轴的位置在最开始,其次是1轴,最后是0轴。可见,表达的不是交换的意思。
最后,默认是完全交换所有轴,即默认axes=list(range(len(a.shape)))[::-1]。
因此,在实现的时候需要注意axes是否为空的情况:
- 当axes 为空时,ndl是想交换最后两个轴,因传入numpy api的参数
axes=list(range(len(a.shape)))[:-2] + list(range(len(a.shape)))[-2::-1]
,即一段不变,最后两个轴交换位置。
- 当axes不为空的时候,其中必定有两个数值,代表需要交换的两个轴。因此,首先令
axes=list(range(len(a.shape)))
,然后令其中对应两个数值的位置交换一下即可。
def compute(self, a): ### BEGIN YOUR SOLUTION _axes = list(range(len(a.shape))) if self.axes: _axes[self.axes[0]], _axes[self.axes[1]] = _axes[self.axes[1]], _axes[self.axes[0]] else: # 默认交换的是最后两个轴 _axes = _axes[:-2] + _axes[-2:][::-1] return array_api.transpose(a, _axes) ### END YOUR SOLUTION
问题二
实现基础运算的反向计算,即
ops.py
文件中每个class
中的 gradient
函数。该函数数据类型均为
ndl.Tensor
,因此在运算的时候会涉及到新节点的创建。关于 reshape、transpose、broadcast_to等,抓住一个核心点,观察size的改变:
- 如果前向计算没有改变tensor的size(即tensor中元素的数量),那么反向回传梯度的时候,只需要改变对应的形状即可。
- 如果改变了size,无非是summary 后减少了,或者broadcast 后变多了。这时需要通过reshape改变ndim(例如,(10,)→(10,1))、broadcast 来增加size、summary 来减少size。
reshape_backward
reshape是不会改变Tensor的size的,因此梯度回传的时候,只需要再reshape回去即可。
transpose_backward
transpose 也是不会改变 Tensor 的 size的,因此梯度回传的时候,只需要再transpose 回去即可。
broadcast_to_backward
boardcast 会改变 数据的size,且通常会有两种case:
- ndim 不变的,仅仅改变 tensor 的shape,如shape 从(10, 1) 到(10, 5) 。且第二维只能从 1到5,不可能从 2到5。
- ndim也改变的,如 shape从 (10, 2)到(5, 10, 2)
对 broadcast的特点说明如下:
- broadcast 后,如果没有改变ndim,那么 shape 改变的那部分,只能是从1到别的数值。
- broadcast 后,如果改变了ndim,那么 多出来的轴一定在前面。例如 (10, 2)到(5, 10, 2) 多出来轴在最开始。
broadcast 后,梯度回传 就是把shape上多出来的 size 累加起来,因此,需要统计 哪些 axes 发生了改变,如果 ndim 增多了,那么 range(len(a_.shape) - len(a.shape)) 即可代表多出来的axes,然后再倒着判断 shape 中的数值,看 前后是否不一致,如果不一致,那么就是需要累计的轴;
def gradient(self, out_grad, node): ### BEGIN YOUR SOLUTION ori_shape = node.inputs[0].shape # 存储待压缩的轴 axes_ = [] # 1. 处理 ndim 的 broadcast, 这里 broadcast 增加的dim 必定是在 较低的轴,所以计算出来增加了几个,然后从0到几就是了 axes_ += list(range(len(self.shape)-len(ori_shape))) # 2, 处理形状的 broadcast,倒着往回检查不一样的 for i in range(-1, -len(ori_shape)-1, -1): # 不相同,必定是 broadcast了 if ori_shape[i] != self.shape[i]: assert ori_shape[i] == 1 axes_.append(i) axes_ = tuple(axes_) return reshape(summation(out_grad, axes=axes_), ori_shape)
summation_backward
summaion之后,ndim会变少,所以size也会变少。因此需要用到broadcast来增加size的数量。
假设原先 a.shape = (5, 10, 2),经过 b = summation(a, axes=(1)) 之后,b.shape = (5, 2), 从后面传回来的梯度 out_grad.shape =(5, 2)。 此时如何计算a的梯度呢?
经过summation运算后,size变少了,所以需要用到broadacat,但是boradcast无法直接把 (5, 2) 变为 (5, 10, 2),因此,先把(5, 2) reshape 为(5, 1, 2),然后再broadcast。
注意,如果在summation的时候,axes为空,那么等价于axes = tuple(range(a.ndim))。有了做summation时候的axes,在第一步reshape的时候,只需要把原始a.shape上该axes 轴的数值变为1即可。然后再broadcast_to 原始的shape
def gradient(self, out_grad, node): ### BEGIN YOUR SOLUTION ori_shape = node.inputs[0].shape # temp_shape 用来保存 做了sum但是keepdim住的shape, 因为要利用该shape做广播 # 如果 self.axes 不存在,则最后的维度为(1,),此时keepdim住的shape: temp_shape = [1] * len(sum之前的dims) # 如果 self.axes 存在,那么此时keepdim住的shape: 初始shape中 self.axes对应轴变为 1 temp_shape = list(ori_shape) if self.axes: for i in self.axes: temp_shape[i] = 1 else: for i in range(len(temp_shape)): temp_shape[i] = 1 # 不涉及 out_grad.size 的改变 temp_node = reshape(out_grad, temp_shape) # 2.booadcast: 涉及到 out_grad.size 的改变 return broadcast_to(temp_node, ori_shape) ### END YOUR SOLUTION
matmul_backward
矩阵乘法的梯度回传,通过交换乘法顺序以及转置保证shape就好了。这里需要注意的是,batch_matrix的矩阵乘法,如果 ndim 不一样,把多出来的 dim 对应的轴sum起来 ,多出来的轴一定是前面的。
def gradient(self, out_grad, node): ### BEGIN YOUR SOLUTION lhs, rhs = node.inputs l_grad = matmul(out_grad, transpose(rhs)) r_grad = matmul(transpose(lhs), out_grad) # 如果 ndim 不一样,把多出来的 dim 对应的轴sum起来 ,多出来的轴一定是前面的 # Tensor没有实现 .ndim 函数,因此只能通过 len(a.shape) 来得到 ndim l_grad_ndim, r_grad_ndim = len(l_grad.shape), len(r_grad.shape) lhs_ndim, rhs_ndim = len(lhs.shape), len(rhs.shape) if l_grad_ndim > lhs_ndim: axes = tuple(range(l_grad_ndim - lhs_ndim)) l_grad = summation(l_grad, axes) if r_grad_ndim > rhs_ndim: axes = tuple(range(r_grad_ndim - rhs_ndim)) r_grad = summation(r_grad, axes) return l_grad, r_grad ### END YOUR SOLUTION
问题三
实现拓扑排序。题目要求了只能通过后续遍历的方式来实现。如果用BFS来实现会无法通过测试案例。
此处的计算图 其实 就是一个二叉树!!最后的节点为 根节点,让根节点存放了子节点。
思考一下,此处后续遍历的拓扑排序为什么是唯一的,是因为此处dfs的拓扑排序其实就是二叉树的后续遍历。但是BFS的拓扑排序不是唯一的,因为叶子节点数量不止一个。
def find_topo_sort(node_list: List[Value]) -> List[Value]: """Given a list of nodes, return a topological sort list of nodes ending in them. A simple algorithm is to do a post-order DFS traversal on the given nodes, going backwards based on input edges. Since a node is added to the ordering after all its predecessors are traversed due to post-order DFS, we get a topological sort. """ ### BEGIN YOUR SOLUTION topo_order = [] visited = set() for cur_node in node_list: topo_sort_dfs(cur_node, visited, topo_order) return topo_order ### END YOUR SOLUTION def topo_sort_dfs(node, visited, topo_order): """Post-order DFS""" ### BEGIN YOUR SOLUTION if node in visited: return for in_node in node.inputs: topo_sort_dfs(in_node, visited, topo_order) topo_order.append(node) visited.add(node) ### END YOUR SOLUTION
问题四 Implementing reverse mode differentiation
实现计算图的反向传播。按照上一步得到的拓扑排序的逆序列表,来遍历即可。
for node in reverse_topo_order: # 先计算当前节点 v_i 的梯度 = sum(node_to_output_grads_list[node]) node.grad = sum(node_to_output_grads_list[node]) # 叶子节点不再继续回传了 if node.is_leaf(): continue # 再计算 v_{j->i} 的梯度 in_nodes_grads = node.op.gradient_as_tuple(node.grad, node) # 最后 分别把 v_{j->i} 赋给 node_to_output_grads_list[v_j] for in_node, grad in zip(node.inputs, in_nodes_grads): # The gradient of an operation node_to_output_grads_list[in_node].append(grad) ### END YOUR SOLUTION
问题五 Softmax loss
实现 softmax loss 的计算,区别于hw0,此处的参数 y 为onehot 之后的矩阵。
l = ndl.log(ndl.summation(ndl.exp(Z), axes=(1,))) r = ndl.summation(ndl.multiply(Z, y_one_hot), axes=(1,)) return ndl.divide_scalar(ndl.summation(l - r), l.shape[0])
问题六 SGD for a two-layer neural network
基本没啥难度,需要注意的是:
- 需要把 类别标签转为 onehot 矩阵。
- 参数更新的时候,要么只更新 Tensor里面的 data,要么直接换一个Tensor,不必要对参数的Tensor 进行 -= 操作,因为 Tensor的运算会创建出来计算图。
for i in range(0, X.shape[0], batch): X_ = X[i: i+batch] y_ = y[i: i+batch] X_ = ndl.Tensor(X_, dtype="float32") Z = ndl.matmul(ndl.relu(ndl.matmul(X_, W1)), W2) y_one_hot = np.zeros(Z.shape) y_one_hot[np.arange(y_.shape[0]), y_] = 1. y_one_hot = ndl.Tensor(y_one_hot, dtype='int8') loss = softmax_loss(Z, y_one_hot) loss.backward() W1 = ndl.Tensor(W1.realize_cached_data() - lr * W1.grad.realize_cached_data(), dtype="float32") W2 = ndl.Tensor(W2.realize_cached_data() - lr * W2.grad.realize_cached_data(), dtype="float32") return W1, W2
注意事项
- 在读取数据parse_mnist函数中,数据类型只能的B,不能是b。B表示 unsigned char, b 表示 signed char。如下代码
struct.unpack(f'>{row_num * cols_num}B', img_file.read(row_num * cols_num))
- Tensor 类 没有实现 ndim 方法,需要手动 通过 len(a.shape) 来计算ndim
知识点
- sum() 这个 python 内置的方法,参数为一个可迭代容器,会遍历相加,具体相加的方法 是由容器内 数据类型决定的。例如,容器内为numpy.ndarray 类型,那么就会执行 numpy中的 ndarray 对象加法函数。
- 每个节点的inputs的长度 一定是和 该节点 gradient返回的节点数量相同的。
- 在 某个节点 输入为 tensor和ndarray 的运算中,计算完成后,存储下来的输入只有tenosr,回传梯度的时候,也只返回一项。
- 在 和标量的计算中,标量的数据类型其实是 ndarray。这类标量不会被存储在计算图中。
- 在 多个(大于2)Tensor的计算中,永远是两两计算的,即先计算两个tenosr的结果,然后得到一个 节点,再用这个得到的节点来和另外的 计算。