作者:xiaoyu
微信公眾號:Python資料科學
知乎:python資料分析師
前言
當大家談到資料分析時,提及最多的語言就是Python
和SQL
。Python之所以適合資料分析,是因為它有很多第三方強大的庫來協助,pandas
就是其中之一。pandas
的文件中是這樣描述的:
“快速,靈活,富有表現力的資料結構,旨在使”關係“或”標記“資料的使用既簡單又直觀。”
我們知道pandas
的兩個主要資料結構:dataframe
和series
,我們對資料的一些操作都是基於這兩個資料結構的。但在實際的使用中,我們可能很多時候會感覺執行一些資料結構的操作會異常的慢。一個操作慢幾秒可能看不出來什麼,但是一整個專案中很多個操作加起來會讓整個開發工作效率變得很低。有的朋友抱怨pandas簡直太慢了,其實對於pandas的一些操作也是有一定技巧的。
pandas是基於numpy庫
的陣列結構上構建的,並且它的很多操作都是(通過numpy或者pandas自身由Cpython實現並編譯成C的擴充套件模組)在C語言中實現的。因此,如果正確使用pandas的話,它的執行速度應該是非常快的。
本篇將要介紹幾種pandas中常用到的方法,對於這些方法使用存在哪些需要注意的問題,以及如何對它們進行速度提升。
- 將datetime資料與時間序列一起使用的優點
- 進行批量計算的最有效途徑
- 通過HDFStore儲存資料節省時間
使用Datetime資料節省時間
我們來看一個例子。
>>> import pandas as pd
>>> pd.__version__
'0.23.1'
# 匯入資料集
>>> df = pd.read_csv('demand_profile.csv')
>>> df.head()
date_time energy_kwh
0 1/1/13 0:00 0.586
1 1/1/13 1:00 0.580
2 1/1/13 2:00 0.572
3 1/1/13 3:00 0.596
4 1/1/13 4:00 0.592
複製程式碼
從執行上面程式碼得到的結果來看,好像沒有什麼問題。但實際上pandas和numpy都有一個dtypes
的概念。如果沒有特殊宣告,那麼date_time將會使用一個 object
的dtype型別,如下面程式碼所示:
>>> df.dtypes
date_time object
energy_kwh float64
dtype: object
>>> type(df.iat[0, 0])
str
複製程式碼
object
型別像一個大的容器,不僅僅可以承載 str,也可以包含那些不能很好地融進一個資料型別的任何特徵列。而如果我們將日期作為 str 型別就會極大的影響效率。
因此,對於時間序列的資料而言,我們需要讓上面的date_time列格式化為datetime
物件陣列(pandas稱之為時間戳)。pandas在這裡操作非常簡單,操作如下:
>>> df['date_time'] = pd.to_datetime(df['date_time'])
>>> df['date_time'].dtype
datetime64[ns]
複製程式碼
我們來執行一下這個df看看轉化後的效果是什麼樣的。
>>> df.head()
date_time energy_kwh
0 2013-01-01 00:00:00 0.586
1 2013-01-01 01:00:00 0.580
2 2013-01-01 02:00:00 0.572
3 2013-01-01 03:00:00 0.596
4 2013-01-01 04:00:00 0.592
複製程式碼
date_time的格式已經自動轉化了,但這還沒完,在這個基礎上,我們還是可以繼續提高執行速度的。如何提速呢?為了更好的對比,我們首先通過 timeit
裝飾器來測試一下上面程式碼的轉化時間。
>>> @timeit(repeat=3, number=10)
... def convert(df, column_name):
... return pd.to_datetime(df[column_name])
>>> df['date_time'] = convert(df, 'date_time')
Best of 3 trials with 10 function calls per trial:
Function `convert` ran in average of 1.610 seconds.
複製程式碼
1.61s,看上去挺快,但其實可以更快,我們來看一下下面的方法。
>>> @timeit(repeat=3, number=100)
>>> def convert_with_format(df, column_name):
... return pd.to_datetime(df[column_name],
... format='%d/%m/%y %H:%M')
Best of 3 trials with 100 function calls per trial:
Function `convert_with_format` ran in average of 0.032 seconds.
複製程式碼
**結果只有0.032s,快了將近50倍。**原因是:我們設定了轉化的格式format。由於在CSV中的datetimes並不是 ISO 8601 格式的,如果不進行設定的話,那麼pandas將使用 dateutil
包把每個字串str轉化成date日期。
相反,如果原始資料datetime已經是 ISO 8601
格式了,那麼pandas就可以立即使用最快速的方法來解析日期。這也就是為什麼提前設定好格式format可以提升這麼多。
pandas資料的迴圈操作
仍然基於上面的資料,我們想新增一個新的特徵,但這個新的特徵是基於一些時間條件的,根據時長(小時)而變化,如下:
因此,按照我們正常的做法就是使用apply方法寫一個函式,函式裡面寫好時間條件的邏輯程式碼。
def apply_tariff(kwh, hour):
"""計算每個小時的電費"""
if 0 <= hour < 7:
rate = 12
elif 7 <= hour < 17:
rate = 20
elif 17 <= hour < 24:
rate = 28
else:
raise ValueError(f'Invalid hour: {hour}')
return rate * kwh
複製程式碼
然後使用for迴圈來遍歷df,根據apply函式邏輯新增新的特徵,如下:
>>> # 不贊同這種操作
>>> @timeit(repeat=3, number=100)
... def apply_tariff_loop(df):
... """Calculate costs in loop. Modifies `df` inplace."""
... energy_cost_list = []
... for i in range(len(df)):
... # 獲取用電量和時間(小時)
... energy_used = df.iloc[i]['energy_kwh']
... hour = df.iloc[i]['date_time'].hour
... energy_cost = apply_tariff(energy_used, hour)
... energy_cost_list.append(energy_cost)
... df['cost_cents'] = energy_cost_list
...
>>> apply_tariff_loop(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_loop` ran in average of 3.152 seconds.
複製程式碼
對於那些寫Pythonic風格的人來說,這個設計看起來很自然。然而,這個迴圈將會嚴重影響效率,也是不贊同這麼做。原因有幾個:
- 首先,它需要初始化一個將記錄輸出的列表。
- 其次,它使用不透明物件範圍
(0,len(df))
迴圈,然後在應用apply_tariff()
之後,它必須將結果附加到用於建立新DataFrame列的列表中。它還使用df.iloc [i] ['date_time']
執行所謂的鏈式索引,這通常會導致意外的結果。 - 但這種方法的最大問題是計算的時間成本。對於8760行資料,此迴圈花費了3秒鐘。接下來,你將看到一些改進的Pandas結構迭代解決方案。
使用itertuples() 和iterrows() 迴圈
那麼推薦做法是什麼樣的呢?
實際上可以通過pandas引入itertuples和iterrows方法可以使效率更快。這些都是一次產生一行的生成器方法,類似scrapy中使用的yield用法。
.itertuples
為每一行產生一個namedtuple
,並且行的索引值作為元組的第一個元素。nametuple是Python的collections模組中的一種資料結構,其行為類似於Python元組,但具有可通過屬性查詢訪問的欄位。
.iterrows
為DataFrame中的每一行產生(index,series)
這樣的元組。
雖然.itertuples往往會更快一些,但是在這個例子中使用.iterrows,我們看看這使用iterrows後效果如何。
>>> @timeit(repeat=3, number=100)
... def apply_tariff_iterrows(df):
... energy_cost_list = []
... for index, row in df.iterrows():
... # 獲取用電量和時間(小時)
... energy_used = row['energy_kwh']
... hour = row['date_time'].hour
... # 新增cost列表
... energy_cost = apply_tariff(energy_used, hour)
... energy_cost_list.append(energy_cost)
... df['cost_cents'] = energy_cost_list
...
>>> apply_tariff_iterrows(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_iterrows` ran in average of 0.713 seconds.
複製程式碼
語法方面:這樣的語法更明確,並且行值引用中的混亂更少,因此它更具可讀性。
在時間收益方面:快了近5倍! 但是,還有更多的改進空間。我們仍然在使用某種形式的Python for迴圈,這意味著每個函式呼叫都是在Python中完成的,理想情況是它可以用Pandas內部架構中內建的更快的語言完成。
Pandas的 .apply()方法
我們可以使用.apply
方法而不是.iterrows進一步改進此操作。Pandas的.apply方法接受函式(callables)並沿DataFrame的軸(所有行或所有列)應用它們。在此示例中,lambda函式將幫助你將兩列資料傳遞給apply_tariff()
:
>>> @timeit(repeat=3, number=100)
... def apply_tariff_withapply(df):
... df['cost_cents'] = df.apply(
... lambda row: apply_tariff(
... kwh=row['energy_kwh'],
... hour=row['date_time'].hour),
... axis=1)
...
>>> apply_tariff_withapply(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_withapply` ran in average of 0.272 seconds.
複製程式碼
.apply
的語法優點很明顯,行數少,程式碼可讀性高。在這種情況下,所花費的時間大約是.iterrows
方法的一半。
但是,這還不是“非常快”。一個原因是.apply()將在內部嘗試迴圈遍歷Cython迭代器。但是在這種情況下,傳遞的lambda不是可以在Cython中處理的東西,因此它在Python中呼叫,因此並不是那麼快。
如果你使用.apply()獲取10年的小時資料,那麼你將需要大約15分鐘的處理時間。如果這個計算只是大型模型的一小部分,那麼你真的應該加快速度。這也就是向量化操作派上用場的地方。
向量化操作:使用.isin()選擇資料
什麼是向量化操作?如果你不基於一些條件,而是可以在一行程式碼中將所有電力消耗資料應用於該價格(df ['energy_kwh'] * 28)
,類似這種。這個特定的操作就是向量化操作的一個例子,它是在Pandas中執行的最快方法。
但是如何將條件計算應用為Pandas中的向量化運算?一個技巧是根據你的條件選擇和分組DataFrame,然後對每個選定的組應用向量化操作。 在下一個示例中,你將看到如何使用Pandas的.isin()方法選擇行,然後在向量化操作中實現上面新特徵的新增。在執行此操作之前,如果將date_time列設定為DataFrame的索引,則會使事情更方便:
df.set_index('date_time', inplace=True)
@timeit(repeat=3, number=100)
def apply_tariff_isin(df):
# 定義小時範圍Boolean陣列
peak_hours = df.index.hour.isin(range(17, 24))
shoulder_hours = df.index.hour.isin(range(7, 17))
off_peak_hours = df.index.hour.isin(range(0, 7))
# 使用上面的定義
df.loc[peak_hours, 'cost_cents'] = df.loc[peak_hours, 'energy_kwh'] * 28
df.loc[shoulder_hours,'cost_cents'] = df.loc[shoulder_hours, 'energy_kwh'] * 20
df.loc[off_peak_hours,'cost_cents'] = df.loc[off_peak_hours, 'energy_kwh'] * 12
複製程式碼
我們來看一下結果如何。
>>> apply_tariff_isin(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_isin` ran in average of 0.010 seconds.
複製程式碼
為了瞭解剛才程式碼中發生的情況,我們需要知道.isin()方法返回的是一個布林值陣列,如下所示:
[False, False, False, ..., True, True, True]
複製程式碼
這些值標識哪些DataFrame索引(datetimes)
落在指定的小時範圍內。然後,當你將這些布林陣列傳遞給DataFrame的.loc索引器時,你將獲得一個僅包含與這些小時匹配的行的DataFrame切片。在那之後,僅僅是將切片乘以適當的費率,這是一種快速的向量化操作。
這與我們上面的迴圈操作相比如何?首先,你可能會注意到不再需要apply_tariff()
,因為所有條件邏輯都應用於行的選擇。因此,你必須編寫的程式碼行和呼叫的Python程式碼會大大減少。
處理時間怎麼樣?比不是Pythonic的迴圈快315倍,比.iterrows快71倍,比.apply快27倍。
還可以做的更好嗎?
在apply_tariff_isin中,我們仍然可以通過呼叫df.loc
和df.index.hour.isin
三次來進行一些“手動工作”。如果我們有更精細的時隙範圍,你可能會爭辯說這個解決方案是不可擴充套件的。幸運的是,在這種情況下,你可以使用Pandas的pd.cut()
函式以程式設計方式執行更多操作:
@timeit(repeat=3, number=100)
def apply_tariff_cut(df):
cents_per_kwh = pd.cut(x=df.index.hour,
bins=[0, 7, 17, 24],
include_lowest=True,
labels=[12, 20, 28]).astype(int)
df['cost_cents'] = cents_per_kwh * df['energy_kwh']
複製程式碼
讓我們看看這裡發生了什麼。pd.cut()
根據每小時所屬的bin應用一組標籤(costs)。
注意include_lowest參數列示第一個間隔是否應該是包含左邊的(您希望在組中包含時間= 0)。 這是一種完全向量化的方式來獲得我們的預期結果,它在時間方面是最快的:
>>> apply_tariff_cut(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_cut` ran in average of 0.003 seconds.
複製程式碼
到目前為止,時間上基本快達到極限了,只需要花費不到一秒的時間來處理完整的10年的小時資料集。但是,最後一個選項是使用 NumPy
函式來操作每個DataFrame的底層NumPy陣列,然後將結果整合回Pandas資料結構中。
使用Numpy繼續加速
使用Pandas時不應忘記的一點是Pandas Series
和DataFrames
是在NumPy庫之上設計的。這為你提供了更多的計算靈活性,因為Pandas可以與NumPy陣列和操作無縫銜接。
下面,我們將使用NumPy的 digitize()
函式。它類似於Pandas的cut(),因為資料將被分箱,但這次它將由一個索引陣列表示,這些索引表示每小時所屬的bin。然後將這些索引應用於價格陣列:
@timeit(repeat=3, number=100)
def apply_tariff_digitize(df):
prices = np.array([12, 20, 28])
bins = np.digitize(df.index.hour.values, bins=[7, 17, 24])
df['cost_cents'] = prices[bins] * df['energy_kwh'].values
複製程式碼
與cut函式一樣,這種語法非常簡潔易讀。但它在速度方面有何比較?讓我們來看看:
>>> apply_tariff_digitize(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_digitize` ran in average of 0.002 seconds.
複製程式碼
在這一點上,仍然有效能提升,但它本質上變得更加邊緣化。使用Pandas,它可以幫助維持“層次結構”,如果你願意,可以像在此處一樣進行批量計算,這些通常排名從最快到最慢(最靈活到最不靈活):
-
使用向量化操作:沒有for迴圈的Pandas方法和函式。
-
將.apply方法:與可呼叫方法一起使用。
-
使用.itertuples:從Python的集合模組迭代DataFrame行作為namedTuples。
-
使用.iterrows:迭代DataFrame行作為(index,Series)對。雖然Pandas系列是一種靈活的資料結構,但將每一行構建到一個系列中然後訪問它可能會很昂貴。
-
使用“element-by-element”迴圈:使用df.loc或df.iloc一次更新一個單元格或行。
使用HDFStore防止重新處理
現在你已經瞭解了Pandas中的加速資料流程,接著讓我們探討如何避免與最近整合到Pandas中的HDFStore
一起重新處理時間。
通常,在構建複雜資料模型時,可以方便地對資料進行一些預處理。例如,如果您有10年的分鐘頻率耗電量資料,即使你指定格式引數,只需將日期和時間轉換為日期時間可能需要20分鐘。你真的只想做一次,而不是每次執行你的模型,進行測試或分析。
你可以在此處執行的一項非常有用的操作是預處理,然後將資料儲存在已處理的表單中,以便在需要時使用。但是,如何以正確的格式儲存資料而無需再次重新處理?如果你要另存為CSV,則只會丟失datetimes物件,並且在再次訪問時必須重新處理它。
Pandas有一個內建的解決方案,它使用 HDF5
,這是一種專門用於儲存表格資料陣列的高效能儲存格式。 Pandas的 HDFStore
類允許你將DataFrame儲存在HDF5檔案中,以便可以有效地訪問它,同時仍保留列型別和其他後設資料。它是一個類似字典的類,因此您可以像讀取Python dict物件一樣進行讀寫。
以下是將預處理電力消耗DataFrame df儲存在HDF5檔案中的方法:
# 建立儲存物件,並存為 processed_data
data_store = pd.HDFStore('processed_data.h5')
# 將 DataFrame 放進物件中,並設定 key 為 preprocessed_df
data_store['preprocessed_df'] = df
data_store.close()
複製程式碼
現在,你可以關閉計算機並休息一下。等你回來的時候,你處理的資料將在你需要時為你所用,而無需再次加工。以下是如何從HDF5檔案訪問資料,並保留資料型別:
# 獲取資料儲存物件
data_store = pd.HDFStore('processed_data.h5')
# 通過key獲取資料
preprocessed_df = data_store['preprocessed_df']
data_store.close()
複製程式碼
資料儲存可以容納多個表,每個表的名稱作為鍵。
關於在Pandas中使用HDFStore的注意事項:您需要安裝PyTables> = 3.0.0
,因此在安裝Pandas之後,請確保更新PyTables
,如下所示:
pip install --upgrade tables
複製程式碼
結論
如果你覺得你的Pandas專案不夠快速,靈活,簡單和直觀,請考慮重新考慮你使用該庫的方式。
這裡探討的示例相當簡單,但說明了Pandas功能的正確應用如何能夠大大改進執行時和速度的程式碼可讀性。以下是一些經驗,可以在下次使用Pandas中的大型資料集時應用這些經驗法則:
- 嘗試儘可能使用向量化操作,而不是在df 中解決for x的問題。如果你的程式碼是許多for迴圈,那麼它可能更適合使用本機Python資料結構,因為Pandas會帶來很多開銷。
- 如果你有更復雜的操作,其中向量化根本不可能或太難以有效地解決,請使用.apply方法。
- 如果必須迴圈遍歷陣列(確實發生了這種情況),請使用.iterrows()或.itertuples()來提高速度和語法。
- Pandas有很多可選性,幾乎總有幾種方法可以從A到B。請注意這一點,比較不同方法的執行方式,並選擇在專案環境中效果最佳的路線。
- 一旦建立了資料清理指令碼,就可以通過使用HDFStore儲存中間結果來避免重新處理。
- 將NumPy整合到Pandas操作中通常可以提高速度並簡化語法。
如果覺得有幫助,還請給點個贊!
歡迎關注我的個人公眾號:Python資料科學