Pandas 初學者代碼優化指南

Pandas 初學者代碼優化指南

摘要:Pandas 是Python Data Analysis Library的簡寫,它是為了解決數據分析任務而創建的工具,本文介紹了五種由慢到快逐步優化其效率的方法 ,以下是譯文。

如果你用Python語言做過任何的數據分析,那麼可能會用到Pandas,一個由Wes McKinney寫的奇妙的分析庫。通過賦予Python數據幀以分析功能,Pandas已經有效地把Python和一些諸如R或者SAS這樣比較成熟的分析工具置於相同的地位。

不幸的是,在早期,Pandas因“慢”而聲名狼藉。的確,Pandas代碼不可能達到如完全優化的原始C語言代碼的計算速度。然而,好消息是,對於大多數應用程序來說,寫的好的Pandas代碼已足夠快;Pandas強大的功能和友好的用戶體驗彌補了其速度的缺點。

在這篇文章中,我們將回顧應用於Pandas DataFrame函數的幾種方法的效率,從最慢到最快:

1. 在用索引的DataFrame行上的Crude looping

2. 用iterrows()循環

3. 用 apply()循環

4. Pandas Series矢量化

5. NumPy數組矢量化

對於我們的實例函數,將使用Haversine(半正矢)距離公式。函數取兩點的經緯度,調整球面的曲率,計算它們之間的直線距離。這個函數看起來像這樣:

import numpy as np

# Define a basic Haversine distance formula

def haversine(lat1, lon1, lat2, lon2):

MILES = 3959

lat1, lon1, lat2, lon2 = map(np.deg2rad, [lat1, lon1, lat2, lon2])

dlat = lat2 - lat1

dlon = lon2 - lon1

a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2

c = 2 * np.arcsin(np.sqrt(a))

total_miles = MILES * c

return total_miles

為了在真實數據上測試函數,我們用一個包含紐約所有酒店座標的數據集,該數據來自Expedia開發者網站。要計算每一個酒店和一個樣本集座標之間的距離(這恰好屬於在紐約市名為布魯克林超級英雄供應店的一個夢幻般的小商店)

大家可以下載數據集,Jupyter notebook(是一個交互式筆記本,支持運行 40 多種編程語言)包含了用於這篇博客的函數,請點擊這裡(https://github.com/sversh/pycon2017-optimizing-pandas)下載。

這篇文章基於我的PyCon訪談,大家可以在這裡(https://www.youtube.com/watch?v=HN5d490_KKk)觀看。

Pandas中的Crude looping,或者你永遠不應該這麼做

首先,讓我們快速回顧一下Pandas數據結構的基本原理。Pandas的基本結構有兩種形式:DataFrame和Series。一個DataFrame是一個二維數組標記軸,很多功能與R中的data.frame類似,可以將DataFrame理解為Series的容器。換句話說,一個DataFrame是一個有行和列的矩陣,列有列名標籤,行有索引標籤。在Pandas DataFrame中一個單獨的列或者行是一個Pandas Series—一個帶有軸標籤的一維數組。

幾乎每一個與我合作過的Pandas初學者,都曾經試圖通過一次一個的遍歷DataFrame行去應用自定義函數。這種方法的優點是,它是Python對象之間交互的一致方式;例如,一種可以通過列表或數組循環的方式。反過來說,不利的一面是,在Pandas中,Crude loop是最慢的方法。與下面將要討論的方法不同,Pandas中的Crude loop沒有利用任何內置優化,通過比較,其效率極低(而且代碼通常不那麼具有可讀性)

例如,有人可能會寫像下面這樣的代碼:

# Define a function to manually loop over all rows and return a series of distances

def haversine_looping(df):

distance_list = []

for i in range(0, len(df)):

d = haversine(40.671, -73.985, df.iloc[i]['latitude'], df.iloc[i]['longitude'])

distance_list.append(d)

return distance_list

為了瞭解執行上述函數所需要的時間,我們用%timeit命令。%timeit是一個“神奇的”命令,專用於Jupyter notebook(所有的魔法命令都以%標識開始,如果%命令只應用於一行,那麼%%命令應用於整個Jupyter單元)。%timeit命令將多次運行一個函數,並打印出獲得的運行時間的平均值和標準差。當然,通過%timeit命令獲得的運行時間,運行該函數的每個系統都不盡相同。儘管如此,它可以提供一個有用的基準測試工具,用於比較同一系統和數據集上不同函數的運行時間。

%%timeit

# Run the haversine looping function

df['distance'] = haversine_looping(df)

結果是:

645 ms ± 31 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

通過分析,crude looping函數運行了大約645ms,標準差是31ms。這似乎很快,但考慮到它僅需要處理大約1600行的代碼,因此它實際上是很慢的。接下來看看如何改善這種不好的狀況。

用iterrows()循環

如果循環是必須的,找一個更好的方式去遍歷行,比如用iterrows()方法。iterrows()是一個生成器,遍歷DataFrame的所有行並返回每一行的索引,除了包含行自身的對象。iterrows() 是用Pandas DataFrame優化,儘管它是運行大多數標準函數最不高效的方式(稍後再談),但相對於Crude looping,這是一個重大的改進。在我們的案例中,iterrows()解決同一個問題,幾乎比手動遍歷行快四倍。

%%timeit

# Haversine applied on rows via iteration

haversine_series = []

for index, row in df.iterrows():

haversine_series.append(haversine(40.671, -73.985, row['latitude'], row['longitude']))

df['distance'] = haversine_series

166 ms ± 2.42 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

使用apply()方法實現更好的循環

一個比iterrows()更好的選擇是用 apply() 方法,它應用一個函數,沿著DataFrame某一個特定的軸線(意思就是行或列)。雖然apply()也固有的通過行循環,但它通過採取一些內部優化比iterrows()更高效,例如在Cython中使用迭代器。我們使用一個匿名的lambda函數,每一行都用Haversine函數,它允許指向每一行中的特定單元格作為函數的輸入。為了指定Pandas是否應該將函數應用於行(axis = 1)或列(axis = 0),Lambda函數包含最終的axis參數。

%%timeit

# Timing apply on the Haversine function

df['distance'] = df.apply(lambda row: haversine(40.671, -73.985, row['latitude'], row['longitude']), axis=1)

90.6 ms ± 7.55 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

iterrows()方法用apply()方法替代後,大致可以將函數的運行時間減半。為了更深入地瞭解函數中的實際運行時間,可以運行一個在線分析器工具(Jupyter中神奇的命令%lprun)

# Haversine applied on rows with line profiler

%lprun -f haversine df.apply(lambda row: haversine(40.671, -73.985, row['latitude'], row['longitude']),axis=1)

結果如下:

Pandas 初學者代碼優化指南

我們可以從這個信息中得到一些有用的見解。例如,進行三角計算的函數佔了總運行時間的近一半。因此,如果想優化函數的各個組件,可以從這裡入手。現在,特別值得注意的是每一行都被循環了1631次—apply()遍歷每一行的結果。如果可以減少重複的工作量,就可以降低整個運行時間。矢量化提供了一種更有效的替代方案。

Pandas Series矢量化

要了解如何可以減少函數所執行的迭代數量,就要記得Pandas的基本單位,DataFrame和Series,它們都基於數組。基本單元的固有結構轉換成內置的設計用於對整個數組進行操作的Pandas函數,而不是按各個值的順序(簡稱標量)。矢量化是對整個數組執行操作的過程。

Pandas包含一個總體的矢量化函數集合,從數學運算到聚合和字符串函數(可用函數的擴展列表,查看Pandas docs)。對Pandas Series和DataFrame的操作進行內置優化。結果,使用矢量Pandas函數幾乎總是會用自定義的循環實現類似的功能。

到目前為止,我們僅傳遞標量給Haversine函數。所有的函數都應用在Haversine函數中,也可以在數組上操作。這使得距離矢量化函數的過程非常的簡單:不是傳遞個別標量值的緯度和經度給它,而是把它傳遞給整個series(列)。這使得Pandas受益於可用於矢量函數的全套優化,特別是包括同時執行整個數組的所有計算。

%%timeit

# Vectorized implementation of Haversine applied on Pandas series

df['distance'] = haversine(40.671, -73.985,df['latitude'], df['longitude'])

1.62 ms ± 41.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

通過使用apply()方法,要比用iterrows()方法改進50倍的效率,通過矢量化函數則改進了iterrows()方法100倍—除了改變輸入類型,什麼都不要做!

看一眼後臺,看看函數到底在做什麼:

Pandas 初學者代碼優化指南

注意,鑑於 apply() 執行函數1631次,矢量化版本僅執行一次,因為它同時應用於整個數組,這就是主要的時間節省來源。

用NumPy數組矢量化

Pandas series矢量化可以完成日常計算優化的絕大多數需要。然而,如果速度是最高優先級,那麼可以以NumPy Python庫的形式調用援軍。

NumPy庫,將自己描述為一個“Python科學計算的基本包”,在後臺執行優化操作,預編譯C語言代碼。跟Pandas一樣,NumPy操作數組對象(簡稱ndarrays);然而,它省去了Pandas series操作所帶來的大量資源開銷,如索引、數據類型檢查等。因此,NumPy數組的操作可以明顯快於pandas series的操作。

當Pandas series提供的額外功能不是很關鍵的時候,NumPy數組可以用於替代Pandas series。例如,Haversine函數矢量化實現不使用索引的經度和緯度系列,因此沒有那些索引,也不會導致函數中斷。通過比較,我們所做的操作如DataFrame的連接,它需要按索引來引用值,可能需要堅持使用Pandas對象。

僅僅是使用Pandas series 的values的方法,把緯度和經度數組從Pandas series轉換到NumPy數組。就像series矢量化一樣,通過NumPy數組直接進入函數將可以讓Pandas對整個矢量應用函數。

%%timeit

# Vectorized implementation of Haversine applied on NumPy arrays

df['distance'] = haversine(40.671, -73.985, df['latitude'].values, df['longitude'].values)

370 µs ± 18 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

NumPy數組操作運行取得了又一個四倍的改善。總之,通過looping改進了運行時間超過半秒,通過NumPy矢量化,運行時間改進到了三分之一毫秒級!

總結

下面的表格總結了相關結果。用NumPy數組矢量化將會帶來最快的運行時間,相對於Pandas series矢量化的效果而言,這是一個很小的改進,但對比最快的looping版本,NumPy數組矢量化帶來了56倍的改進。

Pandas 初學者代碼優化指南

這給我們帶來了一些關於優化Pandas代碼的基本結論:

  1. 避免循環;它們很慢,而且在大多數情況下是不必要的。
  2. 如果必須使用循環,用 apply(),而不是迭代函數。
  3. 矢量化通常優於標量運算。在Pandas中的大部分常見操作都可以矢量化。
  4. NumPy數組矢量化操作比Pandas series更有效。

當然,以上並不是Pandas所有可能優化的全面清單。更愛冒險的用戶或許可以考慮進一步用Cython改寫函數,或者嘗試優化函數的各個組件。然而,這些話題超出了這篇文章的範圍。

關鍵的是,在開始一次宏大的優化冒險之前,要確保正在優化的函數實際上是你希望在長期運行中使用的函數。引用XKCD不朽的名言:“過早優化是萬惡之源”。


分享到:


相關文章: