首先深度学习是机器学习研究领域中的一种范式,而深度学习的概念源于对人工神经网络的研究,很多深度学习算法都使用神经网络进行表示,因为神经网络的性能精度和通用效果都非常好,于是业界习惯性地把深度学习算法等同于 AI。
深度学习范式主要是通过发现经验数据中,错综复杂的结构进行学习。通过构建包含多个处理层的计算模型(网络模型),深度学习可以创建多个级别的抽象层来表示数据。例如,卷积神经网络 CNN 可以使用大量图像进行训练,例如对猫狗分类去学习猫和狗图片的特征。这种类型的神经网络通常从所采集图像中,包含的像素进行学习。
1. 深度学习原理
深度学习的概念源于人工神经网络的研究,但是并不完全等于传统神经网络。在叫法上,很多深度学习算法中都会包含”神经网络”这个词,比如:卷积神经网络 CNN、循环神经网络 LSTM。所以,深度学习可以说是在传统神经网络基础上的升级,约等于神经网络。
虽然深度学习理论最初创立于上世纪八十年代,但有两个主要原因导致其直到近年来才得以发挥巨大作用:
-
深度学习需要大量的标签化数据。例如,无人驾驶汽车模型训练需要数万亿张图片和数千万小时的视频进行学习;
-
深度学习需要巨大的计算能力。例如,需要局别并行架构和集群组网能力的高性能 GPU/NPU 对于深度学习计算进行加速。
1.1 神经网络
现在业界比较通用对神经网络概念的解释是:
-
从通用概念的角度上来看的话,神经网络是在模拟人脑的工作机制,神经元与神经突触之间的连接产生不同的信号传递,每个神经元都记录着信号的特征;
-
从统计学的角度来说,就是在预测数据的分布,从数据中学得一个模型,然后再通过这个模型去预测新的数据(这一点就要求测试数据和训练数据必须是同分布)。
实际上,一个神经网络由多个神经元结构组成,每一层的神经元都拥有多个输入和输出,一层可以由多个神经元组成。例如,第 2 层神经网络的神经元输出是第 3 层神经元的输入,输入的数据通过神经元上的激活函数(非线性函数如 tanh、sigmod 等),来控制输出的数值。
数学上简单地理解,单个神经元其实就是一个 𝑋·𝑊 的矩阵乘,然后加一个激活函数 𝑓𝑢𝑛(𝑋·𝑊),通过复合函数组合神经元,就变成一个神经网络的层。这种模拟生物神经元的数学计算,能够很好地对大规模独立同分布的数据进行非线性映射和处理,使得其能够应对到 AI 的不同任务。
1.2 函数逼近
现在,如果把神经网络看做一个复杂函数,那么这个函数可以逼近任何函数。上面只是定义了什么是神经网络,其实神经网络内部的参数(神经元链接间的权重)需要通过求解函数逼进来确定的。
直观地看下一个简单的例子:假设 1 个圆圈代表一个神经元,那么一个神经元可模拟“与或非”3 种运算,3 个神经元组成包含 1 个隐层的神经网络即可以模拟异或运算。因此,理论上,如此组合的神经网络可模拟任意组合的逻辑函数。
很多人会说神经网络只要网络模型足够深和足够宽,就可以拟合(fit)任意函数,这样的说法数学理论上靠谱吗?严格地说,神经网络并不是拟合任意函数,其数学理论建立在通用逼近定理(Universal approximation theorem)的基础之上:
神经网络则是传统的逼近论中的逼近函数的一种推广。逼近理论证明,只要神经网络规模经过巧妙的设计,使用非线性函数进行组合,它可以以任意精度逼近任意一个在闭集里的连续函数。
既然神经网络模型理论上能够逼近任何连续函数,那么有意思的事情就来了。我们可以利用神经网络处理数学上分类、回归、拟合、逼近等问题啦。例如在 CV 领域对人脸图像进行分类、通过回归检测图像中的车辆和行人,在 NLP 中对离散的语料数据进行拟合。
可是,神经网络介绍现在还只能逼近任何函数,逼近函数需要求解,怎么去求解神经网络呢?
函数逼近求解:在数学的理论研究和实际应用中经常遇到逼近求解问题,在选定的一类函数中寻找某个函数 𝑓,使它与已知函数 𝑔(或观测数据)在一定意义下为最佳近似表示,并求出用 𝑓 近似表示 𝑔 而产生的最小误差(即损失函数):
𝑙𝑜𝑠𝑠(𝑤)=𝑓(𝑤)−𝑔
所以,神经网络可以通过求解损失函数的最小值,来确定这个神经网络中的参数 𝑤,从而固化这个逼近函数。
1.3 反向求导
深度学习一般流程是:1)构建神经网络模型,2)定义损失函数和优化器(优化目标),3)开始训练神经网络模型(计算梯度并更新网络模型中的权重参数),4)最后验证精度。
因为 AI 框架已经帮我们封装好了许多功能,所以遇到神经网络模型的精度不达标,算法工程师可以调整网络模型结构、调节损失函数、优化器等参数重新训练,不断地测试验证精度,因此很多人戏称算法工程师又是“调参工程师”。
但是在这一过程中,这种机械的调参是无法触碰到深度学习的本质的,为了了解实际的工作原理,进行总结:训练的过程本质是进行反向求导(反向传播算法实现)的过程,然后通过迭代计算求得神经网络中的参数,调整参数是控制这一过程的前进速度和方向。
导数是函数的局部性质。一个函数在某一点的导数,描述该函数在这一点附近的变化率。如果函数的自变量和取值都是实数的话,函数在某一点的导数就是该函数所代表的曲线在这一点上的切线斜率。
那么,针对导数的几何意义,其可以表示为函数在某点处的切线斜率;在代数上,其意味着可以求得函数的瞬时变化率。如果把神经网络看做一个高维复杂的函数,那么训练的过程就是对损失函数进行求导,利用导数的性质找到损失函数的变化趋势,每次一点点地改变神经网络仲的参数 𝑤,最后逼近得到这个高维函数。
2. AI 框架的作用
2.1 AI 框架与微分关系
根据深度学习的原理,AI 框架最核心和基础的功能是自动求导(后续统一称为自动微分,AutoGrad)。接下来有个更加重要的问题,深度学习中的神经网络为什么需要反向求导?
按照高中数学的基本概念,假设神经网络是一个复合函数(高维函数),那么对这个复合函数求导,用的是链式法则。举个简单的例子,考虑函数 𝑧=𝑓(𝑥,𝑦),其中 𝑥=𝑔(𝑡),𝑡=ℎ(𝑡) ,其中 𝑔(𝑡),ℎ(𝑡) 是可微函数,那么对函数 𝑧 关于 𝑡 求导,函数会顺着链式向外逐层进行求导。既然有了链式求导法则,而神经网络其实就是个庞大的复合函数,直接求导不就解决问题了吗?反向到底起了什么作用?下面来看几组公式。
假设用 3 组复合函数来表示一个简单的神经网络:
𝐿1=𝑠𝑖𝑔𝑚𝑜𝑖𝑑(𝑤1⋅𝑥)
𝐿2=𝑠𝑖𝑔𝑚𝑜𝑖𝑑(𝑤2⋅𝐿1)
𝐿3=𝑠𝑖𝑔𝑚𝑜𝑖𝑑(𝑤3⋅𝐿2)
现在定义深度学习中网络模型的损失函数,即优化目标:
𝑙𝑜𝑠𝑠=𝐿𝑜𝑠𝑠(𝐿3,𝑦)
根据链式求导法则可以得到:
假设神经网络为上述公式 𝐿1,𝐿2,𝐿3,对损失函数求神经网络中各参数求偏导,可以看到在接下来的求导公式中,每一次导数的计算都可以重用前一次的的计算结果,于是 Paul Werbos 在 1975 年发明了反向传播算法(并在 1990 重新使用神经网络对反向求导进行表示)。
这里的反向,指的是图中的反向箭头,每一次对损失函数中的参数进行求导,都会复用前一次的计算结果和与其对称的原公式中的变量,更方便地对复合函数进行求导。
2.2 AI 框架与程序结合
AI 框架给开发者提供构建神经网络模型的数学操作,AI 框架把复杂的数学表达,转换成计算机可识别的计算图。
通过损失函数对神经网络模型进行求导,训练过程中更新网络模型中的参数(函数逼近的过程),使得损失函数的值越来越小(表示网络模型的表现越好)。这一过程,只要你定义好网络 AI 框架都会主动地帮我们完成。
很有意思的是,AI 框架对整体开发流程进行了封装,好处是让算法研究人员专注于神经网络模型结构的设计(更好地设计出逼近复合函数),针对数据集提供更好的解决方案,研究让训练加速的优化器或者算法等。
综上所述,AI 框架最核心的作用是提供开发者构建神经网络的接口(数学操作),自动对神经网络训练(进行反向求导,逼近地求解最优值),得到一个神经网络模型(逼近函数)用于解决分类、回归、拟合的问题,实现目标分类、语音识别等应用场景。
3. AI 框架的目的
神经网络是机器学习技术中一类具体算法分枝,通过堆叠基本处理单元形成宽度和深度,构建出一个带拓扑结构的高度复杂的非凸函数,对蕴含在各类数据分布中的统计规律进行拟合。传统机器学习方法在面对不同应用时,为了达到所需的学习效果往往需要重新选择函数空间设计新的学习目标。
相比之下,神经网络方法能够通过调节构成网络使用的处理单元,处理单元之间的堆叠方式,以及网络的学习算法,用一种较为统一的算法设计视角解决各类应用任务,很大程度上减轻了机器学习算法设计的选择困难。同时,神经网络能够拟合海量数据,深度学习方法在图像分类,语音识别以及自然语言处理任务中取得的突破性进展,揭示了构建更大规模的神经网络对大规模数据进行学习,是一种有效的学习策略。
然而,神经网络应用的开发需要对软件栈的各个抽象层进行编程,这对新算法的开发效率和算力都提出了很高的要求,进而催生了 AI 框架的发展。AI 框架可以让开发者更加专注于应用程序的业务逻辑,而不需要关注底层的数学和计算细节。同时 AI 框架通常还提供可视化的界面,使得开发者可以更加方便地设计、训练和优化自己的模型。在 AI 框架之上,还会提供了一些预训练的网络模型,可以直接用于一些常见的应用场景,例如图像识别、语音识别和自然语言处理等。
AI 框架的目的是为了在计算加速硬件(GPU/NPU)和 AI 集群上高效训练神经网络而设计的可编程系统,需要同时兼顾以下互相制约设计目标可编程性与性能。
1. 提供灵活的编程模型和编程接口
-
自动推导计算图:根据客户编写的神经网络模型和对应的代码,构建自动微分功能,并转换为计算机可以识别和执行的计算图。
-
较好的支持与现有生态融合:AI 应用层出不穷,需要提供良好的编程环境和编程体系给开发者方便接入,这里以 PyTorch 框架为例对外提供超过 2000+ API。
-
提供直观的模型构建方式,简洁的神经网络计算编程语言:使用易用的编程接口,用高层次语义描述出各类主流神经网络模型和训练算法。而在编程范式主要是以声明式编程和命令式编程为主,提供丰富的编程方式,能够有效提提升开发者开发效率,从而提升 AI 框架的易用性
2. 提供高效和可扩展的计算能力
-
自动编译优化算法:为可复用的处理单元提供高效实现,使得 AI 算法在真正训练或者推理过程中,执行得更快,需要对计算图进行进一步的优化,如子表达式消除、内核融合、内存优化等算法,支持多设备、分布式计算等。
-
根据不同体系结构和硬件设备自动并行化:体系结构的差异主要是指针对 GPU、NPU、TPU 等 AI 加速硬件的实现不同,有必要进行深度优化,而面对大模型、大规模分布式的冲击需要对自动分布式化、扩展多计算节点等进行性能提升。
-
降低新模型的开发成本:在添加新计算加速硬件(GPU/NPU)支持时,降低增加计算原语和进行计算优化的开发成本。
4. AI 框架的发展
4.1 时间维度
AI 框架作为智能经济时代的中枢,是 AI 开发环节中的基础工具,承担着 AI 技术生态中操作系统的角色,是 AI 学术创新与产业商业化的重要载体,助力 AI 由理论走入实践,快速进入了场景化应用时代,也是发展 AI 所必需的基础设施之一。随着重要性的不断凸显,AI 框架已经成为了 AI 产业创新的焦点之一,引起了学术界、产业界的重视。
- 萌芽阶段
在 2020 年前,早期受限于计算能力不足,萌芽阶段神经网络技术影响力相对有限,因而出现了一些传统的机器学习工具来提供基本支持,也就是 AI 框架的雏形,但这些工具或者不是专门为神经网络模型开发定制的,或者 API 极其复杂对开发者并不友好,且并没有对异构加速算力(如 GPU/NPU 等)进行支持。缺点在于萌芽阶段的 AI 框架并不完善,开发者需要编写大量基础的工作,例如手写反向传播、搭建网络结构、自行设计优化器等。
其以 Matlab 的神经网络库为代表作品:
- 成长阶段
2012 年,Alex Krizhevsky 等人提出了 AlexNet 一种神经网络架构,在 ImageNet 数据集上达到了最佳精度,并碾压第二名提升 15%以上的准确率,引爆了神经网络的热潮。
自此极大地推动了 AI 框架的发展,出现了 Caffe、Chainer 和 Theano 等具有代表性的早期 AI 框架,帮助开发者方便地建立复杂的神经网络模型(如 CNN、RNN、LSTM 等)。不仅如此,这些框架还支持多 GPU 训练,让开展更大、更深的模型训练成为可能。在这一阶段,AI 框架体系已经初步形成,声明式编程和命令式编程为下一阶段的 AI 框架发展的两条截然不同的道路做了铺垫。
- 爆发阶段
2015 年,何恺明等人提出的 ResNet,再次突破了图像分类的边界,在 ImageNet 数据集上的准确率再创新高,也凝聚了产业界和学界的共识,即深度学习将成为下一个重大技术趋势。
2016 年谷歌开源了 TensorFlow 框架,Meta AI 研究团队也发布了基于动态图的 AI 框架 PyTorch,该框架拓展自 Torch 框架,但使用了更流行的 Python 进行重构整体对外 API。Caffe 的发明者加入了 Meta(现更名为 Meta)并发布了 Caffe2 并融入了 PyTorch 的推理生态;与此同时,微软研究院开发了 CNTK 框架。Amazon 采用了这是华盛顿大学、CMU 和其他机构的联合学术课程 MXNet。国内百度则率先布局了 PaddlePaddle 飞桨 AI 框架并于 2016 年发布。
在 AI 框架的爆发阶段,AI 系统也迎来了繁荣,而在不断发展的基础上,各种框架不断迭代,也被开发者自然选择。经过激烈的竞争后,最终形成了两大阵营,TensorFlow 和 PyTorch 双头垄断。2019 年,Chainer 团队将他们的开发工作转移到 PyTorch,Microsoft 停止了 CNTK 框架的积极开发,部分团队成员转而支持 PyTorch;Keras 被 TensorFlow 收编,并在 TensorFlow2.X 版本中成为其高级 API 之一。
- 深化阶段
随着 AI 的进一步发展,AI 应用场景的扩展以及与更多领域交叉融合进程的加快,新的趋势不断涌现,越来越多的需求被提出。
例如超大模型的出现(GPT-3、ChatGPT 等),新的趋势给 AI 框架提出了更高的要求。例如超大模型的出现(GPT-3、ChatGPT 等);如对全场景多任务的支持、对异构算力支持等。这就要求 AI 框架最大化的实现编译优化,更好地利用算力、调动算力,充分发挥集群硬件资源的潜力。此外,AI 与社会伦理的痛点问题也促使可信赖 AI 、或则 AI 安全在 AI 框架层面的进步。
基于以上背景,现有的主流 AI 框架都在探索下一代 AI 框架的发展方向,如 2020 年华为推出昇思 MindSpore,在全场景协同、可信赖方面有一定的突破;旷视推出天元 MegEngine,在训练推理一体化方面深度布局;PyTorch 捐赠给 Linux 基金会,并面向图模式提出了新的架构和新的版本 PyTorch2.X。
在这一阶段,AI 框架正向着全场景支持、大模型、分布式 AI、 超大规模 AI、安全可信 AI 等技术特性深化探索,不断实现新的突破。
4.2 技术维度
- 第一代 AI 框架
第一代 AI 框架在时间上主要是在 2010 年前,面向需要解决问题有:1)机器学习 ML 中缺乏统一的算法库,2)提供稳定和统一的神经网络 NN 定义。其对应的 AI 框架框架其实广义上并不能称为 AI 框架,更多的是对机器学习中的算法进行了统一的封装,并在一定程度上提供了少量的神经网络模型算法和 API 的定义。具体形态有 2 种:
第一种的主要特点的是以库(Library)的方式对外提供脚本式编程,方便开发者通过简单配置的形式定义神经网络,并且针对特殊的机器学习 ML、神经网络 NN 算法提供接口,其比较具有代表性意义的是 MATLAB 和 SciPy。另外还有针对矩阵计算提供特定的计算接口的 NumPy。优点是:面向 AI 领域提供了一定程度的可编程性;支持 CPU 加速计算。
第二种的在编程方面,以 CNN 网络模型为主,由常用的 layers 组成,如:Convolution, Pooling, BatchNorm, Activation 等,都是以 Layer Base 为驱动,可以通过简单配置文件的形式定义神经网络。模型可由一些常用 layer 构成一个简单的图,AI 框架提供每一个 layer 及其梯度计算实现。这方面具有代表性的作品是 Torch、Theano 等 AI 框架。其优点是提供了一定程度的可编程性,计算性能有一定的提升,部分支持 GPU/NPU 加速计算。
同时,第一代 AI 框架的缺点也比较明显,主要集中在 1)灵活性和 2)面向新场景支持不足。首先是易用性的限制难以满足深度学习的快速发展,主要是层出不穷的新型网络结构,新的网络层需要重新实现前向和后向计算;其次是第一代 AI 框架大部分使用非高级语言实现,修改和定制化成本较高,对开发者不友好。最后是新优化器要求对梯度和参数进行更通用复杂的运算。
随着生成对抗网络模型 GAN、深度强化学习 DRL、Stable Diffusion 等新的结构出现,基于简单的“前向+后向”的训练模式难以满足新的训练模式。例如循环神经网络 LSTM 需要引入控制流、对抗神经网络 GAN 需要两个网络交替训练,强化学习模型 RL 需要和外部环境进行交互等众多场景没办法满足新涌现的场景。
- 第二代 AI 框架
第二代 AI 框架在技术上,统一称为基于数据流图(DAG)的计算框架:将复杂的神经网络模型,根据数据流拆解为若干处理环节,构建数据流图,数据流图中的处理环节相互独立,支持混合编排控制流与计算,以任务流为最终导向,AI 框架将数据流图转换为计算机可以执行或者识别的任务流图,通过执行引擎(Runtime)解析任务流进行处理环节的分发调度、监控与结果回传,最终实现神经网络模型的构建与运行。
以数据流图描述神经网络,前期实践最终催生出了工业级 AI 框架,如 TensorFlow 和 PyTorch,这一时期同时伴随着如 Chainer,DyNet 等激发了 AI 框架设计灵感的诸多实验课程。TensorFlow 和 PyTorch 代表了现今 AI 框架框架的两种不同的设计路径:系统性能优先改善灵活性,和灵活性易用性优先改善系统性能。
这两种选择,随着神经网络算法研究和应用的更进一步发展,又逐步造成了 AI 框架在具体技术实现方案的分裂。
- 第三代 AI 框架
在第三代 AI 框架中,面向通用化场景,如 CNN、LSTM、RNN 等场景开始走向统一的设计架构,不同的 AI 框架在一定程度都会模仿或者参考 PyTorch 的动态图 Eager 模式,提升自身框架的易用性,使其更好地接入 AI 生态中。
目前在技术上一定程度开始迈进第三代 AI 框架,其主要面向设计特定领域语言(Domain-Specific Language,DSL)。最大的特性是:1)兼顾编程的灵活性和计算的高效性;2)提高描述神经网络算法表达能力和编程灵活性;3)通过编译期优化技术来改善运行时性能。
具体面向不同的业务场景会有一些差异(即特定领域),如 JAX 是 Autograd 和 XLA 的结合,作为一个高性能的数值计算库,更是结合了可组合的函数转换库,除了可用于 AI 场景的计算,更重要的是可以用于高性能机器学习研究。例如 Taichi 面向图形图像可微分编程,作为开源并行计算框架,可以用于云原生的 3D 内容创作。
4. 框架编程范式
常见的编程范型有:函数式编程、命令式编程、声明式编程、面向对象编程等等,编程范式提供并决定了开发者对程序执行的看法。在开发者使用 AI 框架进行编程的过程中,主要使用到的编程范式主要有 2 种:1)声明式编程与 2)命令式编程。
-
命令式编程(Imperative):详细的命令机器怎么(How)去处理一件事情以达到想要的结果(What);
-
声明式编程(Declarative):只告诉想要的结果(What),机器自己摸索执行过程(How)。
4.1 命令式编程
命令式编程(Imperative programming)是一种描述计算机所需作出的行为的编程典范,几乎所有计算机的硬件工作都是命令式的。
其步骤可以分解为:首先,必须将带解决问题的解决方案抽象为一系列概念化的步骤。然后通过编程的方法将这些步骤转化成程序指令集(算法),而这些指令按照一定的顺序排列,用来说明如何执行一个任务或解决一个问题。这意味着,开发者必须要知道程序要完成什么,并且告诉计算机如何进行所需的计算工作,包括每个细节操作。简而言之,就是把计算机看成一个善始善终服从命令的装置。
所以在命令式编程中,把待解问题规范化、抽象为某种算法是解决问题的关键步骤。其次,才是编写具体算法和完成相应的算法实现问题的正确解决。
目前开发者接触到的命令式编程主要以硬件控制程序、执行指令为主。AI 框架中 PyTorch 则主要使用了命令式编程的方式。
下面的代码实现一个简单的声明式编程的过程:创建一个存储结果的集合变量 results,并遍历数字集合 collection,判断每个数字大于 5 则添加到结果集合变量 results 中。上述过程需要告诉计算机每一步如何执行。
results = []
def fun(collection):
for num in collection:
if num > 5:
results.append(num)
4.2 声明式编程
声明式编程(Declarative programming)是一种编程范式,与命令式编程相对立。它描述目标的性质,让计算机明白目标,而非流程。声明式编程不用告诉计算机问题领域,从而避免随之而来的副作用。而命令式编程则需要用算法来明确的指出每一步该怎么做。
副作用:在计算机科学中,函数副作用(Side Effects)指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数,向主调方的终端、管道输出字符或改变外部存储信息等。
声明式编程透过函数、推论规则或项重写(term-rewriting)规则,来描述变量之间的关系。它的语言运行器(编译器或解释器)采用了一个固定的算法,以从这些关系产生结果。
目前开发者接触到的声明式编程语言主要有:括数据库查询语言(SQL,XQuery),正则表达式,逻辑编程,函数式编程等。在 AI 框架领域中以 TensorFlow1.X 为代表,就使用了声明式编程。
以常用数据库查询语言 SQL 为例,其属于较为明显的一种声明式编程的例子,其不需要创建变量用来存储数据,告诉计算机需要查询的目标即可:
>>> SELECT * FROM collection WHERE num > 5
4.3 函数式编程
函数式编程(Functional Programming)函数式编程本质上也是一种编程范式,其在软件开发的工程中避免使用共享状态(Shared State)、可变状态(Mutable Data)以及副作用。即将计算机运算视为函数运算,并且避免使用程序状态以及易变对象,理论上函数式编程是声明式的,因为它不使用可变状态,也不需要指定任何的执行顺序关系。
其核心是只使用纯粹的数学函数编程,函数的结果仅取决于参数,而没有副作用,就像 I/O 或者状态转换。程序通过组合函数(function composition)的方法构建。整个应用由数据驱动,应用的状态在不同纯函数之间流动。与命令式编程的面向对象编程而言,函数式编程其更偏向于声明式编程,代码更加简洁明了、更可预测,并且可测试性也更好。因此实际上可以归类为属于声明式编程的其中一种特殊范型。
函数式编程最重要的特点是“函数第一位”(First Class),即函数可以出现在任何地方,比如可以把函数作为参数传递给另一个函数,不仅如此你还可以将函数作为返回值。
5. AI 框架的编程范式
主流 AI 框架,无论 PyTorch 还是 TensorFlow 都使用都以 Python 为主的高层次语言为前端,提供脚本式的编程体验,后端用更低层次的编程模型和编程语言开发。后端高性能可复用模块与前端深度绑定,通过前端驱动后端方式执行。AI 框架为前端开发者提供声明式(declarative programming)和命令式(imperative programming)两种编程范式。
在主流的 AI 框架中,TensorFlow 提供了声明式编程体验,PyTroch 提供了命令式的编程体验。但两种编程模型之间并不存在绝对的边界,multi-stage 编程和及时编译(Just-in-time, JIT)技术能够实现两种编程模式的混合。随着 AI 框架引入更多的编程模式和特性,例如 TensorFlow Eager 模式和 PyTorch JIT 的加入,主流 AI 框架都选择了通过支持混合式编程以兼顾两者的优点。
5.1 命令式编程
在命令式编程模型下,前端 Python 语言直接驱动后端算子执行,表达式会立即被求值,又被称作 define-by-run。开发者编写好神经网络模型的每一层,并编写训练过程中的每一轮迭代需要执行的计算任务。在程序执行的时候,系统会根据 Python 语言的动态解析性,每解析一行代码执行一个具体的计算任务,因此称为动态计算图(动态图)。
命令式编程的优点是方便调试,灵活性高,但由于在执行前缺少对算法的统一描述,也失去了编译期优化的机会。相比之下,命令式编程对数据和控制流的静态性限制很弱,方便调试,灵活度极高。缺点在于,网络模型程序在执行之前没有办法获得整个计算图的完整描述,从而缺乏缺乏在编译期的各种优化手段。
以 PyTorch 其编程特点为即时执行,它属于一种声明式的编程风格。下面使用 PyTorch 实现一个简单的 2 层神经网络模型并训练:
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
import torch
import torch.nn as nn
import torch.optim as optim
# 导入数据
data = pd.read_csv('mnist.csv')
X = data.iloc[:, 1:].values
y = data.iloc[:, 0].values
# 分割数据集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# 将数据转换为张量
X_train = torch.tensor(X_train, dtype=torch.float)
X_test = torch.tensor(X_test, dtype=torch.float)
y_train = torch.tensor(y_train, dtype=torch.long)
y_test = torch.tensor(y_test, dtype=torch.long)
# 定义模型
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.fc1 = nn.Linear(784, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.fc1(x)
x = self.fc2(x)
return x
model = Net()
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters())
# 训练模型
for epoch in range(5):
# 将模型设为训练模式
model.train()
# 计算模型输出
logits = model(X_train)
loss = criterion(logits, y_train)
5.2 声明式编程
在声明式编程模型下,前端语言中的表达式不直接执行,而是构建起一个完整前向计算过程表示,对数据流图经过优化然后再执行,又被称作 define-and-run。即开发者定义好整体神经网络模型的前向表示代码,因为整体定义好神经网络模型,因此在 AI 框架的后端会把网络模型编译成 静态计算图(简称:静态图) 来执行。
执行方式比较直接:前端开发者写的 Python 语言中的表达式不直接执行;首先会利用 AI 框架提供的 API 定义接口构建一个完整前向计算过程表示。最后对数计算图经过优化然后再执行。
AI 框架采用声明式编程的优点在于:
-
执行之前得到整个程序(整个神经网络模型)的描述;
-
在真正运行深度学习之前能够执行编译优化算法;
-
能够实现极致的性能优化。
缺点也较为明显:
-
数据类型和控制流受到 AI 框架中的 API 对神经网络有限定义而约束;
-
因为神经网络的独特性需要 AI 框架预定义对应的概念(DSL),造成不方便调试、灵活性低。
以谷歌的 TensorFlow1.X 为代表的编程特点包括:计算图(Computational Graphs)、会话(Session)、张量(Tensor),其作为一种典型声明式编程风格。下面使用 TensorFlow 实现一个隐层的全连接神经网络,优化的目标函数是预测值和真实值的欧氏距离。该实现使用基本的 TensorFlow 操作来构建一个计算图,然后多次执行这个计算图来训练网络。
import TensorFlow as tf
import numpy as np
# 首先构建计算图
# N 是 batch 大小;D_in 是输入大小。
# H 是隐单元个数;D_out 是输出大小。
N, D_in, H, D_out = 64, 1000, 100, 10
# 输入和输出是 placeholder,在用 session 执行 graph 的时候
# 我们会 feed 进去一个 batch 的训练数据。
x = tf.placeholder(tf.float32, shape=(None, D_in))
y = tf.placeholder(tf.float32, shape=(None, D_out))
# 创建变量,并且随机初始化。
# 在 TensorFlow 里,变量的生命周期是整个 session,因此适合用它来保存模型的参数。
w1 = tf.Variable(tf.random_normal((D_in, H)))
w2 = tf.Variable(tf.random_normal((H, D_out)))
接着为 Forward 阶段,计算模型的预测值 y_pred。注意和 PyTorch 不同,这里不会执行任何计算,而只是定义了计算,后面用 session.run 的时候才会真正的执行计算。
h = tf.matmul(x, w1)
h_relu = tf.maximum(h, tf.zeros(1))
y_pred = tf.matmul(h_relu, w2)
# 计算 loss
loss = tf.reduce_sum((y - y_pred) ** 2.0)
# 计算梯度
grad_w1, grad_w2 = tf.gradients(loss, [w1, w2])
使用梯度下降来更新参数。assign 同样也只是定义更新参数的操作,不会真正的执行。在 TensorFlow 里,更新操作是计算图的一部分,而在 PyTorch 里,因为是动态的”实时“的计算,所以参数的更新只是普通的 Tensor 计算,不属于计算图的一部分。
learning_rate = 1e-6
new_w1 = w1.assign(w1 - learning_rate * grad_w1)
new_w2 = w2.assign(w2 - learning_rate * grad_w2)
# 计算图构建好了之后,我们需要创建一个 session 来执行计算图。
with tf.Session() as sess:
# 首先需要用 session 初始化变量
sess.run(tf.global_variables_initializer())
# 创建随机训练数据
x_value = np.random.randn(N, D_in)
y_value = np.random.randn(N, D_out)
for _ in range(500):
# 用 session 多次的执行计算图。每次 feed 进去不同的数据。
# 这里是模拟的,实际应该每次 feed 一个 batch 的数据。
# run 的第一个参数是需要执行的计算图的节点,它依赖的节点也会自动执行,
# 因此我们不需要手动执行 forward 的计算。
# run 返回这些节点执行后的值,并且返回的是 numpy array
loss_value, _, _ = sess.run([loss, new_w1, new_w2],
feed_dict={x: x_value, y: y_value})
print(loss_value)
5.3 函数式编程
不管是 JAX 还是 MindSpore 都使用了函数式编程的范式,其在高性能计算、科学计算、分布式方面有着独特的优势。
其中 JAX 是作为 GPU/TPU 的高性能并行计算的框架,与普通 AI 框架相比其核心是对神经网络计算和数值计算的融合,接口上兼容 NumPy、Scipy 等 Python 原生数据科学库,在此基础上扩展分布式、向量化、高阶求导、硬件加速,其编程风格采用了函数式编程,主要体现在无副作用、Lambda 闭包等。而华为推出的 MindSpore 框架,其函数式可微分编程架构,可以让开发者聚焦机器学习模型数学的原生表达。