在資料科學領域,pandas 是不可或缺的工具,但效能問題常常困擾開發者。本文將介紹一些 pandas 的效能最佳化技巧,幫助你寫出更有效率的程式碼。首先,避免使用 dtype=object 儲存字串,尤其在 pandas 1.0 後,pd.StringDtype 提供更好的效能和型別檢查。其次,善用 PyArrow 的 date32 型別處理日期資料,避免使用 datetime.date 物件造成效能損耗。選擇合適的整數型別,例如 Int8、Int16 等,可以有效減少記憶體使用量。最後,利用 pandas 的向量化運算取代 Python 迴圈,充分發揮 pandas 的效能優勢,例如使用 .sum()、.mean() 等內建函式。
一般使用與效能提示
在學習了pandas函式庫的相當大部分內容,並透過範例應用來加強正確使用方法後,您現在已準備好踏入現實世界,並開始將所學的一切應用於您的資料分析問題。
本章將提供一些您在獨立作業時應牢記的技巧和訣竅。本章介紹的秘訣是所有經驗層級的pandas使用者都會犯的常見錯誤。雖然出發點是好的,但不當使用pandas結構可能會浪費很多效能。當您的資料集較小時,這可能不是什麼大問題,但資料往往會增長,而不是縮減。使用正確的慣用語並避免維護低效程式碼所帶來的負擔,可以為您的組織節省大量的時間和金錢。
避免使用dtype=object
使用dtype=object來儲存字串是pandas中最容易出錯和低效的做法之一。不幸的是,在很長一段時間裡,dtype=object是處理字串資料的唯一方法;直到1.0版本才得到「解決」。
如何做到
讓我們建立兩個具有相同資料的pd.Series物件,一個使用object資料型別,另一個使用pd.StringDtype:
ser_obj = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=object)
ser_str = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=pd.StringDtype())
嘗試將非字串值賦給ser_str將會失敗:
ser_str.iloc[0] = False
# TypeError: Cannot set non-string value 'False' into a StringArray.
相比之下,使用object型別的pd.Series則會欣然接受我們的布林值:
ser_obj.iloc[0] = False
這最終會導致資料問題的隱藏。使用pd.StringDtype時,當我們嘗試賦予非字串資料時,失敗的原因非常明顯。使用object資料型別時,您可能直到稍後在程式碼中嘗試進行一些字串操作(如大寫轉換)時才會發現問題:
ser_obj.str.capitalize().head()
# 0 NaN
# 1 Bar
# 2 Baz
# 3 Foo
# 4 Bar
# dtype: object
pandas只是決定將第一行的False條目設定為缺失值。很可能默默地將值設定為缺失值並不是您想要的行為,但使用object資料型別,您會失去對資料品質的控制。
如果您正在使用pandas 3.0及更高版本,您還會發現,當安裝了PyArrow時,pd.StringDtype會變得明顯更快。讓我們重新建立我們的pd.Series物件來衡量這一點:
ser_obj = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=object)
ser_str = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=pd.StringDtype())
為了快速比較執行時間,讓我們使用標準函式庫中的timeit模組:
import timeit
timeit.timeit(ser_obj.str.upper, number=1000)
# 2.2286621460007154
將該執行時間與使用正確的pd.StringDtype時的執行時間進行比較:
timeit.timeit(ser_str.str.upper, number=1000)
# 2.7227514309997787(注意:此範例中,實際上 object dtype 更快,但在 pandas 3.0 之後版本通常 StringDtype 更快)
不幸的是,在3.0版本之前的使用者不會看到任何效能差異,但僅僅是資料驗證就值得讓您從dtype=object轉移。
那麼,避免使用dtype=object的最簡單方法是什麼?如果您幸運地正在使用pandas 3.0及更高版本,您自然不會經常遇到這種資料型別,這是函式庫的自然演進。即使如此,對於仍在使用pandas 2.x系列的使用者,我建議在I/O方法中使用dtype_backend="numpy_nullable"引數:
import io
data = io.StringIO("int_col,string_col\n0,foo\n1,bar\n2,baz")
data.seek(0)
pd.read_csv(data, dtype_backend="numpy_nullable").dtypes
# int_col Int64
# string_col string[python]
# dtype: object
如果您手動建立一個pd.DataFrame,您可以使用pd.DataFrame.convert_dtypes並搭配相同的dtype_backend="numpy_nullable"引數:
df = pd.DataFrame([
[0, "foo"],
[1, "bar"],
[2, "baz"],
])
df = df.convert_dtypes(dtype_backend="numpy_nullable")
程式碼解析:
上述程式碼展示瞭如何避免使用 dtype=object 以及如何利用 pd.StringDtype() 來提升 pandas 處理字串的效率與資料驗證能力。
建立範例 Series:分別建立了使用
object與pd.StringDtype()的兩個pd.Series物件,比較其行為差異。ser_obj = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=object) ser_str = pd.Series(["foo", "bar", "baz"] * 10_000, dtype=pd.StringDtype())- 解說:這裡建立了兩個包含相同資料的 Series,但使用了不同的資料型別。接著比較了賦予非字串值的行為差異。
嘗試賦予非字串值:
ser_str.iloc[0] = False # 會引發 TypeError,因為 ser_str 是使用 StringDtype,不允許非字串值。 ser_obj.iloc[0] = False # 可以成功執行,因為 ser_obj 使用的是 object dtype。- 解說:展示了
pd.StringDtype()的嚴格型別檢查,而object型別則允許任意型別的值,可能導致潛在錯誤。
- 解說:展示了
進行字串操作:
ser_obj.str.capitalize().head() # 將非字串值(False)轉為 NaN。- 解說:當對混合型別的
objectSeries 進行字串操作時,非字串值會被默默轉為NaN,這可能導致難以察覺的錯誤。
- 解說:當對混合型別的
效能比較:
timeit.timeit(ser_obj.str.upper, number=1000) timeit.timeit(ser_str.str.upper, number=1000)- 解說:透過
timeit模組比較了兩種不同型別 Series 在執行字串操作的效能差異。在 pandas 3.0 之後版本,通常StringDtype()更高效。
- 解說:透過
讀取 CSV 時指定型別:
pd.read_csv(data, dtype_backend="numpy_nullable").dtypes- 解說:在讀取 CSV 時,使用
dtype_backend="numpy_nullable"引數,可以自動將適當欄位轉換為更合適的型別(如string[python])。
- 解說:在讀取 CSV 時,使用
手動轉換 DataFrame 型別:
df.convert_dtypes(dtype_backend="numpy_nullable")- 解說:展示如何手動將 DataFrame 的型別轉換為更合適的型別,以避免使用
object型別。
- 解說:展示如何手動將 DataFrame 的型別轉換為更合適的型別,以避免使用
圖表說明:
此處沒有 Plantuml 圖表,但可以考慮加入流程圖來說明不同資料型別之間的轉換過程,或是用於比較不同 pandas 版本之間的行為差異。
一般使用與效能提示
在處理資料時,選擇適當的資料型別對於效能最佳化至關重要。pandas 提供了多種資料型別,幫助開發者更有效地使用記憶體和提升運算效能。
認識 dtype=object 的使用與限制
dtype=object 是一種常見的資料型別,用於儲存多種不同型別的資料。然而,這種型別也帶來了一些問題。
使用 dtype=object 的缺點
- 當使用
dtype=object儲存字串或日期時,pandas 無法充分利用其內建的最佳化機制。 - 使用
datetime.date物件儲存日期時,資料型別被視為object,導致無法使用.dt存取器。
import datetime
ser = pd.Series([
datetime.date(2024, 1, 1),
datetime.date(2024, 1, 2),
datetime.date(2024, 1, 3),
])
print(ser)
輸出結果:
0 2024-01-01
1 2024-01-02
2 2024-01-03
dtype: object
當嘗試使用 .dt 存取器時,會出現錯誤:
ser.dt.year
錯誤訊息:
AttributeError: Can only use .dt accessor with datetimelike values
解決方案:使用 PyArrow 的 date32 型別
可以使用 PyArrow 的 date32 型別來儲存日期,這樣就可以使用 .dt 存取器。
import pyarrow as pa
ser = pd.Series([
datetime.date(2024, 1, 1),
datetime.date(2024, 1, 2),
datetime.date(2024, 1, 3),
], dtype=pd.ArrowDtype(pa.date32()))
print(ser)
輸出結果:
0 2024-01-01
1 2024-01-02
2 2024-01-03
dtype: date32[day][pyarrow]
現在可以順利使用 .dt 存取器:
print(ser.dt.year)
輸出結果:
0 2024
1 2024
2 2024
dtype: int64[pyarrow]
注意資料大小
當資料集變大時,選擇適當的資料型別對於記憶體使用至關重要。
使用適當的整數型別
pandas 提供了多種整數型別,可以根據資料範圍選擇適當的型別。
df = pd.DataFrame({
"a": [0] * 100_000,
"b": [2 ** 8] * 100_000,
"c": [2 ** 16] * 100_000,
"d": [2 ** 32] * 100_000,
})
df = df.convert_dtypes(dtype_backend="numpy_nullable")
print(df.memory_usage())
輸出結果:
Index 128
a 900000
b 900000
c 900000
d 900000
dtype: int64
使用 .astype 方法指定資料型別
可以根據資料範圍,使用 .astype 方法指定適當的整數型別。
df = df.assign(
a=lambda x: x["a"].astype(pd.Int8Dtype()),
b=lambda x: x["b"].astype(pd.Int16Dtype()),
c=lambda x: x["c"].astype(pd.Int32Dtype()),
)
print(df.memory_usage())
輸出結果:
Index 128
a 200000
b 300000
c 500000
d 900000
dtype: int64
使用 pd.to_numeric 方法最佳化資料型別
pandas 提供了 pd.to_numeric 方法,可以自動最佳化數值資料型別。
df = df.select_dtypes("number").assign(
**{x: pd.to_numeric(y, downcast="signed", dtype_backend="numpy_nullable") for x, y in df.items()}
)
print(df.memory_usage())
輸出結果:
Index 128
a 200000
b 300000
c 500000
d 900000
dtype: int64
使用向量化函式取代迴圈
pandas 提供向量化運算,可以大幅提升運算效能。
向量化運算範例
ser = pd.Series(range(100_000), dtype=pd.Int64Dtype())
result = ser.sum()
print(result)
輸出結果:
4999950000
為什麼要避免使用迴圈?
在 pandas 中,使用迴圈會導致效能下降。向量化運算可以讓 pandas 更有效地利用底層實作,從而提升運算速度。
如何實作向量化運算?
pandas 提供多種向量化運算方法,例如 .sum()、.mean() 等。可以根據需求選擇適當的方法。
pandas 的一般使用與效能最佳化技巧
在進行資料分析時,pandas 是一個非常強大的工具。然而,要充分發揮其效能,瞭解其內部運作和最佳實踐至關重要。本篇文章將探討幾個重要的使用技巧和效能最佳化方法。
避免使用 Python 迴圈
直接對 pd.Series 物件進行迴圈操作可能導致效能問題。比較以下兩個範例:
import pandas as pd
# 建立一個範例 Series
ser = pd.Series(range(100000))
# 方法 1:使用 pandas 的內建函式
result = ser.sum()
print(result)
# 方法 2:使用 Python 迴圈
result = 0
for x in ser:
result += x
print(result)
內容解密:
- 第一個範例使用了
pd.Series.sum(),這是一個向量化操作,由底層的 C 語言實作,因此執行效率較高。 - 第二個範例則使用了 Python 迴圈,這會受到 Python 執行時的限制,執行速度較慢。
使用 timeit 模組進行簡單的效能測試:
import timeit
# 定義測試函式
def pandas_sum():
return ser.sum()
def loop_sum():
result = 0
for x in ser:
result += x
return result
# 執行效能測試
pandas_time = timeit.timeit(pandas_sum, number=1000)
loop_time = timeit.timeit(loop_sum, number=1000)
print(f"pandas sum 時間:{pandas_time}")
print(f"迴圈 sum 時間:{loop_time}")
內容解密:
timeit.timeit()用於測量小段程式碼的執行時間。number=1000表示重複執行測試 1000 次以獲得平均執行時間。
盡量減少資料變異
在某些情況下,直接修改資料可能導致效能問題。比較以下兩個範例:
import pandas as pd
# 方法 1:先變異再建立 Series
def mutate_before():
data = ["foo", "bar", "baz"]
data[1] = "BAR"
ser = pd.Series(data, dtype=pd.StringDtype())
return ser
# 方法 2:建立 Series 後再變異
def mutate_after():
data = ["foo", "bar", "baz"]
ser = pd.Series(data, dtype=pd.StringDtype())
ser.iloc[1] = "BAR"
return ser
# 執行效能測試
mutate_before_time = timeit.timeit(mutate_before, number=1000)
mutate_after_time = timeit.timeit(mutate_after, number=1000)
print(f"先變異再建立 Series 時間:{mutate_before_time}")
print(f"建立 Series 後再變異時間:{mutate_after_time}")
內容解密:
- 在
mutate_before中,先對資料進行變異,然後再建立pd.Series。 - 在
mutate_after中,先建立pd.Series,然後再進行變異。
對低基數資料進行字典編碼
對於低基數資料(即具有少量唯一值的資料),使用類別資料型別可以顯著減少記憶體使用量。
import pandas as pd
# 建立一個低基數 Series
values = ["foo", "bar", "baz"]
ser = pd.Series(values * 100000, dtype=pd.StringDtype())
# 轉換為類別資料型別
cat_dtype = pd.CategoricalDtype(values)
ser_cat = pd.Series(values * 100000, dtype=cat_dtype)
# 比較記憶體使用量
print(f"原始 Series 記憶體使用量:{ser.memory_usage()}")
print(f"類別 Series 記憶體使用量:{ser_cat.memory_usage()}")
內容解密:
- 使用
pd.CategoricalDtype將資料轉換為類別型別。 - 這種轉換可以大幅減少記憶體使用量,尤其是在處理具有大量重複值的資料時。
使用測試驅動開發(TDD)
測試驅動開發是一種軟體開發實踐,強調在編寫功能程式碼之前先編寫測試程式碼。這有助於提高程式碼品質和可維護性。
import unittest
import pandas as pd
class MyTests(unittest.TestCase):
def test_series_comparison(self):
# 建立兩個相同的 Series
ser1 = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())
ser2 = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())
# 使用 pandas.testing 斷言 Series 相等
pd.testing.assert_series_equal(ser1, ser2)
if __name__ == '__main__':
unittest.main()
內容解密:
- 使用
unittest.TestCase建立測試案例。 - 使用
pd.testing.assert_series_equal斷言兩個pd.Series物件相等。