选自tryolabs
作者:JoaquínThu
机器之心编译
参与:Panda、Racoon
Python 并不完美,而 Swift 则正在谷歌和苹果的共同养育下茁壮成长,有望成长为深度学习领域一门新的主要语言。近日,Tryolabs 的研究工程师 Joaquín Alori 发布了一篇长文,从 Python 的缺点一路谈到了谷歌在 Swift 机器学习方面的大计划,并且文中还给出了相当多一些具体的代码实例。可微分编程真如 Yann LeCun 所言的那样会成为新一代的程序开发范式吗?Swift 又将在其中扮演怎样的角色?也许你能在这篇文章中找到答案。
近日,国外一小哥在 tryolabs 上写了一篇博文,为我们详尽地介绍了 Python 的缺陷与相比之下 Swift 的优势,解释了为什么 Swift 版的 TensorFlow 未来在机器学习领域有非常好的发展前景。其中包含大量代码示例,展示了如何用 Swift 优雅地编写机器学习程序。
两年之前,谷歌的一个小团队开始研究让 Swift 语言成为首个在语言层面上一流地整合了可微分编程能力的主流语言。该项目的研究范围着实与众不同,而且也取得了一些出色的初期研究成果,似乎离公众应用也并不很远了。
尽管如此,该项目却并未在机器学习社区引起多大反响,而且很多实践者还对此浑然不觉。造成这种结果的主要原因之一是语言的选择。机器学习社区的很多人很大程度上并不关心 Swift,谷歌研究它也让人们感到疑惑;因为 Swift 主要用来开发 iOS 应用而已,在数据科学生态系统中几乎毫无存在感。
不过,事实却并非如此,只需粗略地看看谷歌这个项目,就能发现这是一个庞大且雄心勃勃的计划,甚至足以将 Swift 确立为机器学习领域的关键成员。此外,即使我们 Tryolabs 也主要使用 Python,但我们还是认为 Swift 是一个绝佳的选择;也因此,我们决定写这篇文章以帮助世人了解谷歌的计划。
但在深入 Swift 以及「可微分编程」的真正含义之前,我们应该先回顾一下当前的状况。
Python,你怎么了?!
到目前为止,Python 都依然是机器学习领域最常被使用的语言,谷歌也有大量用 Python 编写的机器学习软件库和工具。那么,为什么还要用 Swift?Python 有什么问题吗?
直接说吧,Python 太慢了。另外,Python 的并行性表现并不好。
为了应对这些缺点,大多数机器学习项目在运行计算密集型算法时,都会使用用 C/C++/Fortran/CUDA 写的软件库,然后再使用 Python 将不同的底层运算组合到一起。对于大部分项目而言,这种做法其实效果很好;但总体概况而言,这会产生一些问题。我们先看看其中一些问题。
外部二进制文件
为每个计算密集型运算都调用外部二进制文件会限制开发者的工作,让他们只能在算法的表层的一小部分上进行开发。比如,编写自定义的卷积执行方式是无法实现的,除非开发者愿意使用 C 等语言来进行开发。大部分程序员都不会选择这么做,要么是因为他们没有编写低层高性能代码的经验,要么则是因为在 Python 开发环境与某个低层语言环境之间来回切换会变得过于麻烦。
这会造成一种不幸的情况:程序员会尽力尽量少地写复杂代码,并且默认情况更倾向于调用外部软件库的运算。对于机器学习这样动态发展的领域来说,这并不是一个好现象,因为很多东西都还并未确定下来,还非常需要新想法。
对软件库的抽象理解
让 Python 代码调用更低层代码并不如将 Python 函数映射成 C 函数那么简单。不幸的现实是:机器学习软件库的创建者必须为了性能而做出一些开发上的选择,而这又会让事情变得更加复杂。举个例子,在 TensorFlow 图(graph)模式中(这是该软件库中唯一的性能模式),你的 Python 代码在你认为会运行时常常并不运行。在这里,Python 实际上的作用是底层 TensorFlow 图的某种元编程(metaprogramming)语言。
其开发流程为:开发者首先使用 Python 定义一个网络,然后 TensorFlow 后端使用该定义来构建网络并将其编译为一个 blob,而开发者却再也无法访问其内部。编译之后,该网络才终于可以运行,开发者可以开始向其馈送数据以便训练和推理。这种工作方式让调试工作变得非常困难,因为在网络运行时,你没法使用 Python 了解其中究竟发生了什么。你也没法使用 pdb 等方法。即使你想使用古老但好用的 print 调试方法,你也只能使用 tf.print 并在你的网络中构建一个 print 节点,这又必须连接到网络中的另一个节点,而且在 print 得到任何信息之前还必须进行编译。
不过也存在更加直接的解决方案。用 PyTorch 时,你的代码必须像用 Python 一样命令式地运行,唯一不透明的情况是运行在 GPU 上的运算是异步式地执行的。这通常不会有问题,因为 PyTorch 对此很智能,它会等到用户交互操作所依赖的所有异步调用都结束之后才会转让控制权。尽管如此,也还是有一些问题存在,尤其是在基准评测(benchmarking)等任务上。
行业滞后
所有这些可用性问题不仅让写代码更困难,而且还会导致产业界毫无必要地滞后于学术界。一直以来都有论文在研究如何调整神经网络中所用的低层运算,并在这一过程中将准确度提升几个百分点,但是产业界仍然需要很长时间才能实际应用这些进展。
一个原因是即使这些算法上的改变可能本身比较简单,但上面提到的工具问题还是让它们非常难以实现。因此,由于这些改进可能只能将准确度提升 1%,所以企业可能会认为为此进行投入并不值得。对于小型机器学习开发团队而言,这个问题尤为明显,因为他们往往缺乏可负担实现/整合成本的规模经济。
因此,企业往往会直接忽略这些进步,直到这些改进被加入到 PyTorch 或 TensorFlow 等软件库中。这能节省企业的实现和整合成本,但也会导致产业界滞后学术界一两年时间,因为这些软件库的维护者基本不会立即实现每篇新论文提出的新方法。
举个具体的例子,可变形卷积似乎可以提升大多数卷积神经网络(CNN)的性能表现,但论文发布大概 2 年之后才出现第一个开源的实现。不仅如此,将可变形卷积的实现整合进 PyTorch 或 TensorFlow 的过程非常麻烦,而且最后这个算法也并没得到广泛的使用。PyTorch 直到最近才加入对它的支持,至于官方的 TensorFlow 版本,至今仍没有见到。
现在,假设说有 n 篇能将准确度提升 2% 的论文都遇到了这种情况,那么产业界将错失准确度显著提升 (1.02^n)% 的机会,而原因不过是没有合适的工具罢了。如果 n 很大,那就太让人遗憾了。
速度
在某些情况中,同时使用 Python 与快速软件库依然还是会很慢。确实,如果是用 CNN 来执行图像分类,那么使用 Python 与 PyTorch/TensorFlow 会很快。此外,就算在 CUDA 环境中编写整个网络,性能也可能并不会得到太多提升,因为大卷积占据了大部分的推理时间,而大卷积又已经有了经过良好优化的代码实现。但情况并非总是如此。
如果不是完全用低层语言实现的,那么由很多小运算组成的网络往往最容易出现性能问题。举个例子,Fast.AI 的 Jeremy Howard 曾在一篇博客文章中表达了自己对用 Swift 来做深度学习开发的热爱,他表示尽管使用了 PyTorch 那出色的 JIT 编译器,他仍然无法让 RNN 的工作速度比肩完全用 CUDA 实现的版本。
此外,对于延迟程度很重要的情况,Python 也不是一种非常好的语言;而且 Python 也不能很好地应用于与传感器通信等非常底层的任务。为了解决这个问题,一些公司的做法是仅用 Python 和 PyTorch/TensorFlow 开发模型。这样,在实验和训练新模型时,他们就能利用 Python 的易用性优势。而在之后的生产部署时,他们会用 C++ 重写他们的模型。不确定他们是会完全重写,还是会使用 PyTorch 的 tracing 功能或 TensorFlow 的图模式来简单地将其串行化,然后再围绕它使用 C++ 来重写 Python。不管是哪种方式,都需要重写大量 Python 代码。对于小公司而言,这样做往往成本过高。
所有这些问题都是众所周知的。公认的深度学习教父之一 Yann LeCun 就曾说机器学习需要一种新语言。他与 PyTorch 的创建者之一 Soumith Chintala 曾在一组推文中讨论了几种可能的候选语言,其中提到了 Julia、Swift 以及改进 Python。另一方面,Fast.AI 的 Jeremy Howard 似乎已经下定决心站队 Swift。
谷歌接受了挑战
幸运的是,谷歌的 Swift for TensorFlow(S4TF)团队接过了这一难题。不仅如此,他们的整个项目进展还非常透明。他们还发布了一份非常详实的文档(
https://github.com/tensorflow/swift/blob/master/docs/WhySwiftForTensorFlow.md),其中详细地介绍了他们做出这一决定的历程,并解释了他们为这一任务考虑过的其它语言并最终选中 Swift 的原因。
在他们考虑过的语言中,最值得关注的包括:
Go:在这份文档中,他们表示 Go 过于依赖其接口提供的动态调度,而且如果要实现他们想要的特性,必须对这门语言进行大刀阔斧的修改。这与 Go 语言的保持简单和小表面积的哲学不符。相反,Swift 的协议和扩展都有很高的自由度:你想要调度有多静态,就能有多静态。另外,Swift 也相当复杂,而且还在越来越复杂,所以再让它复杂点以满足谷歌想要的特性并不是什么大问题。
C++ 和 Rust:谷歌的目标用户群是那些大部分工作都使用 Python 的人,他们更感兴趣的是花时间思考模型和数据,而不是思考如何精细地管理内存或所有权(ownership)。Rust 和 C++ 的复杂度都足够,但都很注重底层细节,而这在数据科学和机器学习开发中通常是不合理的。
Julia:如果你在 HackerNews 或 Reddit 上读到过任何有关 S4TF 的帖子,那么最常看到的评论是:「为啥不选 Julia?」在前面提到的那份文档中,谷歌提到 Julia 看起来也很有潜力,但他们并未给出不选 Julia 的靠谱理由。他们提到 Swift 的社区比 Julia 大得多,事实确实如此,然而 Julia 的科研社区和数据科学社区却比 Swift 大得多,而这些社区的人才更可能更多地使用 S4TF。要记住,谷歌团队的 Swift 专业人才更多,毕竟发起 S4TF 项目的正是 Swift 的创建者 Chris Lattner,相信这在谷歌的决定中起到了重大的作用。
一种新语言:作者认为他们在宣言中说得很好:「创建一种语言的工作量多得吓人。」这需要太长的时间,而机器学习又发展得太快。
那么,Swift 的优势在哪里?
简单来说,Swift 让你可几乎完全用 Python 的方式在非常高的层面上进行编程,同时又可以保证非常快的速度。数据科学家可像使用 Python 一样来使用 Swift,同时可用 Swift 内置的已优化机器学习库来进行更加精细的开发,比如管理内存,甚至当常用的 Swift 代码约束太大时还能降至指针层面进行操作。
本文的目的不是介绍 Swift 语言,所以不会连篇累牍地详细介绍其特性。如果你想详细了解这门语言,看官方文档就够了。这里只会介绍 Swift 的几个亮点,并希望这能吸引人们去尝试它。下面几节将按随机顺序介绍 Swift 的一些亮点,所以排序与它们的重要程度无关。之后,本文将深入介绍可微分编程,并聊聊谷歌在 Swift 上的大计划。
亮点一
Swift 速度很快。这是作者在开始使用 Swift 时所做的第一项测试。作者写了一些短脚本来评估 Swift 与 Python 和 C 的相对表现。说实话,这些测试并不特别复杂。也就是用整型数填充一个数组,然后再将它们全部加起来。这个测试本身并不能透彻地了解 Swift 在各种情况下的速度表现,但作者想了解的是 Swift 能否达到 C 一样的速度,而不是 Swift 是否总能和 C 一样快。
第一组比较作者选的是 Swift vs Python。为了让对应的每一行所执行的任务一致,作者对某些地方的花括号的位置进行了调整。
import time | import Foundation|result = [] | var result = [Int]()for it in range(15): | for it in 0.. start = time.time() | let start = CFAbsoluteTimeGetCurrent() for _ in range(3000): | for _ in 0.. result.append(it) | result.append(it)} sum_ = sum(result) | let sum = result.reduce(0, +) end = time.time() | let end = CFAbsoluteTimeGetCurrent() print(end - start, sum_) | print(end - start, sum) result = [] | result = []}
尽管在这个特定的代码段中,Python 与 Swift 代码看起来句法相近,但运行结果表明这个 Swift 脚本的运行速度比 Python 脚本的运行速度快 25 倍。在这个 Python 脚本中,最外层的循环每执行一次平均耗时 360 μs,相比之下 Swift 的是 14 μs。差别非常明显。
另外,也还有其它一些事情值得注意。比如,+ 既是一个运算符也是一个函数,它会被传递给 reduce(后面我会详细介绍);CFAbsoluteTimeGetCurrent 揭示了 Swift 在传承下来的 iOS 命名空间方面的怪异特性;.< 范围运算符让你可以选择该范围是否包含区间端点以及哪个端点。
但是,这个测试并不能说明 Swift 有多快。要知道 Swift 有多快,我们得将其与 C 来比比看。我也这样做了,但让人失望的是,初始结果并不好。用 C 编写的版本平均耗时 1.5 μs,比我们的 Swift 代码快 10 倍。Uh oh.
不过老实讲,这样比较其实并不公平。这段 Swift 代码并没使用动态数组,因此当数组规模变大时,它会在内存堆中不断重新分配位置。这也意味着它会在每个附加(append)的数组上执行边界检查。为了佐证这一点,我们来看看相关定义。Swift 的标准类型包括整型、浮点数和数组,它们并没有硬编码到编译器中,而是标准库中所定义的结构体(struct)。因此,根据数组的附加(append)定义,我们可以了解到很多信息。知道了这一点后,我的测试方式甚至可以包括预分配数组的内存以及使用指针来填充数组。这样得到的脚本其实也并不是很长:
import Foundation// Preallocating memoryvar result = ContiguousArray(repeating: 0, count: 3001)for it in 0.. let start = CFAbsoluteTimeGetCurrent()
// Using a buffer pointer for assignment result.withUnsafeMutableBufferPointer({ buffer infor i in 0.. buffer[i] = it } }) let sum = result.reduce(0, +) let end = CFAbsoluteTimeGetCurrent() print(end - start, sum)
这段新代码耗时 3 μs,速度已经达到 C 的一半,可以说是很不错的结果了。不过为了进行完整的比较,作者继续对代码进行了剖析,以便了解该代码的 Swift 版本和 C 版本的差异究竟可以做到多小。事实证明,作者之前使用的 reduce 方法会毫无必要地间接使用 nextPartialResult 函数执行一些计算,这可以提供非必需的泛化能力。在使用指针重写了这段代码之后,作者最终让这段代码达到了与 C 同等的速度。但是,这显然不符合我们使用 Swift 的目的,因为这种操作本质上就是写更冗长更丑陋的 C 语言。尽管如此,知道在确实需要时可以达到 C 的速度也是一件好事。
总结:使用 Swift,你没法在执行 Python 层面的工作时获得 C 语言等级的速度,但你能在两者之间取得良好的平衡。
亮点二
Swift 采用的函数签名方法也很有趣。它们的最基本形式其实相当简单:
func greet(person: String, town: String) -> String { return "Hello \(person)! Glad you could visit from \(town)."}
greet(person: "Bill", town: "Cupertino")
其函数签名由参数名加它们的类型构成,没其它多余花哨的东西。唯一不同寻常的是 Swift 需要你在调用该函数时提供参数名,因此你在调用上面的 greet 时必须写下 person 和 town,如上面代码段中最后一行所示。
当我们向其中引入参数标签时,情况还会变得更加有趣。
func greet(_ person: String, from town: String) -> String { return "Hello \(person)! Glad you could visit from \(town)."}
greet("Bill", from: "Cupertino")
顾名思义,参数标签就是函数的参数的标签,而且它们是在函数签名中各自的参数之前声明的。在上面的示例中,from 是 town 的参数标签,_ 是 person 的参数标签。对于最后一个标签,作者使用的是,因为 _ 在 Swift 中是一个特殊字母,其含义是:「在调用这个参数时不提供任何参数名。」
有了参数标签,每个参数都有两个不同的名字:一个是参数标签,在调用该函数时使用;另一个是参数名,在函数的主体定义中使用。这看起来似乎有些任性,但会让你的代码更易读。
看看上面的函数签名,基本就像是在读英语。「Greet person from town.」上面的函数调用看起来也同样清楚直白:「Greet Bill from Cupertino.」如果没有参数标签,就有些含混不清了:「Greet person town.」我们不知道这里的 town 是什么意思。这是我们现在所处的城镇吗?还是我们为了面见这个人而将要前去的城镇?又或是这个人原本来处的城镇?如果没有参数标签,我们就必须阅读函数主体才能知晓实际情况,或者采用让函数名或参数名更长更直白的方法。如果你有大量参数,那么情况将变得非常复杂;在作者看来这会导致代码变得更丑而且会让函数名变得毫无必要地长。参数标签更加好看,而且也更容易扩展,而且幸运的是它们也在 Swift 中得到了广泛的应用。
亮点三
Swift 广泛地使用了闭包(closure)。因此,有一些捷径可让该语言的使用更接近人的直觉。这个来自 Swift 的文档的示例展现了这些捷径简洁明了又具有很强的表现力的特性。
我们的目标是将下面的数组向后排序:
let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
如果用不那么地道的 Swift 代码形式,可为数组使用 sorted 方法,并采用一个自定义函数来定义按逐对顺序比较数组元素的方式,就像这样:
func backward(_ s1: String, _ s2: String) -> Bool { return s1 > s2}var reversedNames = names.sorted(by: backward)
backward 函数一次可比较两项,如果这两项的顺序与所需顺序一样,则返回 true;否则便返回 false。sorted 数组方法需要这样一个函数作为一个输入才能知道如何对数组进行排序。顺便一提,我们还可以看到这里使用了参数标签 by——这是如此的简洁明了。
如果我们采用更地道的 Swift,可以发现使用闭包能更好地完成这项任务。
reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )
{} 之间的代码是一个正被定义的闭包,同时也被传递用作 sorted 的一个参数。你也许从未听说过闭包,但其实很简单,闭包就是一个获取上下文的未命名的函数你可以将其看作是增强版的 Python lambda。该闭包中的关键词 in 的作用是分开该闭包的参数及其主体。: 等更直观的关键词已被签名类型定义所占用(在这个案例中,该闭包的参数类型是从 sorted 的签名中自动推导出来的,因此可以避免使用 :),而且我们都知道命名是编程中最艰难的事情之一,所以为此只能继续使用不那么直观的关键词了。
不管从哪个角度看,这段代码都已经简洁了许多。
但我们还可能做得更好:
reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )
这里我们移除了 return 语句,这是因为在 Swift 中,单行闭包就暗含了 return。
即便如此,我们还能继续更进一步:
reversedNames = names.sorted(by: { reversedNames = names.sorted(by: { $0 > $1 } ) > } )
Swift 也有暗含的命名位置参数,所以在上面的案例中,Swift 也有暗含的命名位置参数,所以在上面的案例中,$0 是第一个参数,$1 是第二个参数,$2 是第三个参数等等。这个代码已经很紧凑了,而且非常容易理解,但是我们甚至还能做得更好: 是第一个参数, 是第二个参数, 是第三个参数等等。这个代码已经很紧凑了,而且非常容易理解,但是我们甚至还能做得更好:
reversedNames = names.sorted(by: >)
在 Swift 中,> 运算符就是一个名为 > 的函数。因此,我们可以将其传递给 sorted 方法,使我们的代码达到极端简洁和可读的程度。
这种操作适用于 +=、-=、、== 和 = 等运算符,你可以在标准库中查看它们的定义。这些函数/运算符与普通函数之间的差异是前者已在标准库中使用 infix、prefix 或 suffix 关键词显式地声明为运算符。举个例子,+= 函数在 Swift 标准库的这一行(
https://github.com/apple/swift/blob/1ed846d8525679d2811418a5ba29405200f6e85a/stdlib/public/core/Policy.swift#L468)中被定义成了一个运算符。可以看到,这个运算符遵循多个不同的协议,比如 Array 和 String,因为很多不同的类型都有自己的 += 函数实现。
更进一步,我们还能定义自己的自定义运算符。GPUImage2 软件库就是一个很好的例子。这个软件库让用户可以加载图像,使用一系列变换来修改它,然后再以某种方式来输出它。很自然,这些变换序列的定义会在该库中不断反复出现,因此这个库的创建者决定定义一个新的运算符 →,可用于将这些变换链接到一起。
func -->(source:T, destination:T) -> T { source.addTarget(destination) return destination}infix operator --> : AdditionPrecedence
在以上稍微简化过的代码中,首先声明了 --> 函数,然后其被定义为了一个 infix 运算符。infix 的意思是如果要使用这个运算符,就必须将其放置在两个参数之间。这让你可以写出如下的代码:
let testImage = UIImage(named:"WID-small.jpg")!let toonFilter = SmoothToonFilter()let luminanceFilter = Luminance()let filteredImage = testImage.filterWithPipeline{input, output in input --> toonFilter --> luminanceFilter --> output // Interesting part}
比起一大堆互相链接的方法或一长串 source.addTarget(...) 函数,上面的代码要简短和容易多了。
亮点四
前面作者已经提到过,Swift 的基本类型是标准库中定义的结构体,而且并没有硬编码到编译器中,因为它们通常是用其它语言写的。这很有用处,一大原因是让我们可以使用名叫扩展(extension)的 Swift 特性,其让我们可以向任意类型添加新特性,包括基本类型。操作方式是这样的:
extension Double { var radians: Double { return self * (Double.pi / 180) }}360.radians // -> 6.28319
尽管这个例子并不是很有用,但也展示 Swift 这门语言的扩展能力,因为这能让你做很多事情,比如向 Swift 解释器输入任何数字以及在其上调用任何你想用的自定义方法。
最后一个亮点
除了拥有编译器之外,Swift 还具有解释器并且支持 Jupyter Notebook。在学习这门语言时,解释器尤其好用,因为它支持直接在命令提示符处输入 swift,然后立马开始代码测试。Python 也具备差不多一样的功能。另一方面,由于整合了 Jupyter Notebook,因此可以轻松进行可视化、执行数据探索和编写报告。最后,当你需要运行生产代码时,你可以编译它并利用 LLVM 提供的出色优化能力。
谷歌的大计划
作者在前面的章节中提到了 Swift 的一些特性,但其中有一个特性与其它不同:Jupyter Notebook 是新加入的,而且事实上正是由 S4TF 团队加入的。这非常值得一说,因为这能让我们一窥谷歌投入这个项目时的想法:他们不仅想为 Swift 语言本身创建一个软件库,而且他们还想深入地改进这门语言本身以及相关工具,然后再使用这门语言的改进版本创建一个新的 TensorFlow 软件库。
只要看看 S4TF 团队在哪些工作上投入的时间最多就能看出这一点。他们到目前为止做的大部分工作都是在苹果公司的 Swift 编译器代码库本身上完成的。更具体而言,谷歌目前完成的大部分工作都在 Swift 编译器代码库中的一个 dev 分支中。谷歌正为 Swift 语言本身添加新特性——他们首先会在自己的分支中创建和测试这些新特性,然后会将它们合并到苹果的主分支中。这意味着运行在世界各地的 iOS 设备上的标准 Swift 语言最终将能集成这些改进。
现在来谈谈更实在的东西:谷歌正为 Swift 构建什么特性?
首先说个大特性。
可微分编程
近来,可微分编程炒得确实很热。特斯拉的人工智能负责人 Andrej Karpathy 称之为软件 2.0(Software 2.0),Yann LeCun 甚至宣称:「深度学习已死,可微分编程万岁。」另一些人则说有必要创建一套全新的工具了,包括新的 Git、新的 IDE 以及新的编程语言。Wink wink.
所以,什么是可微分编程?
简而言之,可微分编程是一种程序自身可被微分的编程范式。这让你可以设定一个你想要优化的具体目标,让你的程序可以根据这个目标自动计算自己的梯度,然后再在这个梯度的方向上优化自己。这和训练神经网络完全一样。
如果能让程序自己优化自己,我们也许就能创造出我们自己完全无法编写出来的程序。想想这一点还挺有趣:你的程序可以使用梯度针对特定任务优化自身,因此它的编程能力比你还强。过去几年的发展已经表明在越来越多的案例已经出现了这种情况,而且目前我们还看不到这一发展趋势的终点。
一种可微分的语言
写了这么长的介绍之后,终于可以谈谈谷歌为 Swift 开发的原生可微分编程版本了。
func cube(_ x: Float) -> Float { return x * x * x}let cube = gradient(of: cube)cube(2) // 8.0cube(2) // 12.0
这里我们首先定义了一个简单的函数 cube,其返回的结果是输入的立方。接下来就是激动人心的部分了:我们只需在原始函数上调用 gradient,就能创建原始函数的导数函数。这里没有使用任何软件库或外部代码,gradient 只是由 S4TF 团队为 Swift 语言引入的一个新函数。该函数利用了 S4TF 团队对 Swift 内核进行的修改,可以实现梯度函数的自动计算。
这是 Swift 的一个重大新特性。对于任意 Swift 代码,只要是可微分的,都可以自动计算梯度。上面的代码没有导入任何东西或奇怪的依赖包,就只是纯粹的 Swift。PyTorch、TensorFlow 或其它任何大型机器学习库都支持这一功能,但前提是你要使用特定于库的特定运算。而且在这些 Python 库中操作梯度并不如单纯用 Swift 那样轻量、透明,而且那些库集成也不如 Swift 原生集成那么好。
这是 Swift 语言的一个重大新特性;而且可以说 Swift 是首个为这一特性提供原生支持的主流语言
为了进一步说明这在实际应用中的使用方式,以下应用于一个标准机器学习训练流程的脚本更完整透彻展示了这一新特性:
struct Perceptron: @memberwise Differentiable { var weight: SIMD2 = .random(in: -1.. var bias: Float = 0
@differentiable func callAsFunction(_ input: SIMD2) -> Float { (weight * input).sum() + bias }}var model = Perceptron()let andGateData: [(x: SIMD2, y: Float)] = [ (x: [0, 0], y: 0), (x: [0, 1], y: 0), (x: [1, 0], y: 0), (x: [1, 1], y: 1),]for _ in 0.. let (loss, loss) = valueWithGradient(at: model) { model -> Float invar loss: Float = 0for (x, y) in andGateData { let ŷ = model(x) let error = y - ŷ loss = loss + error * error / 2 } return loss } print(loss) model.weight -= loss.weight * 0.02 model.bias -= loss.bias * 0.02}
同样,上面的代码完全是用 Swift 写的,不带任何依赖包。在这段代码中,我们可以看到谷歌为 Swift 引入的两个新特性:callAsFunction 和 valueWithGradient。第一个很简单,其作用是实例化类和结构体,让我们可以像调用函数一样调用它们。这里,Perceptron 结构体被实例化为了 model,然后 model 又在 let ŷ = model(x) 中被作为一个函数而调用。在这样操作时,实际上调用的是 callAsFunction 方法。如果你曾经用过 Keras 或 PyTorch 模型,你一定知道这是一种处理模型/层的常用方式。但 Keras 和 PyTorch 这两个库使用了 Python 的 *call* 方法来实现它们各自的 call 和 forward;Swift 之前没有这样的特性,于是谷歌把它加了进去。
上面的脚本中还有一个有趣的新特性:valueWithGradient。该函数会返回在特定点评估的函数或闭包的结果值和梯度。在以上案例中,我们定义并用作 valueWithGradient 的输入的闭包实际上是我们的损失函数。这个损失函数的输入是我们的模型,所以当我们说 valueWithGradient 会在特定的点评估我们的函数时,我们的意思是其会使用有特定权重配置的模型评估我们的损失函数。计算了上述的值和梯度之后,我们可以把值打印出来(这是我们的损失)并使用梯度更新模型的权重。重复这一过程一百次,我们就训练了一个模型。我们还可以访问损失函数内部的 andGateData,这是 Swift 闭包可以获取其周围上下文的又一案例。
微分外部代码
Swift 还有一个神奇的特性:我们不仅可以微分 Swift 运算,还能微分外部的、非 Swift 的软件库——只需我们在 Swift 中手动定义这些运算操作的导数。这意味着你可以使用 C 软件库中一些非常快速的实现或一些 Swift 还不具备的运算操作。你只需将其导入到你的项目中、编写导数代码,然后就可以在你的大型神经网络中使用这些运算操作,让反向传播等功能无缝运行。
此外,这件事做起来其实非常简单:
import Glibc // we import pow and log from herefunc powerOf2(_ x: Float) -> Float { return pow(2, x)}
@derivative(of: powerOf2)func dPowerOf2d(_ x: Float) -> (value: Float, pullback: (Float) -> Float) { let d = powerOf2(x) * log(2) return (value: d, pullback: { v in v * d })}
powerOf2(3), // 8gradient(of: powerOf2)(3) // 5.545
Glibc 是一个 C 软件库,因此 Swift 编译器并不知道其运算操作的导数是什么。通过使用 @derivative,我们可以为编译器提供有关这些外部运算操作的导数的信息,然后搭配 Swift 的原生运算,可以非常轻松地构建出大型的可微分网络。在这个示例中,我们导入了 Glibc 的 pow 和 log,并用它们创建了 powerOf2 函数及其导数。
为 Swift 开发的新 TensorFlow 软件库的当前版本就正在使用这一特性进行开发。这个库从 TF Eager 软件库的 C API 导入了其所有运算操作,但其不是将 TensorFlow 的自动微分系统直接接上去,而是要指定每个基础运算操作的导数,然后再让 Swift 处理。但是,并非所有运算都需要这种操作,因为许多运算都是更基本运算组合而成的,因此 Swift 可以自动推断它们的导数。但由于这个库的当前版本基于 TF Eager,因此存在一个大缺点:TF Eager 非常慢,因此 Swift 的这个版本也很慢。这个问题应该只是暂时性的,随着与 XLA(通过 x10)和 MLIR 的整合,这个问题可以得到解决。
话虽如此,实际上 Swift TensorFlow API 已经初具规模,谷歌的开发者已经可以使用这个 API 进行开发了。使用它,你可以这样训练一个简单模型:
import TensorFlowlet hiddenSize: Int = 10struct IrisModel: Layer { var layer1 = Dense(inputSize: 4, outputSize: hiddenSize, activation: relu) var layer2 = Dense(inputSize: hiddenSize, outputSize: hiddenSize, activation: relu) var layer3 = Dense(inputSize: hiddenSize, outputSize: 3)
@differentiable func callAsFunction(_ input: Tensor) -> Tensor { return input.sequenced(through: layer1, layer2, layer3) }}var model = IrisModel()let optimizer = SGD(for: model, learningRate: 0.01)let (loss, grads) = valueWithGradient(at: model) { model -> Tensor inlet logits = model(firstTrainFeatures) return softmaxCrossEntropy(logits: logits, labels: firstTrainLabels)}print("Current loss: \(loss)")
可以看到,这与之前的无导入的模型训练脚本非常相似。它的设计非常类似 PyTorch,真是太棒了。
与 Python 的互操作性
Swift 目前仍面临的一大问题是当前的机器学习和数据科学生态系统仍处于起步阶段。幸运的是,谷歌正在解决这个问题,其方式是为 Swift 纳入 Python 互操作性。其想法是让开发者可在 Swift 代码中编写 Python 代码;通过这种方式,数量庞大的 Python 软件库就能为 Swift 所用了。
这种操作的一种典型用例是用 Swift 训练模型,然后用 Python 的 matplotlib 来绘制图表:
import Pythonprint(Python.version)let np = Python.import("numpy")let plt = Python.import("matplotlib.pyplot")// let time = np.arange(0, 10, 0.01)let time = Array(stride(from: 0, through: 10, by: 0.01)).makeNumpyArray()let amplitude = np.exp(-0.1 * time)let position = amplitude * np.sin(3 * time)
plt.figure(figsize: [15, 10])
plt.plot(time, position)plt.plot(time, amplitude)plt.plot(time, -amplitude)
plt.xlabel("Time (s)")plt.ylabel("Position (m)")plt.title("Oscillations")
plt.show()
这看起来就像是单纯的 Python 代码加了一点 let 和 var 语句。这是由谷歌提供的一段代码示例。作者只做了一项修改,即注释掉了一行 Python 代码,并用 Swift 对其进行了重写。可以看到,这两者在这里竟然可以交互得如此之好。这项任务完成起来并不如完全使用 Python 那样清晰简洁,因为我们必须使用 makeNumpyArray() 和 Array();但这种操作是可行的。
谷歌成功实现 Python 互操作性的方法是引入了 PythonObject 类型,其可表示 Python 中的任何对象。Python 互操作性被限定在单个 Swift 软件库中,因此 S4TF 团队仅需为 Swift 语言本身添加少量功能,比如添加少量改进以适应 Python 的极端动态性。至于现在的 Python 支持已经达到了何种程度,目前尚不清楚他们将如何处理 with 语句等更地道的 Python 元素,而且可以肯定地说还有其它一些极端情况有待考虑;尽管如此,现在已经实现的成果就已经很不错了。
而在 Swift 与其它语言的整合方面,作者对 Swift 的最早的兴趣点之一就是想看看它在处理实时计算机视觉任务上的表现。因为这个原因,作者最终找到了 OpenCV 的一个 Swift 版本,而通过 FastAI 的论坛,最终找到了一个大有潜力的 OpenCV 封装类(wrapper):SwiftCV。但是,这个库很奇怪。OpenCV 是用 C++ 构建的(并且刚刚废弃了其 C API),而 Swift 目前并不支持 C++(不过将会支持)。因此,SwiftCV 必须将 OpenCV 代码封装在 C++ 代码的一个兼容 C 的子集中,然后再以 C 软件包的形式导入。之后,才能将其封装到 Swift 中。
S4TF 项目的当前状态
尽管作者对 S4TF 项目一直不吝赞美之辞,但也必须承认其还不足以支持一般的生产使用。其新的 API 仍在不断变化,这个新的 TensorFlow 库的性能也仍然不是很好;即便其数据科学生态系统正在发展壮大,但总体仍处于起步阶段。最重要的是,其 Linux 支持情况很奇怪,目前官方仅支持 Ubuntu。考虑到所有这些问题,要保证所有这些问题及时得到解决,还有很多工作要做。
谷歌正在努力提升其性能,包括最近添加的 x10 以及在让 MLIR 达到标准方面所做的工作。另外,谷歌还有一些项目致力于在 Swift 中复制许多 Python 数据科学生态系统的功能,比如 SwiftPlot、类似 Pandas 的 Penguin、类似 Scikit-learn 的 swiftML。
但最让人惊讶的是苹果公司也与谷歌在同一方向上推动 Swift 的发展。在苹果的 Swift 发展路线图上,下一个重大版本的主要目标是在非苹果平台上建立不断发展增长的 Swift 软件生态系统。这一目标也反映在了苹果对多个项目的支持上,比如 Swift Server Work Group、类似 numpy 的 Numerics、一个运行在 Linux 上的官方语言服务器以及将 Swift 移植到 Windows 系统的工作。
此外,Fast.ai 的 Sylvain Gugger 也正为 FastAI 构建一个 Swift 版本,而 Jeremy Howard 也已经将 Swift 课程纳入到了他们的广受欢迎的在线课程中。另外,第一批基于 S4TF 相关软件库的学术论文也正陆陆续续发表出来。
总结
在作者本人看来,尽管 Swift 很有可能发展成机器学习生态系统的一大关键角色,但风险仍然存在。其中最大的风险是:尽管 Python 存有缺陷,但对于大部分机器学习任务来说已经足够好了。对于许多已经熟悉 Python 的人来说,惯性可能太大,也没有换成另一种语言的理由。另外,谷歌已经不是一次两次放弃大型项目了,而 S4TF 的一些关键人员的脱离也让人担忧。
给出了这些免责声明之后,作者仍然觉得 Swift 是一门很棒的语言,这些新增的功能也极具创新性,相信它们最终能在机器学习社区找到自己的位置。因此,如果你也想为这个潜力无穷的项目添砖加瓦,现在就是很好的时机。Swift 在机器学习领域的地位还远未确立,还有很多工具有待开发。随着 Swift 机器学习生态系统的持续发展,现在的小项目也许未来可以成长为巨大的社区项目。
原文链接:
https://tryolabs.com/blog/2020/04/02/swift-googles-bet-on-differentiable-programming/