从零实现深度学习框架——优化反向传播相关代码
引言
本着“凡我不能创造的,我就不能理解”的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导。
要深入理解深度学习,从零开始创建的经验非常重要,从自己可以理解的角度出发,尽量不适用外部完备的框架前提下,实现我们想要的模型。本系列文章的宗旨就是通过这样的过程,让大家切实掌握深度学习底层实现,而不是仅做一个调包侠。
本系列文章首发于微信公众号:JavaNLP
在前面的文章中,我们实现了反向传播的模式。并实现了加法和乘法的计算图。但是这种实现方式有一些弊端,本文就来优化实现反向传播模式的代码,同时修复加法和乘法计算图实现的问题。
优化反向传播代码
在上篇文章中,我们将_Function
与Tensor
放到了同一个文件中,这不符合单一职责模式。同时在实现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()
方法传入的grad
为Tensor
对象。
所以,我们修改下对应的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
求梯度时,梯度要和x
的shape
保持一致;对y
求梯度时,也要和y
的shape
保持一致。
修复广播带来的问题
由于要保证梯度的维度和输入的维度一致,而最后得到的梯度是经过了广播操作的。所以,我们要实现广播操作的逆操作:
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
从零实现深度学习框架——优化反向传播相关代码相关推荐
- python学习框架图-从零搭建深度学习框架(二)用Python实现计算图和自动微分
我们在上一篇文章<从零搭建深度学习框架(一)用NumPy实现GAN>中用Python+NumPy实现了一个简单的GAN模型,并大致设想了一下深度学习框架需要实现的主要功能.其中,不确定性最 ...
- 【李宏毅机器学习2021】Task04 深度学习介绍和反向传播机制
[李宏毅机器学习2021]本系列是针对datawhale<李宏毅机器学习-2022 10月>的学习笔记.本次是对深度学习介绍和反向传播机制的学习总结.本节针对上节课内容,对batch.梯度 ...
- 从零实现深度学习框架——深入浅出Word2vec(下)
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导. 要深入理解深度学 ...
- 从零实现深度学习框架——GloVe从理论到实战
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.
- 从零实现深度学习框架——Seq2Seq从理论到实战【实战】
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.
- 从零实现深度学习框架——RNN从理论到实战【理论】
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.
- 从零实现深度学习框架——从共现矩阵到点互信息
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.
- 从零实现深度学习框架——LSTM从理论到实战【理论】
引言 本着"凡我不能创造的,我就不能理解"的思想,本系列文章会基于纯Python以及NumPy从零创建自己的深度学习框架,该框架类似PyTorch能实现自动求导.
- 机器学习(深度学习)中的反向传播算法与梯度下降
这是自己在CSDN的第一篇博客,目的是为了给自己学习过的知识做一个总结,方便后续温习,避免每次都重复搜索相关文章. 一.反向传播算法 定义:反向传播(Backpropagation,缩写为BP)是&q ...
- Datawhale 7月学习——李弘毅深度学习:深度学习介绍和反向传播机制
前情回顾 机器学习简介 回归 误差与梯度下降 1 深度学习简介 1.1 深度学习的历史 李宏毅老师带我们简要回顾了深度学习的历史. 1958: Perceptron (linear model) 19 ...
最新文章
- 高度可扩展的类脑神经拟态硬件,完成了字母识别和人脸识别
- java中对象的序列化和反序列化
- jQuery.sap.getModulePath(cus.crm.opportunity.css.Opportunity, .css)
- 关于 lockfree 算法
- 流体式布局与响应式布局_将固定像素设计转换为流体比例布局
- 正则表达式验证六位数以上数字,符号,字母任意两种混合的密码验证策略
- WinDbg配置与下载 (转载)
- LeetCode--042--接雨水(java版)
- java log4j trace_关于LOG4J中的日志级别TRACE
- Sublime text3 Version 3.2.1 3207 和 3.2.2 3211(2019-11-06亲测有效)
- 基于JESD204B的LMK04826时钟芯片开发笔记
- 新手如何建立网站,网站建设的几个步骤。
- ubuntu安装中文拼音输入法,装系统的第一步
- 360如何清理注册表
- python爬取百度百科获取中国高校信息
- 记一次 Ruby 内存泄漏的排查和修复
- 罗克韦尔PLC程序,水处理自动化最高程序
- 最新微信记录恢复工具MMRecovery的下载与使用方法
- 深度学习 01 探索深度学习
- EOS智能合约开发系列(16): deferred action与inline action
热门文章
- 1005. Maximize Sum Of Array After K Negations
- vuex , 简单入(liao)门(jie)
- python常用内置函数整理
- Win Form不能响应键盘事件
- NSJSONSerialization-JSON数据与NSDictionary和NSArray之间的转化
- C# 使用JSON对数据序列化和反序列化.
- MySQL常见问题及解决方案
- JVM故障分析系列之四:jstack生成的Thread Dump日志线程状态
- 从零开始一起学习SLAM | 相机成像模型
- Javascript 四种输出方式