隨著機(jī)器學(xué)習(xí)的興起,Python 逐步成為了「最受歡迎」的語言。它簡(jiǎn)單易用、邏輯明確并擁有海量的擴(kuò)展包,因此其不僅成為機(jī)器學(xué)習(xí)與數(shù)據(jù)科學(xué)的首選語言,同時(shí)在網(wǎng)頁、數(shù)據(jù)爬取可科學(xué)研究等方面成為不二選擇。此外,很多入門級(jí)的機(jī)器學(xué)習(xí)開發(fā)者都是跟隨大流選擇 Python,但到底為什么要選擇 Python 就是本文的核心內(nèi)容。
本教程的目的是讓你相信兩件事:首先,Python 是一種非常棒的編程語言;其次,如果你是一名科學(xué)家,Python 很可能值得你去學(xué)習(xí)。本教程并非想要說明 Python 是一種萬能的語言;相反,作者明確討論了在幾種情況下,Python 并不是一種明智的選擇。本教程的目的只是提供對(duì) Python 一些核心特征的評(píng)論,并闡述作為一種通用的科學(xué)計(jì)算語言,它比其他常用的替代方案(最著名的是 R 和 Matlab)更有優(yōu)勢(shì)。
本教程的其余部分假定你已經(jīng)有了一些編程經(jīng)驗(yàn),如果你非常精通其他以數(shù)據(jù)為中心的語言(如 R 或 Matlab),理解本教程就會(huì)非常容易。本教程不能算作一份關(guān)于 Python 的介紹,且文章重點(diǎn)在于為什么應(yīng)該學(xué)習(xí) Python 而不是怎樣寫 Python 代碼(盡管其他地方有大量的優(yōu)秀教程)。
概述
Python 是一種廣泛使用、易于學(xué)習(xí)、高級(jí)、通用的動(dòng)態(tài)編程語言。這很令人滿意,所以接下來分開討論一些特征。
Python(相對(duì)來說)易于學(xué)習(xí)
編程很難,因此從絕對(duì)意義上來說,除非你已經(jīng)擁有編程經(jīng)驗(yàn),否則編程語言難以學(xué)習(xí)。但是,相對(duì)而言,Python 的高級(jí)屬性(見下一節(jié))、語法可讀性和語義直白性使得它比其他語言更容易學(xué)習(xí)。例如,這是一個(gè)簡(jiǎn)單 Python 函數(shù)的定義(故意未注釋),它將一串英語單詞轉(zhuǎn)換為(crummy)Pig Latin:
def pig_latin(text):
''' Takes in a sequence of words and converts it to (imperfect) pig latin. '''
word_list = text.split(' ')
output_list = []
for word in word_list:
word = word.lower()
if word.isalpha():
first_char = word[0]
if first_char in 'aeiou':
word = word + 'ay'
else:
word = word[1:] + first_char + 'yay'
output_list.append(word)
pygged = ' '.join(output_list)
return pygged
以上函數(shù)事實(shí)上無法生成完全有效的 Pig Latin(假設(shè)存在「有效 Pig Latin」),但這沒有關(guān)系。有些情況下它是可行的:
test1 = pig_latin("let us see if this works")
print(test1)
拋開 Pig Latin 不說,這里的重點(diǎn)只是,出于幾個(gè)原因,代碼是很容易閱讀的。首先,代碼是在高級(jí)抽象中編寫的(下面將詳細(xì)介紹),因此每行代碼都會(huì)映射到一個(gè)相當(dāng)直觀的操作。這些操作可以是「取這個(gè)單詞的第一個(gè)字符」,而不是映射到一個(gè)沒那么直觀的低級(jí)操作,例如「為一個(gè)字符預(yù)留一個(gè)字節(jié)的內(nèi)存,稍后我會(huì)傳入一個(gè)字符」。其次,控制結(jié)構(gòu)(如,for—loops,if—then 條件等)使用諸如「in」,「and」和「not」的簡(jiǎn)單單詞,其語義相對(duì)接近其自然英語含義。第三,Python 對(duì)縮進(jìn)的嚴(yán)格控制強(qiáng)加了一種使代碼可讀的規(guī)范,同時(shí)防止了某些常見的錯(cuò)誤。第四,Python 社區(qū)非常強(qiáng)調(diào)遵循樣式規(guī)定和編寫「Python 式的」代碼,這意味著相比使用其他語言的程序員而言,Python 程序員更傾向于使用一致的命名規(guī)定、行的長(zhǎng)度、編程習(xí)慣和其他許多類似特征,它們共同使別人的代碼更易閱讀(盡管這可以說是社區(qū)的一個(gè)特征而不是語言本身)。
Python 是一種高級(jí)語言
與其他許多語言相比,Python 是一種相對(duì)「高級(jí)」的語言:它不需要(并且在許多情況下,不允許)用戶擔(dān)心太多底層細(xì)節(jié),而這是其他許多語言需要去處理的。例如,假設(shè)我們想創(chuàng)建一個(gè)名為「my_box_of_things」的變量當(dāng)作我們所用東西的容器。我們事先不知道我們想在盒子中保留多少對(duì)象,同時(shí)我們希望在添加或刪除對(duì)象時(shí),對(duì)象數(shù)量可以自動(dòng)增減。所以這個(gè)盒子需要占據(jù)一個(gè)可變的空間:在某個(gè)時(shí)間點(diǎn),它可能包含 8 個(gè)對(duì)象(或「元素」),而在另一個(gè)時(shí)間點(diǎn),它可能包含 257 個(gè)對(duì)象。在像 C 這樣的底層語言中,這個(gè)簡(jiǎn)單的要求就已經(jīng)給我們的程序帶來了一些復(fù)雜性,因?yàn)槲覀冃枰崆奥暶骱凶有枰紦?jù)多少空間,然后每次我們想要增加盒子需要的空間時(shí),我么需要明確創(chuàng)建一個(gè)占據(jù)更多空間的全新的盒子,然后將所有東西拷貝到其中。
相比之下,在 Python 中,盡管在底層這些過程或多或少會(huì)發(fā)生(效率較低),但我們?cè)谑褂酶呒?jí)語言編寫時(shí)并不需要擔(dān)心這一部分。從我們的角度來看,我們可以創(chuàng)建自己的盒子并根據(jù)喜好添加或刪除對(duì)象:
# Create a box (really, a 'list') with 5 things# Create
my_box_of_things = ['Davenport', 'kettle drum', 'swallow-tail coat', 'table cloth', 'patent leather shoes']
print(my_box_of_things)
['Davenport', 'kettle drum', 'swallow-tail coat', 'table cloth', 'patent leather shoes']
# Add a few more things
my_box_of_things += ['bathing suit', 'bowling ball', 'clarinet', 'ring']
# Maybe add one last thing
my_box_of_things.append('radio that only needs a fuse')
# Let's see what we have...
print(my_box_of_things)
更一般來說,Python(以及根據(jù)定義的其他所有高級(jí)語言)傾向于隱藏需要在底層語言中明確表達(dá)的各種死記硬背的聲明。這使得我們可以編寫非常緊湊、清晰的代碼(盡管它通常以降低性能為代價(jià),因?yàn)閮?nèi)部不再可訪問,因此優(yōu)化變得更加困難)。
例如,考慮從文件中讀取純文本這樣看似簡(jiǎn)單的行為。對(duì)于與文件系統(tǒng)直接接觸而傷痕累累的開發(fā)者來說,從概念上看似乎只需要兩個(gè)簡(jiǎn)單的操作就可以完成:首先打開一個(gè)文件,然后從其中讀取。實(shí)際過程遠(yuǎn)不止這些,并且比 Python 更底層的語言通常強(qiáng)制(或至少是鼓勵(lì))我們?nèi)コ姓J(rèn)這一點(diǎn)。例如,這是在 Java 中從文件中讀取內(nèi)容的規(guī)范(盡管肯定不是最簡(jiǎn)潔的)方法:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class ReadFile {
public static void main(String[] args) throws IOException{
String fileContents = readEntireFile("./foo.txt");
}
private static String readEntireFile(String filename) throws IOException {
FileReader in = new FileReader(filename);
StringBuilder contents = new StringBuilder();
char[] buffer = new char[4096];
int read = 0;
do {
contents.append(buffer, 0, read);
read = in.read(buffer);
} while (read >= 0);
return contents.toString();
}
}
你可以看到我們不得不做一些令人苦惱的事,例如導(dǎo)入文件讀取器、為文件中的內(nèi)容創(chuàng)建一個(gè)緩存,以塊的形式讀取文件塊并將它們分配到緩存中等等。相比之下,在 Python 中,讀取文件中的全部?jī)?nèi)容只需要如下代碼:
# Read the contents of "hello_world.txt"
text = open("hello_world.txt").read()
當(dāng)然,這種簡(jiǎn)潔性并不是 Python 獨(dú)有的;還有其他許多高級(jí)語言同樣隱藏了簡(jiǎn)單請(qǐng)求所暗含的大部分令人討厭的內(nèi)部過程(如,Ruby,R,Haskell 等)。但是,相對(duì)來說比較少有其他語言能與接下來探討的 Python 特征相媲美。
Python 是一種通用語言
根據(jù)設(shè)計(jì),Python 是一種通用的語言。也就是說,它旨在允許程序員在任何領(lǐng)域編寫幾乎所有類型的應(yīng)用,而不是專注于一類特定的問題。在這方面,Python 可以與(相對(duì))特定領(lǐng)域的語言進(jìn)行對(duì)比,如 R 或 PHP。這些語言原則上可用于很多情形,但仍針對(duì)特定用例進(jìn)行了明確優(yōu)化(在這兩個(gè)示例中,分別用于統(tǒng)計(jì)和網(wǎng)絡(luò)后端開發(fā))。
Python 通常被親切地成為「所有事物的第二個(gè)最好的語言」,它很好地捕捉到了這樣的情緒,盡管在很多情況下 Python 并不是用于特定問題的最佳語言,但它通常具有足夠的靈活性和良好的支持性,使得人們?nèi)匀豢梢韵鄬?duì)有效地解決問題。事實(shí)上,Python 可以有效地應(yīng)用于許多不同的應(yīng)用中,這使得學(xué)習(xí) Python 成為一件相當(dāng)有價(jià)值的事。因?yàn)樽鳛橐粋€(gè)軟件開發(fā)人員,能夠使用單一語言實(shí)現(xiàn)所有事情,而不是必須根據(jù)所執(zhí)行的項(xiàng)目在不同語言和環(huán)境間進(jìn)行切換,是一件非常棒的事。
標(biāo)準(zhǔn)庫
通過瀏覽標(biāo)準(zhǔn)庫中可用的眾多模塊列表,即 Python 解釋器自帶的工具集(沒有安裝第三方軟件包),這可能是最容易理解 Python 通用性的方式。若考慮以下幾個(gè)示例:
os: 系統(tǒng)操作工具
re:正則表達(dá)
collections:有用的數(shù)據(jù)結(jié)構(gòu)
multiprocessing:簡(jiǎn)單的并行化工具
pickle:簡(jiǎn)單的序列化
json:讀和寫 JSON
argparse:命令行參數(shù)解析
functools:函數(shù)化編程工具
datetime:日期和時(shí)間函數(shù)
cProfile:分析代碼的基本工具
這張列表乍一看并不令人印象深刻,但對(duì)于 Python 開發(fā)者來說,使用它們是一個(gè)相對(duì)常見的經(jīng)歷。很多時(shí)候用谷歌搜索一個(gè)看似重要甚至有點(diǎn)深?yuàn)W的問題,我們很可能找到隱藏在標(biāo)準(zhǔn)庫模塊內(nèi)的內(nèi)置解決方案。
JSON,簡(jiǎn)單的方法
例如,假設(shè)你想從 web.JSON 中讀取一些 JSON 數(shù)據(jù),如下所示:
data_string = '''
[
{
"_id": "59ad8f86450c9ec2a4760fae",
"name": "Dyer Kirby",
"registered": "2016-11-28T03:41:29 +08:00",
"latitude": -67.170365,
"longitude": 130.932548,
"favoriteFruit": "durian"
},
{
"_id": "59ad8f8670df8b164021818d",
"name": "Kelly Dean",
"registered": "2016-12-01T09:39:35 +08:00",
"latitude": -82.227537,
"longitude": -175.053135,
"favoriteFruit": "durian"
}
]
'''
我們可以花一些時(shí)間自己編寫 json 解析器,或試著去找一個(gè)有效讀取 json 的第三方包。但我們很可能是在浪費(fèi)時(shí)間,因?yàn)?Python 內(nèi)置的 json 模塊已經(jīng)能完全滿足我們的需要:
import json
data = json.loads(data_string)
print(data)
'''
[{'_id': '59ad8f86450c9ec2a4760fae', 'name': 'Dyer Kirby', 'registered': '2016-11-28T03:41:29 +08:00', 'latitude': -67.170365, 'longitude': 130.932548, 'favoriteFruit': 'durian'}, {'_id': '59ad8f8670df8b164021818d', 'name': 'Kelly Dean', 'registered': '2016-12-01T09:39:35 +08:00', 'latitude': -82.227537, 'longitude': -175.053135, 'favoriteFruit': 'durian'}]
請(qǐng)注意,在我們能于 json 模塊內(nèi)使用 loads 函數(shù)前,我們必須導(dǎo)入 json 模塊。這種必須將幾乎所有功能模塊明確地導(dǎo)入命名空間的模式在 Python 中相當(dāng)重要,且基本命名空間中可用的內(nèi)置函數(shù)列表非常有限。許多用過 R 或 Matlab 的開發(fā)者會(huì)在剛接觸時(shí)感到惱火,因?yàn)檫@兩個(gè)包的全局命名空間包含數(shù)百甚至上千的內(nèi)置函數(shù)。但是,一旦你習(xí)慣于輸入一些額外字符,它就會(huì)使代碼更易于讀取和管理,同時(shí)命名沖突的風(fēng)險(xiǎn)(R 語言中經(jīng)常出現(xiàn))被大大降低。
優(yōu)異的外部支持
當(dāng)然,Python 提供大量?jī)?nèi)置工具來執(zhí)行大量操作并不意味著總需要去使用這些工具??梢哉f比 Python 豐富的標(biāo)準(zhǔn)庫更大的賣點(diǎn)是龐大的 Python 開發(fā)者社區(qū)。多年來,Python 一直是世界上最流行的動(dòng)態(tài)編程語言,開發(fā)者社區(qū)也貢獻(xiàn)了眾多高質(zhì)量的安裝包。
如下 Python 軟件包在不同領(lǐng)域內(nèi)提供了被廣泛使用的解決方案(這個(gè)列表在你閱讀本文的時(shí)候可能已經(jīng)過時(shí)了!):
Web 和 API 開發(fā):flask,Django,F(xiàn)alcon,hug
爬取數(shù)據(jù)和解析文本/標(biāo)記: requests,beautifulsoup,scrapy
自然語言處理(NLP):nltk,gensim,textblob
數(shù)值計(jì)算和數(shù)據(jù)分析:numpy,scipy,pandas,xarray
機(jī)器學(xué)習(xí):scikit-learn,Theano,Tensorflow,keras
圖像處理:pillow,scikit-image,OpenCV
作圖:matplotlib,seaborn,ggplot,Bokeh
等等
Python 的一個(gè)優(yōu)點(diǎn)是有出色的軟件包管理生態(tài)系統(tǒng)。雖然在 Python 中安裝包通常比在 R 或 Matlab 中更難,這主要是因?yàn)?Python 包往往具有高度的模塊化和/或更多依賴于系統(tǒng)庫。但原則上至少大多數(shù) Python 的包可以使用 pip 包管理器通過命令提示符安裝。更復(fù)雜的安裝程序和包管理器,如 Anaconda 也大大減少了配置新 Python 環(huán)境時(shí)產(chǎn)生的痛苦。
Python 是一種(相對(duì))快速的語言
這可能令人有點(diǎn)驚訝:從表面上看,Python 是一種快速語言的說法看起來很愚蠢。因?yàn)樵跇?biāo)準(zhǔn)測(cè)試時(shí),和 C 或 Java 這樣的編譯語言相比,Python 通常會(huì)卡頓。毫無疑問,如果速度至關(guān)重要(例如,你正在編寫 3D 圖形引擎或運(yùn)行大規(guī)模的流體動(dòng)力學(xué)模擬實(shí)驗(yàn)),Python 可能不會(huì)成為你最優(yōu)選擇的語言,甚至不會(huì)是第二好的語言。但在實(shí)際中,許多科學(xué)家工作流程中的限制因素不是運(yùn)行時(shí)間而是開發(fā)時(shí)間。一個(gè)花費(fèi)一個(gè)小時(shí)運(yùn)行但只需要 5 分鐘編寫的腳本通常比一個(gè)花費(fèi) 5 秒鐘運(yùn)行但是需要一個(gè)禮拜編寫和調(diào)試的腳本更合意。此外,正如我們將在下面看到的,即使我們所用的代碼都用 Python 編寫,一些優(yōu)化操作通常可以使其運(yùn)行速度幾乎與基于 C 的解決方案一樣快。實(shí)際上,對(duì)大多數(shù)科學(xué)家家來說,基于 Python 的解決方案不夠快的情況并不是很多,而且隨著工具的改進(jìn),這種情況的數(shù)量正在急劇減少。
不要重復(fù)做功
軟件開發(fā)的一般原則是應(yīng)該盡可能避免做重復(fù)工作。當(dāng)然,有時(shí)候是沒法避免的,并且在很多情況下,為問題編寫自己的解決方案或創(chuàng)建一個(gè)全新的工具是有意義的。但一般來說,你自己編寫的 Python 代碼越少,性能就越好。有以下幾個(gè)原因:
Python 是一種成熟的語言,所以許多現(xiàn)有的包有大量的用戶基礎(chǔ)并且經(jīng)過大量?jī)?yōu)化。例如,對(duì) Python 中大多數(shù)核心科學(xué)庫(numpy,scipy,pandas 等)來說都是如此。
大多數(shù) Python 包實(shí)際上是用 C 語言編寫的,而不是用 Python 編寫的。對(duì)于大多數(shù)標(biāo)準(zhǔn)庫,當(dāng)你調(diào)用一個(gè) Python 函數(shù)時(shí),實(shí)際上很大可能你是在運(yùn)行具有 Python 接口的 C 代碼。這意味著無論你解決問題的算法有多精妙,如果你完全用 Python 編寫,而內(nèi)置的解決方案是用 C 語言編寫的,那你的性能可能不如內(nèi)置的方案。例如,以下是運(yùn)行內(nèi)置的 sum 函數(shù)(用 C 編寫):
# Create a list of random floats
import random
my_list = [random.random() for i in range(10000)]
# Python's built-in sum() function is pretty fast
%timeit sum(my_list)
47.7 μs ± 4.5 μs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
從算法上來說,你沒有太多辦法來加速任意數(shù)值列表的加和計(jì)算。所以你可能會(huì)想這是什么鬼,你也許可以用 Python 自己寫加和函數(shù),也許這樣可以封裝內(nèi)置 sum 函數(shù)的開銷,以防它進(jìn)行任何內(nèi)部驗(yàn)證。嗯……并非如此。
def ill_write_my_own_sum_thank_you_very_much(l):
s = 0
for elem in my_list:
s += elem
return s
%timeit ill_write_my_own_sum_thank_you_very_much(my_list)
331 μs ± 50.9 μs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
至少在這個(gè)例子中,運(yùn)行你自己簡(jiǎn)單的代碼很可能不是一個(gè)好的解決方案。但這不意味著你必須使用內(nèi)置 sum 函數(shù)作為 Python 中的性能上限!由于 Python 沒有針對(duì)涉及大型輸入的數(shù)值運(yùn)算進(jìn)行優(yōu)化,因此內(nèi)置方法在加和大型列表時(shí)是表現(xiàn)次優(yōu)。在這種情況下我們應(yīng)該做的是提問:「是否有其他一些 Python 庫可用于對(duì)潛在的大型輸入進(jìn)行數(shù)值分析?」正如你可能想的那樣,答案是肯定的:NumPy 包是 Python 的科學(xué)生態(tài)系統(tǒng)中的主要成分,Python 中的絕大多數(shù)科學(xué)計(jì)算包都以某種方式構(gòu)建在 NumPy 上,它包含各種能幫助我們的計(jì)算函數(shù)。
在這種情況下,新的解決方案是非常簡(jiǎn)單的:如果我們將純 Python 列表轉(zhuǎn)化為 NumPy 數(shù)組,我們就可以立即調(diào)用 NumPy 的 sum 方法,我們可能期望它應(yīng)該比核心的 Python 實(shí)現(xiàn)更快(技術(shù)上講,我們可以傳入一個(gè) Python 列表到 numpy.sum 中,它會(huì)隱式地將其轉(zhuǎn)換為數(shù)組,但如果我們打算復(fù)用該 NumPy 數(shù)組,最好明確地轉(zhuǎn)化它)。
import numpy as np
my_arr = np.array(my_list)
%timeit np.sum(my_arr)
7.92 μs ± 1.15 μs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
因此簡(jiǎn)單地切換到 NumPy 可加快一個(gè)數(shù)量級(jí)的列表加和速度,而不需要自己去實(shí)現(xiàn)任何東西。
需要更快的速度?
當(dāng)然,有時(shí)候即使使用所有基于 C 的擴(kuò)展包和高度優(yōu)化的實(shí)現(xiàn),你現(xiàn)有的 Python 代碼也無法快速削減時(shí)間。在這種情況下,你的下意識(shí)反應(yīng)可能是放棄并轉(zhuǎn)化到一個(gè)「真正」的語言。并且通常,這是一種完全合理的本能。但是在你開始使用 C 或 Java 移植代碼前,你需要考慮一些不那么費(fèi)力的方法。
使用 Python 編寫 C 代碼
首先,你可以嘗試編寫 Cython 代碼。Cython 是 Python 的一個(gè)超集(superset),它允許你將(某些)C 代碼直接嵌入到 Python 代碼中。Cython 不以編譯的方式運(yùn)行,相反你的 Python 文件(或其中特定的某部分)將在運(yùn)行前被編譯為 C 代碼。實(shí)際的結(jié)果是你可以繼續(xù)編寫看起來幾乎完全和 Python 一樣的代碼,但仍然可以從 C 代碼的合理引入中獲得性能提升。特別是簡(jiǎn)單地提供 C 類型的聲明通常可以顯著提高性能。
以下是我們簡(jiǎn)單加和代碼的 Cython 版本:
# Jupyter extension that allows us to run Cython cell magics
%load_ext Cython
The Cython extension is already loaded. To reload it, use:
%reload_ext Cython
%%%%cythoncython
defdef ill_write_my_own_cython_sum_thank_you_very_muchill_write (list arr):
cdef int N = len(arr)
cdef float x = arr[0]
cdef int i
for i in range(1 ,N):
x += arr[i]
return x
%timeit ill_write_my_own_cython_sum_thank_you_very_much(my_list)
227 μs ± 48.4 μs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
關(guān)于 Cython 版本有幾點(diǎn)需要注意一下。首先,在你第一次執(zhí)行定義該方法的單元時(shí),需要很少的(但值得注意的)時(shí)間來編譯。那是因?yàn)?,與純粹的 Python 不同,代碼在執(zhí)行時(shí)不是逐行解譯的;相反,Cython 式的函數(shù)必須先編譯成 C 代碼才能調(diào)用。
其次,雖然 Cython 式的加和函數(shù)比我們上面寫的簡(jiǎn)單的 Python 加和函數(shù)要快,但仍然比內(nèi)置求和方法和 NumPy 實(shí)現(xiàn)慢得多。然而,這個(gè)結(jié)果更有力地說明了我們特定的實(shí)現(xiàn)過程和問題的本質(zhì),而不是 Cython 的一般好處;在許多情況下,一個(gè)有效的 Cython 實(shí)現(xiàn)可以輕易地將運(yùn)行時(shí)間提升一到兩個(gè)數(shù)量級(jí)。
使用 NUMBA 進(jìn)行清理
Cython 并不是提升 Python 內(nèi)部性能的唯一方法。從開發(fā)的角度來看,另一種更簡(jiǎn)單的方法是依賴于即時(shí)編譯,其中一段 Python 代碼在第一次調(diào)用時(shí)被編譯成優(yōu)化的 C 代碼。近年來,在 Python 即時(shí)編譯器上取得了很大進(jìn)展。也許最成熟的實(shí)現(xiàn)可以在 numba 包中找到,它提供了一個(gè)簡(jiǎn)單的 jit 修飾器,可以輕易地結(jié)合其他任何方法。
我們之前的示例并沒有強(qiáng)調(diào) JITs 可以產(chǎn)生多大的影響,所以我們轉(zhuǎn)向一個(gè)稍微復(fù)雜點(diǎn)的問題。這里我們定義一個(gè)被稱為 multiply_randomly 的新函數(shù),它將一個(gè)一維浮點(diǎn)數(shù)數(shù)組作為輸入,并將數(shù)組中的每個(gè)元素與其他任意一個(gè)隨機(jī)選擇的元素相乘。然后它返回所有隨機(jī)相乘的元素和。
讓我們從定義一個(gè)簡(jiǎn)單的實(shí)現(xiàn)開始,我們甚至都不采用向量化來代替隨機(jī)相乘操作。相反,我們簡(jiǎn)單地遍歷數(shù)組中的每個(gè)元素,從中隨機(jī)挑選一個(gè)其他元素,將兩個(gè)元素相乘并將結(jié)果分配給一個(gè)特定的索引。如果我們用基準(zhǔn)問題測(cè)試這個(gè)函數(shù),我們會(huì)發(fā)現(xiàn)它運(yùn)行得相當(dāng)慢。
import numpy as np
def multiply_randomly_naive(l):
n = l.shape[0]
result = np.zeros(shape=n)
for i in range(n):
ind = np.random.randint(0, n)
result[i] = l[i] * l[ind]
return np.sum(result)
%timeit multiply_randomly_naive(my_arr)
25.7 ms ± 4.61 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
在我們即時(shí)編譯之前,我們應(yīng)該首先自問是否上述函數(shù)可以用更加符合 NumPy 形式的方法編寫。NumPy 針對(duì)基于數(shù)組的操作進(jìn)行了優(yōu)化,因此應(yīng)該不惜一切代價(jià)地避免使用循環(huán)操作,因?yàn)樗鼈儠?huì)非常慢。幸運(yùn)的是,我們的代碼非常容易向量化(并且易于閱讀):
def multiply_randomly_vectorized(l):
n = len(l)
inds = np.random.randint(0, n, size=n)
result = l * l[inds]
return np.sum(result)
%timeit multiply_randomly_vectorized(my_arr)
234 μs ± 50.9 μs per loop (mean ± std. dev. of 7 runs, 1000 loops each)
在作者的機(jī)器上,向量化版本的運(yùn)行速度比循環(huán)版本的代碼快大約 100 倍。循環(huán)和數(shù)組操作之間的這種性能差異對(duì)于 NumPy 來說是非常典型的,因此我們要在算法上思考你所做的事的重要性。
假設(shè)我們不是花時(shí)間重構(gòu)我們樸素的、緩慢的實(shí)現(xiàn),而是簡(jiǎn)單地在我們的函數(shù)上加一個(gè)修飾器去告訴 numba 庫我們要在第一次調(diào)用它時(shí)將函數(shù)編譯為 C。字面上,下面的函數(shù) multiply_randomly_naive_jit 與上面定義的函數(shù) multiply_randomly_naive 之間的唯一區(qū)別是 @jit 修飾器。當(dāng)然,4 個(gè)小字符是沒法造成那么大的差異的。對(duì)吧?
import numpy as np
from numba import jit
@jit
def multiply_randomly_naive_jit(l):
n = l.shape[0]
result = np.zeros(shape=n)
for i in range(n):
ind = np.random.randint(0, n)
result[i] = l[i] * l[ind]
return np.sum(result)
%timeit multiply_randomly_naive_jit(my_arr)
135 μs ± 22.4 μs per loop (mean ± std. dev. of 7 runs, 1 loop each)
令人驚訝的是,JIT 編譯版本的樸素函數(shù)事實(shí)上比向量化的版本跑得更快。
有趣的是,將 @jit 修飾器應(yīng)用于函數(shù)的向量化版本(將其作為聯(lián)系留給讀者)并不能提供更多幫助。在 numba JIT 編譯器用于我們的代碼之后,Python 實(shí)現(xiàn)的兩個(gè)版本都以同樣的速度運(yùn)行。因此,至少在這個(gè)例子中,即時(shí)編譯不僅可以毫不費(fèi)力地為我們提供類似 C 的速度,而且可以避免以 Python 式地去優(yōu)化代碼。
這可能是一個(gè)相當(dāng)有力的結(jié)論,因?yàn)椋╝)現(xiàn)在 numba 的 JIT 編譯器只覆蓋了 NumPy 特征的一部分,(b)不能保證編譯的代碼一定比解譯的代碼運(yùn)行地更快(盡管這通常是一個(gè)有效的假設(shè))。這個(gè)例子真正的目的是提醒你,在你宣稱它慢到無法去實(shí)現(xiàn)你想要做的事之前,其實(shí)你在 Python 中有許多可用的選擇。值得注意的是,如 C 集成和即時(shí)編譯,這些性能特征都不是 Python 獨(dú)有的。Matlab 最近的版本自動(dòng)使用即時(shí)編譯,同時(shí) R 支持 JIT 編譯(通過外部庫)和 C ++ 集成(Rcpp)。
Python 是天生面向?qū)ο蟮?/p>
即使你正在做的只是編寫一些簡(jiǎn)短的腳本去解析文本或挖掘一些數(shù)據(jù),Python 的許多好處也很容易領(lǐng)會(huì)到。在你開始編寫相對(duì)大型的代碼片段前,Python 的最佳功能之一可能并不明顯:Python 具有設(shè)計(jì)非常優(yōu)雅的基于對(duì)象的數(shù)據(jù)模型。事實(shí)上,如果你查看底層,你會(huì)發(fā)現(xiàn) Python 中的一切都是對(duì)象。甚至函數(shù)也是對(duì)象。當(dāng)你調(diào)用一個(gè)函數(shù)的時(shí)候,你事實(shí)上正在調(diào)用 Python 中每個(gè)對(duì)象都運(yùn)行的 __call__ 方法:
def double(x):
return x*2
# Lists all object attributes
dir(double)
['__annotations__',
'__call__',
'__class__',
'__closure__',
'__code__',
'__defaults__',
'__delattr__',
'__dict__',
'__dir__',
'__doc__',
'__eq__',
'__format__',
'__ge__',
'__get__',
'__getattribute__',
'__globals__',
'__gt__',
'__hash__',
'__init__',
'__init_subclass__',
'__kwdefaults__',
'__le__',
'__lt__',
'__module__',
'__name__',
'__ne__',
'__new__',
'__qualname__',
'__reduce__',
'__reduce_ex__',
'__repr__',
'__setattr__',
'__sizeof__',
'__str__',
'__subclasshook__']
事實(shí)上,因?yàn)?Python 中的一切都是對(duì)象,Python 中的所有內(nèi)容遵循相同的核心邏輯,實(shí)現(xiàn)相同的基本 API,并以類似的方式進(jìn)行擴(kuò)展。對(duì)象模型也恰好非常靈活:可以很容易地定義新的對(duì)象去實(shí)現(xiàn)有意思的事,同時(shí)仍然表現(xiàn)得相對(duì)可預(yù)測(cè)。也許并不奇怪,Python 也是編寫特定領(lǐng)域語言(DSLs)的一個(gè)絕佳選擇,因?yàn)樗试S用戶在很大程度上重載和重新定義現(xiàn)有的功能。
魔術(shù)方法
Python 對(duì)象模型的核心部分是它使用「魔術(shù)」方法。這些在對(duì)象上實(shí)現(xiàn)的特殊方法可以更改 Python 對(duì)象的行為——通常以重要的方式。魔術(shù)方法(Magic methods)通常以雙下劃線開始和結(jié)束,一般來說,除非你知道自己在做什么,否則不要輕易篡改它們。但一旦你真的開始改了,你就可以做些相當(dāng)了不起的事。
舉個(gè)簡(jiǎn)單的例子,我們來定義一個(gè)新的 Brain 對(duì)象。首先,Barin 不會(huì)進(jìn)行任何操作,它只會(huì)待在那兒禮貌地發(fā)呆。
class Brain(object):
def __init__(self, owner, age, status):
self.owner = owner
self.age = age
self.status = status
def __getattr__(self, attr):
if attr.startswith('get_'):
attr_name = attr.split('_')[1]
if hasattr(self, attr_name):
return lambda: getattr(self, attr_name)
raise AttributeError
在 Python 中,__init__ 方法是對(duì)象的初始化方法——當(dāng)我們嘗試創(chuàng)建一個(gè)新的 Brain 實(shí)例時(shí),它會(huì)被調(diào)用。通常你需要在編寫新類時(shí)自己實(shí)現(xiàn)__init__,所以如果你之前看過 Python 代碼,那__init__ 可能看起來就比較熟悉了,本文就不再贅述。
相比之下,大多數(shù)用戶很少明確地實(shí)現(xiàn)__getattr__方法。但它控制著 Python 對(duì)象行為的一個(gè)非常重要的部分。具體來說,當(dāng)用戶試圖通過點(diǎn)語法(如 brain.owner)訪問類屬性,同時(shí)這個(gè)屬性實(shí)際上并不存在時(shí),__getattr__方法將會(huì)被調(diào)用。此方法的默認(rèn)操作僅是引發(fā)一個(gè)錯(cuò)誤:
# Create a new Brain instance
brain = Brain(owner="Sue", age="62", status="hanging out in a jar")
print(brain.owner)
---------------------------------------------------------------------------sue
print(brain.gender)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-136-52813a6b3567> in <module>()
----> 1 print(brain.gender)
<ipython-input-133-afe64c3e086d> in __getattr__(self, attr)
12 if hasattr(self, attr_name):
13 return lambda: getattr(self, attr_name)
---> 14 raise AttributeError
AttributeError:
重要的是,我們不用忍受這種行為。假設(shè)我們想創(chuàng)建一個(gè)替代接口用于通過以「get」開頭的 getter 方法從 Brain 類的內(nèi)部檢索數(shù)據(jù)(這是許多其他語言中的常見做法),我們當(dāng)然可以通過名字(如 get_owner、get_age 等)顯式地實(shí)現(xiàn) getter 方法。但假設(shè)我們很懶,并且不想為每個(gè)屬性編寫一個(gè)顯式的 getter。此外,我們可能想要為已經(jīng)創(chuàng)建的 Brains 類添加新的屬性(如,brain.foo = 4),在這種情況下,我們不需要提前為那些未知屬性創(chuàng)建 getter 方法(請(qǐng)注意,在現(xiàn)實(shí)世界中,這些是為什么我們接下來要這么做的可怕理由;當(dāng)然這里完全是為了舉例說明)。我們可以做的是,當(dāng)用戶請(qǐng)求任意屬性時(shí),通過指示 Brain 類的操作去改變它的行為。
在上面的代碼片段中,我們的 __getattr__ 實(shí)現(xiàn)首先檢查了傳入屬性的名稱。如果名稱以 get_ 開頭,我們將檢查對(duì)象內(nèi)是否存在期望屬性的名稱。如果確實(shí)存在,則返回該對(duì)象。否則,我們會(huì)引發(fā)錯(cuò)誤的默認(rèn)操作。這讓我們可以做一些看似瘋狂的事,比如:
print(brain.get_owner())
其他不可思議的方法允許你動(dòng)態(tài)地控制對(duì)象行為的其他各種方面,而這在其他許多語言中你沒法做到。事實(shí)上,因?yàn)?Python 中的一切都是對(duì)象,甚至數(shù)學(xué)運(yùn)算符實(shí)際上也是對(duì)對(duì)象的秘密方法調(diào)用。例如,當(dāng)你用 Python 編寫表達(dá)式 4 + 5 時(shí),你實(shí)際上是在整數(shù)對(duì)象 4 上調(diào)用 __add__,其參數(shù)為 5。如果我們?cè)敢猓ú⑶椅覀儜?yīng)該小心謹(jǐn)慎地行使這項(xiàng)權(quán)利?。覀兡茏龅氖莿?chuàng)建新的特定領(lǐng)域的「迷你語言」,為通用運(yùn)算符注入全新的語義。
舉個(gè)簡(jiǎn)單的例子,我們來實(shí)現(xiàn)一個(gè)表示單一 Nifti 容積的新類。我們將依靠繼承來實(shí)現(xiàn)大部分工作;只需從 nibabel 包中繼承 NiftierImage 類。我們要做的就是定義 __and__ 和 __or__ 方法,它們分別映射到 & 和 | 運(yùn)算符。看看在執(zhí)行以下幾個(gè)單元前你是否搞懂了這段代碼的作用(可能你需要安裝一些包,如 nibabel 和 nilearn)。
from nibabel import Nifti1Image
from nilearn.image import new_img_like
from nilearn.plotting import plot_stat_map
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
class LazyMask(Nifti1Image):
''' A wrapper for the Nifti1Image class that overloads the & and | operators
to do logical conjunction and disjunction on the image data. '''
def __and__(self, other):
if self.shape != other.shape:
raise ValueError("Mismatch in image dimensions: %s vs. %s" % (self.shape, other.shape))
data = np.logical_and(self.get_data(), other.get_data())
return new_img_like(self, data, self.affine)
def __or__(self, other):
if self.shape != other.shape:
raise ValueError("Mismatch in image dimensions: %s vs. %s" % (self.shape, other.shape))
data = np.logical_or(self.get_data(), other.get_data())
return new_img_like(self, data, self.affine)
img1 = LazyMask.load('image1.nii.gz')
img2 = LazyMask.load('image2.nii.gz')
result = img1 & img2
fig, axes = plt.subplots(3, 1, figsize=(15, 6))
p = plot_stat_map(img1, cut_coords=12, display_mode='z', title='Image 1', axes=axes[0], vmax=3)
plot_stat_map(img2, cut_coords=p.cut_coords, display_mode='z', title='Image 2', axes=axes[1], vmax=3)
p = plot_stat_map(result, cut_coords=p.cut_coords, display_mode='z', title='Result', axes=axes[2], vmax=3)
Python 社區(qū)
我在這里提到的 Python 的最后一個(gè)特征就是它優(yōu)秀的社區(qū)。當(dāng)然,每種主要的編程語言都有一個(gè)大型的社區(qū)致力于該語言的開發(fā)、應(yīng)用和推廣;關(guān)鍵是社區(qū)內(nèi)的人是誰。一般來說,圍繞編程語言的社區(qū)更能反映用戶的興趣和專業(yè)基礎(chǔ)。對(duì)于像 R 和 Matlab 這樣相對(duì)特定領(lǐng)域的語言來說,這意味著為語言貢獻(xiàn)新工具的人中很大一部分不是軟件開發(fā)人員,更可能是統(tǒng)計(jì)學(xué)家、工程師和科學(xué)家等等。當(dāng)然,統(tǒng)計(jì)學(xué)家和工程師沒什么不好。例如,與其他語言相比,統(tǒng)計(jì)學(xué)家較多的 R 生態(tài)系統(tǒng)的優(yōu)勢(shì)之一就是 R 具有一系列統(tǒng)計(jì)軟件包。
然而,由統(tǒng)計(jì)或科學(xué)背景用戶所主導(dǎo)的社區(qū)存在缺點(diǎn),即這些用戶通常未受過軟件開發(fā)方面的訓(xùn)練。因此,他們編寫的代碼質(zhì)量往往比較低(從軟件的角度看)。專業(yè)的軟件工程師普遍采用的最佳實(shí)踐和習(xí)慣在這種未經(jīng)培訓(xùn)的社區(qū)中并不出眾。例如,CRAN 提供的許多 R 包缺少類似自動(dòng)化測(cè)試的東西——除了最小的 Python 軟件包之外,這幾乎是聞所未聞的。另外在風(fēng)格上,R 和 Matlab 程序員編寫的代碼往往在人與人之間的一致性方面要低一些。結(jié)果是,在其他條件相同的情況下,用 Python 編寫軟件往往比用 R 編寫的代碼具備更高的穩(wěn)健性。雖然 Python 的這種優(yōu)勢(shì)無疑與語言本身的內(nèi)在特征無關(guān)(一個(gè)人可以使用任何語言(包括 R、Matlab 等)編寫出極高質(zhì)量的代碼),但仍然存在這樣的情況,強(qiáng)調(diào)共同慣例和最佳實(shí)踐規(guī)范的開發(fā)人員社區(qū)往往會(huì)使大家編寫出更清晰、更規(guī)范、更高質(zhì)量的代碼。