Pandas 是資料科學領域的利器,但處理大規模資料時,效能瓶頸常成為困擾。透過選用合適的資料型別,例如使用 PyArrow 的 date32 取代 Python 內建的 datetime.date,能有效避免 dt accessor 的錯誤並提升效能。此外,善用 convert_dtypesastypepd.to_numeric 明確指定或轉換資料型別,能大幅降低記憶體使用量。向量化運算也是 Pandas 效能最佳化的關鍵,它能避免低效的 Python 迴圈,以更快速地處理資料。

最佳化Pandas效能:資料型別與向量化運算

在處理大規模資料集時,Pandas的效能最佳化至關重要。本文將探討如何透過選擇適當的資料型別和運用向量化運算來提升Pandas的效能。

認識資料型別的重要性

Pandas中的dtype=object常被濫用於儲存字串或日期等資料。然而,這種做法不僅會導致效能下降,還會限制某些Pandas功能的適用性。

日期型別的處理

在建立日期序列時,新手可能會使用Python標準函式庫中的datetime.date型別:

import datetime
ser = pd.Series([
    datetime.date(2024, 1, 1),
    datetime.date(2024, 1, 2),
    datetime.date(2024, 1, 3),
])

此時,ser的資料型別為object,而非日期型別。這會導致在使用dt存取器時出現錯誤:

ser.dt.year
# AttributeError: Can only use .dt accessor with datetimelike values

為瞭解決這個問題,可以使用PyArrow的date32型別:

ser = pd.Series([
    datetime.date(2024, 1, 1),
    datetime.date(2024, 1, 2),
    datetime.date(2024, 1, 3),
], dtype=pd.ArrowDtype(pa.date32()))

此時,ser的資料型別變為date32[day][pyarrow],且可正常使用dt存取器:

ser.dt.year
# 0    2024
# 1    2024
# 2    2024
# dtype: int64[pyarrow]

資料型別與記憶體使用

選擇適當的資料型別不僅能提升效能,還能降低記憶體使用。以下是一個範例:

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")

使用memory_usage()方法可檢視DataFrame的記憶體使用情況:

df.memory_usage()
# Index    128
# a      900000
# b      900000
# c      900000
# d      900000
# dtype: int64

透過明確指定資料型別,可降低記憶體使用:

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()),
).memory_usage()
# Index    128
# a      200000
# b      300000
# c      500000
# d      900000
# dtype: int64

或者,使用pd.to_numeric()方法自動推斷適當的資料型別:

df.select_dtypes("number").assign(
    **{x: pd.to_numeric(y, downcast="signed", dtype_backend="numpy_nullable") for x, y in df.items()}
).memory_usage()
# Index    128
# a      200000
# b      300000
# c      500000
# d      900000
# dtype: int64

向量化運算的重要性

Python以其出色的迴圈處理能力而聞名,但這並不適用於Pandas。Pandas提供了向量化運算,能夠在不明確使用迴圈的情況下對整個Series進行運算。

範例:計算Series的總和

建立一個簡單的Series:

ser = pd.Series(range(100_000), dtype=pd.Int64Dtype())

使用內建的sum()方法計算總和:

ser.sum()
# 4999950000

向量化運算能夠大幅提升Pandas的效能,尤其是在處理大規模資料集時。

pandas 的一般使用與效能最佳化技巧

在處理資料分析時,pandas 提供了多種強大的功能來提高效能和簡化程式碼。然而,要充分利用這些功能,需要了解如何正確地使用它們。

使用向量化函式

pandas 的一大優勢是其向量化操作,這些操作在底層語言(如 C)中實作,避免了與 Python 執行環境的互動,從而大大提高了效能。例如,使用 pd.Series.sum 函式比使用 Python 迴圈來計算總和要快得多。

import pandas as pd
import timeit

# 建立一個包含大量資料的 Series
ser = pd.Series(range(100000))

# 使用 pandas 的 sum 函式
def pandas_sum():
    return ser.sum()

# 使用 Python 迴圈計算總和
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"Python 迴圈耗時:{loop_time} 秒")

內容解密:

  1. ser.sum():這是 pandas 提供的向量化函式,可以快速計算 Series 中的所有元素的總和。
  2. loop_sum():這是一個使用 Python 迴圈來計算總和的函式,效能較差。
  3. timeit.timeit():用於測量函式執行的時間,number=1000 表示重複執行 1000 次。

避免資料變異

儘管 pandas 允許資料變異,但這樣做的成本會根據資料型別的不同而有所不同。在某些情況下,資料變異可能會非常昂貴,因此應盡量避免。

def mutate_after():
    data = ["foo", "bar", "baz"]
    ser = pd.Series(data, dtype=pd.StringDtype())
    ser.iloc[1] = "BAR"

def mutate_before():
    data = ["foo", "bar", "baz"]
    data[1] = "BAR"
    ser = pd.Series(data, dtype=pd.StringDtype())

mutate_after_time = timeit.timeit(mutate_after, number=1000)
mutate_before_time = timeit.timeit(mutate_before, number=1000)

print(f"在 pandas Series 中變異資料耗時:{mutate_after_time} 秒")
print(f"在載入前變異資料耗時:{mutate_before_time} 秒")

內容解密:

  1. mutate_after():在建立 pandas Series 之後修改資料。
  2. mutate_before():在建立 pandas Series 之前修改資料。
  3. 比較兩者的時間可以看出,在載入前變異資料更為高效。

對低基數資料進行字典編碼

對於基數較低的資料(即唯一值與總記錄數的比例較低),使用類別資料型別可以顯著減少記憶體使用量。

values = ["foo", "bar", "baz"]
ser = pd.Series(values * 100000, dtype=pd.StringDtype())
print(f"原始 Series 記憶體使用量:{ser.memory_usage()} 位元組")

cat = pd.CategoricalDtype(values)
ser_cat = pd.Series(values * 100000, dtype=cat)
print(f"類別 Series 記憶體使用量:{ser_cat.memory_usage()} 位元組")

內容解密:

  1. pd.CategoricalDtype(values):建立一個類別資料型別。
  2. 將 Series 的資料型別轉換為類別型別後,記憶體使用量大幅減少。

使用測試驅動開發

測試驅動開發(TDD)是一種軟體開發實踐,旨在提高程式碼品質和可維護性。pandas 提供了 pd.testing 模組來幫助編寫測試。

import unittest
import pandas as pd

class MyTests(unittest.TestCase):
    def test_series_comparison(self):
        ser1 = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())
        ser2 = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())
        pd.testing.assert_series_equal(ser1, ser2)

if __name__ == '__main__':
    unittest.main()

內容解密:

  1. pd.testing.assert_series_equal():用於比較兩個 Series 是否相等。
  2. 在單元測試中使用 assert_series_equal 可以確保程式碼的正確性。

使用Pandas進行單元測試的最佳實踐

在進行資料分析或科學計算時,Pandas是一個不可或缺的工具。然而,當我們需要確保程式碼的正確性時,單元測試就變得至關重要。本篇文章將探討如何使用Pandas進行單元測試,特別是在使用unittest模組和pytest函式庫的情況下。

為何需要自定義斷言

在進行單元測試時,我們通常會使用斷言來驗證程式碼的輸出是否符合預期。然而,當處理Pandas的SeriesDataFrame時,直接使用assertEqual可能會遇到問題。這是因為Pandas過載了等於運算元(==),使得比較兩個SeriesDataFrame時傳回的不是簡單的TrueFalse,而是另一個包含逐元素比較結果的SeriesDataFrame

import pandas as pd

# 定義一個傳回Series的函式
def some_cool_numbers():
    return pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())

# 建立測試類別
class MyTests(unittest.TestCase):
    def test_cool_numbers(self):
        result = some_cool_numbers()
        expected = pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())
        
        # 直接使用assertEqual會導致錯誤
        # self.assertEqual(result, expected)
        
        # 正確的做法是使用pd.testing.assert_series_equal
        import pandas.testing as tm
        tm.assert_series_equal(result, expected)

# 執行測試
def suite():
    suite = unittest.TestSuite()
    suite.addTest(MyTests("test_cool_numbers"))
    return suite

runner = unittest.TextTestRunner()
runner.run(suite())

內容解密:

  1. some_cool_numbers函式:傳回一個包含特定數字的Pandas Series,資料型別為pd.Int64Dtype()
  2. MyTests類別:繼承自unittest.TestCase,定義了一個測試方法test_cool_numbers
  3. test_cool_numbers方法:呼叫some_cool_numbers()取得結果,並與預期結果進行比較。
  4. pd.testing.assert_series_equal:用於比較兩個Series是否相等,可以正確處理資料型別、索引和缺失值。

觸發測試失敗

為了展示測試失敗的情況,我們可以故意將預期結果的資料型別改為pd.Int32Dtype()

def some_cool_numbers():
    return pd.Series([42, 555, pd.NA], dtype=pd.Int64Dtype())

class MyTests(unittest.TestCase):
    def test_cool_numbers(self):
        result = some_cool_numbers()
        expected = pd.Series([42, 555, pd.NA], dtype=pd.Int32Dtype())  # 更改資料型別
        import pandas.testing as tm
        tm.assert_series_equal(result, expected)

# 執行測試
def suite():
    suite = unittest.TestSuite()
    suite.addTest(MyTests("test_cool_numbers"))
    return suite

runner = unittest.TextTestRunner()
runner.run(suite())

內容解密:

  1. 測試失敗:由於結果和預期結果的資料型別不同(Int64 vs Int32),測試失敗。
  2. 錯誤訊息:Pandas提供了詳細的錯誤訊息,指出了屬性"dtype"的不同。

使用pytest進行單元測試

除了使用內建的unittest模組,許多大型Python專案使用pytest函式庫來編寫和執行單元測試。與unittest不同,pytest採用根據測試夾具(fixture)的方法,而不是類別基礎的結構。

  graph LR;
    A[開始測試] --> B[使用unittest];
    A --> C[使用pytest];
    B --> D[類別基礎結構];
    C --> E[夾具基礎結構];
    D --> F[適用於小型專案];
    E --> G[適用於大型專案];

圖表翻譯: 此圖示展示了兩種不同的單元測試框架:unittest和pytest。unittest採用類別基礎結構,而pytest則使用夾具基礎結構。對於小型專案,unittest可能更為合適;而對於大型專案,pytest提供了更靈活和可擴充套件的解決方案。

Pandas 生態系統

Pandas 函式庫提供了令人印象深刻的豐富功能,而其受歡迎程度很大程度上歸功於大量與之相輔相成的第三方函式庫。我們無法在本章涵蓋所有這些函式庫,甚至無法探討任何一個函式庫的工作原理。然而,只要瞭解這些工具的存在並理解它們所提供的功能,就能為未來的學習提供很大的啟發。

基礎函式庫

像許多開源函式庫一樣,Pandas 在其他基礎函式庫之上構建功能,讓它們管理底層細節,而 Pandas 提供更友好的功能。如果你想深入瞭解 Pandas 以外的技術細節,這些是你需要關注的函式庫。

NumPy

NumPy 將自己標榜為 Python 科學計算的基本套件,它是 Pandas 最初構建的基礎函式庫。NumPy 實際上是一個 n 維函式庫,因此你不受限於像 pd.DataFrame 這樣的二維資料(Pandas 曾經提供過 3 維和 4 維的面板結構,但現在已經不存在了)。

import numpy as np
import pandas as pd

# 從 NumPy 陣列建立 DataFrame
arr = np.arange(1, 10).reshape(3, -1)
df = pd.DataFrame(arr)
print(df)

內容解密:

此程式碼首先匯入必要的函式庫 NumPy 和 Pandas。然後,它建立一個 NumPy 陣列 arr,包含從 1 到 9 的數字,並將其重塑為 3x3 的矩陣。最後,將這個 NumPy 陣列轉換為 Pandas DataFrame df 並列印出來。

# 將 DataFrame 轉換為 NumPy 陣列
print(df.to_numpy())

內容解密:

此程式碼將之前建立的 DataFrame df 轉換回 NumPy 陣列並列印出來。這展示了 Pandas 和 NumPy 之間的互操作性。

許多 NumPy 函式接受 pd.DataFrame 作為引數,甚至會傳回 pd.DataFrame

# 對 DataFrame 中的元素取自然對數
print(np.log(df))

內容解密:

此程式碼對 DataFrame df 中的每個元素取自然對數。這是 Pandas 和 NumPy 之間互操作性的另一個例子,展示瞭如何直接在 DataFrame 上應用 NumPy 函式。

PyArrow

另一個 Pandas 建立在其上的主要函式庫是 Apache Arrow,它將自己標榜為記憶體分析的跨語言開發平台。由 Pandas 的建立者 Wes McKinney 發起,Apache Arrow 專案定義了一維資料結構的記憶體佈局方式,允許不同的語言、程式和函式庫處理相同的資料。除了定義這些結構外,Apache Arrow 專案還為實作 Apache Arrow 規範的函式庫提供了大量的工具。

import pyarrow as pa

# 將 DataFrame 轉換為 PyArrow Table
tbl = pa.Table.from_pandas(df)
print(tbl)

內容解密:

此程式碼將 Pandas DataFrame df 轉換為 PyArrow Table tbl。這展示瞭如何將 Pandas 的資料結構轉換為 PyArrow 的資料結構,以利用 PyArrow 的功能。

# 將 PyArrow Table 轉換回 DataFrame
print(tbl.to_pandas())

內容解密:

此程式碼將 PyArrow Table tbl 轉換回 Pandas DataFrame。這展示了 PyArrow 和 Pandas 之間的互操作性,允許在不同的資料處理函式庫之間輕鬆切換。

探索性資料分析

通常,你會發現自己拿到一個幾乎一無所知的資料集。在本文中,我們已經展示瞭如何手動篩選資料,但也有工具可以幫助自動化潛在的繁瑣任務,並幫助你在更短的時間內掌握資料。

YData Profiling

YData Profiling 自稱為“資料剖析的領先套件,能夠自動生成詳細報告,包括統計資料和視覺化”。雖然我們在視覺化章節中已經瞭解瞭如何手動探索資料,但這個套件可以用作快速啟動,自動生成許多有用的報告和功能。

import pandas as pd

# 載入 vehicles.csv.zip 資料集的子集
df = pd.read_csv(
    "data/vehicles.csv.zip",
    dtype_backend="numpy_nullable",
    usecols=[
        "id",
        "engId",
        "make",
        "model",
        "cylinders",
        "city08",
        "highway08",
        "year",
        "trany",
    ]
)
print(df.head())

內容解密:

此程式碼載入 vehicles.csv.zip 資料集的子集到 DataFrame df 中。它指定了要載入的列,並使用 head() 方法列印出前幾行資料,以展示載入的資料內容。