選自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: { $0 > $1 } )
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/