返回文章列表

Pandas效能最佳化技巧

本文探討 pandas 效能最佳化的技巧,包含避免使用 object dtype,選擇合適的資料型別,利用 PyArrow 的 date32 處理日期,以及使用向量化運算取代迴圈等,有效提升資料處理效率並降低記憶體使用。

資料科學 Python

在資料科學領域,pandas 是不可或缺的工具,但效能問題常常困擾開發者。本文將介紹一些 pandas 的效能最佳化技巧,幫助你寫出更有效率的程式碼。首先,避免使用 dtype=object 儲存字串,尤其在 pandas 1.0 後,pd.StringDtype 提供更好的效能和型別檢查。其次,善用 PyArrow 的 date32 型別處理日期資料,避免使用 datetime.date 物件造成效能損耗。選擇合適的整數型別,例如 Int8Int16 等,可以有效減少記憶體使用量。最後,利用 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 處理字串的效率與資料驗證能力。

  1. 建立範例 Series:分別建立了使用 objectpd.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,但使用了不同的資料型別。接著比較了賦予非字串值的行為差異。
  2. 嘗試賦予非字串值

    ser_str.iloc[0] = False  # 會引發 TypeError,因為 ser_str 是使用 StringDtype,不允許非字串值。
    ser_obj.iloc[0] = False  # 可以成功執行,因為 ser_obj 使用的是 object dtype。
    
    • 解說:展示了 pd.StringDtype() 的嚴格型別檢查,而 object 型別則允許任意型別的值,可能導致潛在錯誤。
  3. 進行字串操作

    ser_obj.str.capitalize().head()  # 將非字串值(False)轉為 NaN。
    
    • 解說:當對混合型別的 object Series 進行字串操作時,非字串值會被默默轉為 NaN,這可能導致難以察覺的錯誤。
  4. 效能比較

    timeit.timeit(ser_obj.str.upper, number=1000)
    timeit.timeit(ser_str.str.upper, number=1000)
    
    • 解說:透過 timeit 模組比較了兩種不同型別 Series 在執行字串操作的效能差異。在 pandas 3.0 之後版本,通常 StringDtype() 更高效。
  5. 讀取 CSV 時指定型別

    pd.read_csv(data, dtype_backend="numpy_nullable").dtypes
    
    • 解說:在讀取 CSV 時,使用 dtype_backend="numpy_nullable" 引數,可以自動將適當欄位轉換為更合適的型別(如 string[python])。
  6. 手動轉換 DataFrame 型別

    df.convert_dtypes(dtype_backend="numpy_nullable")
    
    • 解說:展示如何手動將 DataFrame 的型別轉換為更合適的型別,以避免使用 object 型別。

圖表說明:

此處沒有 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 物件相等。