引言

本着“凡我不能创造的,我就不能理解”的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导。

要深入理解深度学习,从零开始创建的经验非常重要,从自己可以理解的角度出发,尽量不适用外部完备的框架前提下,实现我们想要的模型。本系列文章的宗旨就是通过这样的过程,让大家切实掌握深度学习底层实现,而不是仅做一个调包侠。
本系列文章首发于微信公众号:JavaNLP

在前面的文章中,我们实现了反向传播的模式。并实现了加法和乘法的计算图。但是这种实现方式有一些弊端,本文就来优化实现反向传播模式的代码,同时修复加法和乘法计算图实现的问题。

优化反向传播代码

在上篇文章中,我们将_FunctionTensor放到了同一个文件中,这不符合单一职责模式。同时在实现Tensor的加法和乘法时,我们需要手动添加很多代码,这也不优雅。

def __add__(self, other):ctx = Add(self, ensure_tensor(other))return ctx.apply(ctx, self, ensure_tensor(other))def __mul__(self, other):ctx = Mul(self, ensure_tensor(other))return ctx.apply(ctx, self, ensure_tensor(other))

首先,我们把与_Function相关的代码移动到新文件ops.py中:

from typing import Any
import numpy as npfrom core.tensor import Tensor'''
ops.py保存所有运算操作相关的类
'''class _Function:def __init__(self, *tensors: "Tensor") -> None:# 该操作所依赖的所有输入self.depends_on = [t for t in tensors]# 保存需要在backward()中使用的Tensor或其他对象(如Shape)self.saved_tensors = []def __new__(cls, *args, **kwargs):'''__new__是静态方法,当该类被实例化时调用'''# 把以下方法转换为静态方法,我们可以通过类名直接调用cls.forward = staticmethod(cls.forward)cls.backward = staticmethod(cls.backward)cls.apply = staticmethod(cls.apply) # 新增return super().__new__(cls)def save_for_backward(ctx, *x: Any) -> None:ctx.saved_tensors.extend(x)def forward(ctx, *args: Any, **kwargs: Any) -> np.ndarray:'''前向传播,进行真正运算的地方'''raise NotImplementedError("You must implement the forward function for custom Function.")def backward(ctx, grad: Any) -> Any:'''实现反向传播,计算梯度'''raise NotImplementedError("You must implement the backward method for your custom Function ""to use it with backward mode AD.")def apply(fxn, *xs: "Tensor", **kwargs) -> "Tensor":'''与PyTorch一样,我们也不直接调用forward,而是调用此方法'''# 先调用构造函数,传入运算依赖的Tensorctx = fxn(*xs)  # 调用到了_Function的__init__方法# [t.data for t in xs]遍历Tensor中的data(np.ndarray)值,参与实际计算的都是NumPy的数组。ret = Tensor(ctx.forward(ctx, *[t.data for t in xs], **kwargs),requires_grad=any([t.requires_grad for t in xs]))if ret.requires_grad:ret._ctx = ctxreturn retclass Add(_Function):def forward(ctx, x: np.ndarray, y: np.ndarray) -> np.ndarray:'''实现 z = x + y ,我们这里的x和y都是Numpy数组,因此可能发生广播,在实现反向传播是需要注意'''# 我们只要保存输入各自的形状即可ctx.save_for_backward(x.shape, y.shape)# 进行真正的运算return x + ydef backward(ctx, grad: Any) -> Any:# 输入有两个,都是需要计算梯度的,因此输出也是两个return grad, gradclass Mul(_Function):def forward(ctx, x: np.ndarray, y: np.ndarray) -> np.ndarray:'''实现 z = x * y'''# 乘法需要保存输入x和y,用于反向传播ctx.save_for_backward(x, y)return x * ydef backward(ctx, grad: Any) -> Any:x, y = ctx.saved_tensors# 分别返回∂L/∂x 和 ∂L/∂yreturn grad * y, grad * x

同时修改apply方法为:

    def apply(fxn, *xs: "Tensor", **kwargs) -> "Tensor":'''与PyTorch一样,我们也不直接调用forward,而是调用此方法'''# 先调用构造函数,传入运算依赖的Tensorctx = fxn(*xs)  # 调用到了_Function的__init__方法# [t.data for t in xs]遍历Tensor中的data(np.ndarray)值,参与实际计算的都是NumPy的数组。ret = Tensor(ctx.forward(ctx, *[t.data for t in xs], **kwargs),requires_grad=any([t.requires_grad for t in xs]))if ret.requires_grad:ret._ctx = ctxreturn ret

将该方法改为静态方法,同时增加了ctx = fxn(*xs)这一句,在该方法实例化Function对象,传入该运算所依赖的输入。

为了避免我们手动添加__add____mul_这些实现。我们利用inspect类去自动注册相应的魔法方法。

def register(name, fxn):print(f"register {name} : {fxn}")def dispatch(*xs, **kwargs):# 把所有的输入都转换为Tensorxs = [ensure_tensor(x) for x in xs]# 调用apply方法return fxn.apply(fxn, *xs, **kwargs)# 为Tensor添加属性,名为name,值为dispatch函数引用setattr(Tensor, name, dispatch)# 这几个方法都有__xx__, __ixx__, __rxx__ 魔法方法if name in ["add", "sub", "mul", "matmul"]:setattr(Tensor, f"__{name}__", dispatch)setattr(Tensor, f"__i{name}__", lambda self, x: self.assign(dispatch(self, x)))  # __i*__ 代表原地操作setattr(Tensor, f"__r{name}__", lambda self, x: dispatch(x, self))  # __r*__ 代表 other在操作符前, self在操作符后def _register_ops(namespace):for name, cls in inspect.getmembers(namespace, inspect.isclass):if name[0] != "_" and name != 'Tensor':# 注册所有_Function的子类register(name.lower(), cls)try:_register_ops(importlib.import_module("core.ops"))
except ImportError as e:print(e)

此时当我们初始化Tensor的时候,它会打印:

register add : <class 'core.ops.Add'>
register mul : <class 'core.ops.Mul'>

比如对于add,这段代码会把__add____iadd____radd__add绑定到其内部的dispatch方法。

该方法主要做了两件事,第一,统一把所有的输入转换为Tensor;第二,调用apply静态方法。

优化完了之后,我们得试一下还能正常使用么。

但是,这次博主不想写一个main方法了,而是写一些测试用例。并且,以后所有的代码提交都走PR,利用github的action机制,只有测试通过的PR,才能合入主分支。

编写测试用例

用一种比较简单的方法,就是创建以test开头的文件,同时里面的函数也是以test开头,idea会自动识别为测试用例,如下图所示:

我们分别测试标量的加法、同shape向量的加法以及广播情况下向量的加法。

from core.tensor import Tensor
import numpy as npdef test_simple_add():x = Tensor(1, requires_grad=True)y = 2z = x + yz.backward()assert x.grad.data == 1.0def test_array_add():x = Tensor([1, 2, 3], requires_grad=True)y = Tensor([4, 5, 6], requires_grad=True)z = x + yassert z.data.tolist() == [5., 7., 9.]# 如果z.backward([1, 1, 1])assert x.grad.data.tolist() == [1, 1, 1]assert y.grad.data.tolist() == [1, 1, 1]x += 1assert x.grad is Noneassert x.data.tolist() == [2, 3, 4]def test_broadcast_add():"""测试当发生广播时,我们的代码还能表现正常吗。对于 z = x + y如果x.shape == y.shape,那么就像上面的例子一样,没什么问题。如果x.shape == (2,3)  y.shape == (3,) 那么,根据广播,先会在y左边插入一个维度1,变成 -> y.shape == (1,3)接着,在第0个维度上进行复制,使得新的维度 y.shape == (2,3)这样的话,对x求梯度时,梯度要和x的shape保持一致;对y求梯度时,也要和y的shape保持一致。"""x = Tensor(np.random.randn(2, 3), requires_grad=True)  # (2,3)y = Tensor(np.random.randn(3), requires_grad=True)  # (3,)z = x + y  # (2,3)z.backward(Tensor(np.ones_like(x.data)))  # grad.shape == z.shapeassert x.grad.data.tolist() == np.ones_like(x.data).tolist()assert y.grad.data.tolist == [2, 2]

分别执行每一个测试用例,第一个没有问题:

test_add.py::test_simple_add PASSED                                      [100%]

第二个测试方法报错了:

>           grads = t._ctx.backward(t._ctx, t.grad.data)
E           AttributeError: 'list' object has no attribute 'data'../../core/tensor.py:177: AttributeError============================== 1 failed in 0.38s ===============================

哦,我们要确保backward()方法传入的gradTensor对象。

所以,我们修改下对应的backward()代码:

        # 如果传递过来的grad为空if grad is None:if self.shape == ():# 设置梯度值为1,grad本身不需要计算梯度self._grad = Tensor(1)else:# 如果当前Tensor得到不是标量,那么grad必须制定raise RuntimeError("grad must be specified for non scalar")else:self._grad = ensure_tensor(grad)

此时它也通过了:

test_add.py::test_array_add PASSED                                       [100%]

第三个测试方法又没通过:

# t.shape要和grad.shape保持一致
>                   assert t.shape == g.shape, f"grad shape must match tensor shape in {self._ctx!r}, {g.shape!r} != {t.shape!r}"
E                   AssertionError: grad shape must match tensor shape in <core.ops.Add object at 0x7fcda8b31c10>, (2, 3) != (3,)

说的是,梯度形状不一致的问题。我们知道,梯度的形状要和输入保持一致。

对于 z = x + y,如果x.shape == y.shape,那么就像上面的例子一样,没什么问题;

如果x.shape == (2,3) y.shape == (3,) 那么,根据广播,先会在y左边插入一个维度1,变成 -> y.shape == (1,3),接着,在第0个维度上进行复制,使得新的维度 y.shape == (2,3)。这样的话,对x求梯度时,梯度要和xshape保持一致;对y求梯度时,也要和yshape保持一致。

修复广播带来的问题

由于要保证梯度的维度和输入的维度一致,而最后得到的梯度是经过了广播操作的。所以,我们要实现广播操作的逆操作:

def unbroadcast(grad: np.ndarray, in_shape: Tuple) -> np.ndarray:'''广播操作的逆操作,确保grad转换成in_shape的形状Args:grad: 梯度in_shape: 梯度要转换的形状Returns:'''# 首先计算维度个数之差ndims_added = grad.ndim - len(in_shape)# 由于广播时,先从左边插入,再进行复制,所以逆操作时,也从左边开始,进行复制的逆操作(求和)for _ in range(ndims_added):# 在axis=0上进行求和,去掉第0个维度,如果ndims_added > 1,就需要不停的在第0个维度上面求和grad = grad.sum(axis=0)return grad

这样,假设输入的维度是(3,)(3,)(3,),梯度的维度(2,3)(2,3)(2,3)。那么上面的代码,首先计算出维度个数差值为111。

然后grad.sum(axis=0),把梯度的维度(2,3)→(3,)(2,3) \rightarrow (3,)(2,3)→(3,)。此时刚好和输入的维度一致。我们的这个测试用例应该可以跑通。

test_add.py::test_broadcast_add PASSED                                   [100%]

我们再写一个测试方法:

def test_broadcast_add2():x = Tensor(np.random.randn(2, 3), requires_grad=True)  # (2,3)y = Tensor(np.random.randn(1, 3), requires_grad=True)  # (1,3)z = x + y  # (2,3)z.backward(Tensor(np.ones_like(x.data)))  # grad.shape == z.shapeassert x.grad.data.tolist() == np.ones_like(x.data).tolist()assert y.grad.data.tolist() == (np.ones_like(y.data) * 2).tolist()

然后跑跑看:

>                   assert t.shape == g.shape, f"grad shape must match tensor shape in {self._ctx!r}, {g.shape!r} != {t.shape!r}"
E                   AssertionError: grad shape must match tensor shape in <core.ops.Add object at 0x000002489FCC85B0>, (2, 3) != (1, 3)..\..\core\tensor.py:190: AssertionError

从零实现深度学习框架——优化反向传播相关代码相关推荐

  1. python学习框架图-从零搭建深度学习框架(二)用Python实现计算图和自动微分

    我们在上一篇文章<从零搭建深度学习框架(一)用NumPy实现GAN>中用Python+NumPy实现了一个简单的GAN模型,并大致设想了一下深度学习框架需要实现的主要功能.其中,不确定性最 ...

  2. 【李宏毅机器学习2021】Task04 深度学习介绍和反向传播机制

    [李宏毅机器学习2021]本系列是针对datawhale<李宏毅机器学习-2022 10月>的学习笔记.本次是对深度学习介绍和反向传播机制的学习总结.本节针对上节课内容,对batch.梯度 ...

  3. 从零实现深度学习框架——深入浅出Word2vec(下)

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导. 要深入理解深度学 ...

  4. 从零实现深度学习框架——GloVe从理论到实战

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.

  5. 从零实现深度学习框架——Seq2Seq从理论到实战【实战】

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.

  6. 从零实现深度学习框架——RNN从理论到实战【理论】

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.

  7. 从零实现深度学习框架——从共现矩阵到点互信息

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.

  8. 从零实现深度学习框架——LSTM从理论到实战【理论】

    引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.

  9. 机器学习(深度学习)中的反向传播算法与梯度下降

    这是自己在CSDN的第一篇博客,目的是为了给自己学习过的知识做一个总结,方便后续温习,避免每次都重复搜索相关文章. 一.反向传播算法 定义:反向传播(Backpropagation,缩写为BP)是&q ...

  10. Datawhale 7月学习——李弘毅深度学习:深度学习介绍和反向传播机制

    前情回顾 机器学习简介 回归 误差与梯度下降 1 深度学习简介 1.1 深度学习的历史 李宏毅老师带我们简要回顾了深度学习的历史. 1958: Perceptron (linear model) 19 ...

最新文章

  1. 高度可扩展的类脑神经拟态硬件,完成了字母识别和人脸识别
  2. java中对象的序列化和反序列化
  3. jQuery.sap.getModulePath(cus.crm.opportunity.css.Opportunity, .css)
  4. 关于 lockfree 算法
  5. 流体式布局与响应式布局_将固定像素设计转换为流体比例布局
  6. 正则表达式验证六位数以上数字,符号,字母任意两种混合的密码验证策略
  7. WinDbg配置与下载 (转载)
  8. LeetCode--042--接雨水(java版)
  9. java log4j trace_关于LOG4J中的日志级别TRACE
  10. Sublime text3 Version 3.2.1 3207 和 3.2.2 3211(2019-11-06亲测有效)
  11. 基于JESD204B的LMK04826时钟芯片开发笔记
  12. 新手如何建立网站,网站建设的几个步骤。
  13. ubuntu安装中文拼音输入法,装系统的第一步
  14. 360如何清理注册表
  15. python爬取百度百科获取中国高校信息
  16. 记一次 Ruby 内存泄漏的排查和修复
  17. 罗克韦尔PLC程序,水处理自动化最高程序
  18. 最新微信记录恢复工具MMRecovery的下载与使用方法
  19. 深度学习 01 探索深度学习
  20. EOS智能合约开发系列(16): deferred action与inline action

热门文章

  1. 1005. Maximize Sum Of Array After K Negations
  2. vuex , 简单入(liao)门(jie)
  3. python常用内置函数整理
  4. Win Form不能响应键盘事件
  5. NSJSONSerialization-JSON数据与NSDictionary和NSArray之间的转化
  6. C# 使用JSON对数据序列化和反序列化.
  7. MySQL常见问题及解决方案
  8. JVM故障分析系列之四:jstack生成的Thread Dump日志线程状态
  9. 从零开始一起学习SLAM | 相机成像模型
  10. Javascript 四种输出方式