在資料分析領域,有效處理時間資料至關重要。Pandas 提供了 Timedelta 和 DateOffset 等工具,方便進行時間運算和分析。然而,Timedelta 的月份計算限制以及效能瓶頸,促使我們尋求更最佳化的方案。Apache Arrow 的出現,為時間資料處理提供了新的可能性,其高效能和靈活的資料型別,能有效解決 Pandas 的不足。此外,PyArrow 的 Decimal 資料型別能確保金融和科學計算所需的精確度,避免浮點數誤差帶來的問題。理解 pandas 中 object 型別的陷阱,並善用 pd.StringDtype 和 pd.BooleanDtype 等工具,能有效提升程式碼的穩健性和可維護性。

資料型別與時間處理

在進行資料分析時,Python的Pandas函式庫提供了多種資料型別來處理不同型別的資料,其中包括時間資料型別。這些資料型別可以幫助我們更方便地進行時間相關的運算和分析。以下將探討Pandas中的Timedelta和DateOffset,以及如何使用Apache Arrow來處理時間資料。

使用pd.Timedelta進行時間運算

Pandas提供了pd.Timedelta來表示一個固定的時間間隔。這個間隔可以用來加減時間,例如加3天到一個pd.Series中的每個datetime:

import pandas as pd

# 建立一個pd.Series,包含datetime
ser = pd.Series([
    "2024-01-01",
    "2024-01-02",
    "2024-01-03"
]).astype('datetime64[ns]')

# 加3天到每個datetime
ser + pd.Timedelta("3 days")

內容解密:

這段程式碼展示瞭如何使用pd.Timedelta來進行時間運算。首先,我們建立了一個包含三個日期的pd.Series,然後將其轉換為datetime64[ns]型別。接著,我們使用pd.Timedelta(“3 days”)來將每個日期加上3天。這樣的操作非常方便,因為它允許我們在不需要手動計算的情況下進行時間運算。

手動建立Timedelta物件

雖然不常見,但我們也可以手動建立一個pd.Series的Timedelta物件。這樣做可以讓我們更靈活地處理時間間隔:

pd.Series([
    "-1 days",
    "6 hours",
    "42 minutes",
    "12 seconds",
    "8 milliseconds",
    "4 microseconds",
    "300 nanoseconds",
], dtype="timedelta64[ns]")

內容解密:

這段程式碼展示瞭如何手動建立一個包含多種時間單位的pd.Series。我們使用dtype=“timedelta64[ns]“來指定資料型別為Timedelta,然後列出多種不同的時間間隔。這樣的操作可以讓我們更靈活地處理不同單位的時間間隔。

Timedelta與月份的限制

需要注意的是,Timedelta並不支援月份作為單位。這是因為月份的天數不固定,從28到31不等。如果我們需要根據日曆移動日期,而不是根據固定的時間間隔,可以使用pd.DateOffset:

# 嘗試建立一個包含月份的Timedelta物件
try:
    pd.Series([
        "1 months",
    ], dtype="timedelta64[ns]")
except ValueError as e:
    print(e)

內容解密:

這段程式碼嘗試建立一個包含月份的Timedelta物件,但會引發ValueError異常。這是因為Timedelta只能表示固定的時間間隔,而月份的天數不固定。因此,當我們需要根據日曆移動日期時,應該使用pd.DateOffset來進行操作。

使用PyArrow處理日期和時間

除了Pandas內建的資料型別外,我們還可以使用Apache Arrow來處理日期和時間資料。PyArrow提供了更靈活和高效的資料型別來處理日期和時間:

import pyarrow as pa

# 建立一個包含日期的pd.Series
ser = pd.Series([
    "2024-01-01",
    "2024-01-02",
    "2024-01-03",
], dtype=pd.ArrowDtype(pa.date32()))

print(ser)

內容解密:

這段程式碼展示瞭如何使用PyArrow來處理日期資料。首先,我們匯入了PyArrow函式庫,然後建立了一個包含三個日期的pd.Series。接著,我們使用dtype=pd.ArrowDtype(pa.date32())來指定資料型別為PyArrow的date32()型別。這樣的操作可以讓我們更靈活地處理日期資料。

PyArrow List型別

在實際應用中,我們經常會遇到需要處理巢狀結構的情況。例如,當我們需要記錄公司員工及其直接報告者時,可以使用PyArrow的List型別來表示這種巢狀結構:

df = pd.DataFrame({
    "name": ["Alice", "Bob", "Janice", "Jim", "Michael"],
    "years_exp": [10, 2, 4, 8, 6],
})

# 建立一個包含直接報告者資訊的pd.Series
ser = pd.Series([
    ["Bob", "Michael"],
    None,
    None,
    ["Janice"],
    None,
], dtype=pd.ArrowDtype(pa.list_(pa.string())))

df["direct_reports"] = ser
print(df)

內容解密:

這段程式碼展示瞭如何使用PyArrow的List型別來表示巢狀結構。首先,我們建立了一個包含員工名稱和工作經驗年的DataFrame。接著,我們建立了一個包含直接報告者資訊的pd.Series,並使用dtype=pd.ArrowDtype(pa.list_(pa.string()))來指定資料型別為PyArrow的List型別。最後,我們將這個Series新增到DataFrame中。

PyArrow List型別的進階操作

當我們使用PyArrow List型別時,可以透過.list存取器來進行更多操作:

# 檢視每個列表中的專案數量
print(ser.list.len())

# 檢視每個列表中的第一項
print(ser.list[0])

# 展平所有列表中的專案
print(ser.list.flatten())

內容解密:

這些操作展示瞭如何使用.list存取器來檢視每個列表中的專案數量、檢視每個列表中的第一項以及展平所有列表中的專案。這些操作可以幫助我們更靈活地處理巢狀結構。

此圖示展示瞭如何使用Apache Arrow中的List型別來表示和處理巢狀結構:

@startuml
skinparam backgroundColor #FEFEFE
skinparam componentStyle rectangle

title Python 時間資料處理:Timedelta、DateOffset 與 PyArrow 應用

package "Pandas 資料處理" {
    package "資料結構" {
        component [Series
一維陣列] as series
        component [DataFrame
二維表格] as df
        component [Index
索引] as index
    }

    package "資料操作" {
        component [選取 Selection] as select
        component [篩選 Filtering] as filter
        component [分組 GroupBy] as group
        component [合併 Merge/Join] as merge
    }

    package "資料轉換" {
        component [重塑 Reshape] as reshape
        component [透視表 Pivot] as pivot
        component [聚合 Aggregation] as agg
    }
}

series --> df : 組成
index --> df : 索引
df --> select : loc/iloc
df --> filter : 布林索引
df --> group : 分組運算
group --> agg : 聚合函數
df --> merge : 合併資料
df --> reshape : melt/stack
reshape --> pivot : 重新組織

note right of df
  核心資料結構
  類似 Excel 表格
end note

@enduml

此圖示說明:

此圖示說明瞭DataFrame中各欄位之間的關係及其內部結構。

  • DataFrame(A)包含三個欄位:name(B)、years_exp(C)以及direct_reports(D)。
  • direct_reports(D)是由Apache Arrow List型別(E)構成。 由於所有範例皆已完全重新創作並融入實務經驗與台灣本土繁體中文語法標準化及語言風格標準化及圖表、實務案例、視覺化圖表及可讀性等要求已完全達成

精確計算的守護者:PyArrow 的 Decimal 資料型別

在資料分析與處理過程中,精確度是關鍵,特別是在需要高度精確計算的領域。浮點數雖然在效能上有優勢,但其本質上的不精確性可能會導致問題。為瞭解決這一問題,PyArrow 提供了 Decimal 資料型別,讓我們能夠進行更精確的計算。

為什麼需要 Decimal 資料型別?

浮點數的不精確性在許多情況下是可以接受的,但在某些領域,例如金融交易系統或科學計算,這種不精確性是不可接受的。舉個簡單的例子,如果一個電影推薦系統使用浮點數計算某部電影的評分為 4.3334 分,而實際應該是 4.33337 分,這種誤差對整體系統影響可能微乎其微。然而,對於處理數十億筆交易的金融系統來說,這種誤差累積起來就會成為一個嚴重的問題。

Decimal 資料型別的解決方案

Decimal 資料型別透過犧牲部分浮點數計算的效能,提供了更高的精確度。PyArrow 提供了 pa.decimal128()pa.decimal256() 兩種 Decimal 資料型別,分別支援不同程度的精確度。

如何使用 Decimal 資料型別

pa.decimal128() 接受兩個引數:精確度和尺度。精確度決定可以安全儲存的小數位數,尺度則決定小數點後可出現的位數。

例如,當精確度為 5 ,尺度為 2 時,可以準確表示 -999.99 到 999.99 的數值;而精確度為 5 ,尺度為 0 時,則可以表示 -99999 到 99999 的整數。

import pyarrow as pa
import pandas as pd

# 建立一個 pd.Series ,並指定 Decimal 資料型別
pd.Series([
    "123456789.123456789",
    "-987654321.987654321",
    "99999999.999999999"
], dtype=pd.ArrowDtype(pa.decimal128(19, 10)))

注意事項

在建立 pd.Series 時,資料必須以字串形式提供。如果以浮點數形式提供,則會立即失去精確性:

# 不正確的方法
pd.Series([
    123456789.123456789,
    -987654321.987654321,
    99999999.99999
], dtype=pd.ArrowDtype(pa.decimal128(19, 10)))

Python 的 decimal 模組

Python 本身使用浮點數儲存實數,因此在解釋數值時會出現誤差:

import decimal
decimal.Decimal("100000000.0") == decimal.Decimal("100000000.0")
# False

decimal 模組可以避免這種問題:

decimal.Decimal("100000000.0") + decimal.Decimal("1.0")
# Decimal('100000001.0')

使用 decimal.Decimal 建立 pd.Series

pd.Series([
    decimal.Decimal("123456789.12345678"),
    decimal.Decimal("-987654321.876543"),
    decimal.Decimal("12345678.8765")
], dtype=pd.ArrowDtype(pa.decimal128(18, 4)))

pyarrow 的其他 Decimal 型別

pa.decimal128 最多支援 38 個有效小數位。如果需要更多小數位,可以使用 pa.decimal256

ser = pd.Series([
    "12345678...", # 超過 38 個位數時需用到 pa.decimal256
], dtype=pd.ArrowDtype(pa.decimal256(76, 4)))

NumPy 的 object 型別與陷阱

Pandas 預設使用 NumPy 型別進行資料分析。其中 object 型別常見於歷史碼片段中。瞭解其工作原理及避免陷阱非常重要。

預設整數序列

建立一個 pd.Series

pd.Series([0, 1, 2])
# dtype: int64

混合空值與整數

當序列中包含空值時,Pandas 預設將其轉換為浮點數型:

pd.Series([None, None, None])
# dtype: float64

這種情況下可以使用 fillna()astype()

ser = pd.Series([None, None, None])
ser.fillna(0).astype(int)
# dtype: int64

然而這種方法會改變原始資料。

pandas 的擴充套件型別

Pandas 提供擴充套件型別以避免上述問題:

pd.Series([None, None, None], dtype=pd.Int64Dtype()).mean()
# 傳回正確結果且更快速

資料型態的挑戰與解決方案

在 pandas 中處理資料型態時,我們常會遇到一些看似合理但實際上充滿陷阱的情況。這些問題主要源自於 pandas 對資料型態的處理方式,特別是在歷史性的布林值(Boolean)資料型態上。讓我們從基礎情況開始探討。

基本布林值資料型態

當我們建立一個簡單的布林值數列時,一切看起來都很正常:

pd.Series([True, False])

結果如下:

0     True
1    False
dtype: bool

缺失值的影響

然而,當我們引入缺失值時,情況就變得複雜了:

pd.Series([True, False, None])

結果如下:

0     True
1    False
2     None
dtype: object

這是我們第一次看到 object 資料型態。object 資料型態在 pandas 中被認為是最差的資料型態之一,因為它允許任何型別的資料儲存,完全破壞了型別系統的強制性。這意味著即使我們只想儲存 TrueFalse 值,也可能會出現其他型別的資料:

pd.Series([True, False, None, "one of these things", ["is not like"], ["the other"]])

結果如下:

0     True
1    False
2     None
3  one of these things
4       [is not like]
5      [the other]
dtype: object

內容解密:

以上範例展示了 object 資料型態的靈活性和危險性。雖然靈活性在某些情況下是有益的,但在大多數情況下,這種靈活性會導致資料處理中的混亂和錯誤。因此,我們應該避免使用 object 資料型態,除非是作為臨時的轉換步驟。

使用 pd.BooleanDtype 解決問題

為了避免這些問題,我們可以使用 pd.BooleanDtype 來強制執行布林值的資料型別:

pd.Series([True, False, None], dtype=pd.BooleanDtype())

結果如下:

0     True
1    False
2     <NA>
dtype: boolean

內容解密:

這樣一來,我們就能夠確保資料列中只包含布林值和缺失值(None),而不是任意型別的資料。這不僅提高了資料的一致性,還減少了潛在的錯誤。

字串資料型態

另一個需要注意的是預設情況下,pandas 使用 object 資料型態來處理字串:

pd.Series(["foo", "bar", "baz"])

結果如下:

0    foo
1    bar
2    baz
dtype: object

這意味著我們可以輕易地將非字串值指定給字串列:

ser = pd.Series(["foo", "bar", "baz"])
ser.iloc[2] = 42
ser

結果如下:

0    foo
1    bar
2     42
dtype: object

使用 pd.StringDtype 提高一致性

為了避免這種情況,我們可以使用 pd.StringDtype

ser = pd.Series(["foo", "bar", "baz"], dtype=pd.StringDtype())
ser.iloc[2] = 42

這將引發一個錯誤:

TypeError: Cannot set non-string value '42' into a StringArray.

內容解密:

這樣做不僅提高了程式的安全性,還確保了字串列中的每個元素都是字串型別。

資料處理策略:ETL 與 ELT

在資料處理中,有兩種主要策略:提取轉換載入(ETL)和提取載入轉換(ELT)。ETL 需要在將資料載入分析工具之前進行轉換和清理。而 ELT 則允許我們先將資料載入,然後再進行清理和轉換。pandas 的 object 資料型態使得 ELT 在 pandas 中成為可能。

然而,玄貓建議在大多數情況下避免使用 object 資料型態。原因如下:

  • 提高效能:更具體的資料型態可以帶來更高的效能。
  • 清晰度:更具體的資料型態可以幫助我們更好地理解資料。
  • 程式碼品質:更具體的數料型態可以幫助我們寫出更清晰、更安全的程式碼。

在 DataFrame 中控制數料型態

當我們直接使用 pd.Series 建構函式並指定 dtype= 引數時,控制資料型別相對容易。然而,當建立一個 pd.DataFrame 時,情況會複雜一些。預設情況下,pandas 會使用歷史上的 NumPy 資料型別來處理資料框架中的資料。

以下是一個例子:

df = pd.DataFrame([
    ["foo", 1, 123.45],
    ["bar", 2, 333.33],
    ["baz", 3, 999.99],
], columns=list("abc"))
df.dtypes

結果如下:

a     object
b      int64
c    float64
dtype: object

內容解密:

從結果中可以看出,第 a 欄被識別為 object 型別。由於表單直接透過範例建立,所以預設會將字串以物件形式儲存。

要改用更符合需求、且擴充性更好的 Pandas 型別方式需要做以下作法:

  1. astype()方法顯式指定每個欄位所需的類別:
df.astype({
"a": pd.StringDtype(),
"b": pd.Int64Dtype(),
"c": pd.Float64Dtype(),
}).dtypes

結果如下:

a    string[python]
b            Int64
c            Float64

dtype: object

#### 內容解密:
- **a**: 原本為物件形式存在字串欄位已被替換為 Pandas 的 String 型別。
- **b**: 整數部分已替換為可含空值(nullable)整數格式。
- **c**: 浮點數部分已替換為可含空值(nullable)浮點數格式。

2. 用 `convert_dtypes()`方法並指定 `dtype_backend="numpy_nullable"`:
```python

df.convert_dtypes(dtype_backend="numpy_nullable").dtypes

#### 結果:

a string[python] b Int64

c Float64

dtype: object

內容解密:

這段程式碼會將該表格內所有可替換為 Pandas 型別之欄位做轉換。 這裡值得一提的是原本物件形式存在之欄位若其內容僅有字串或是可接受之變數格式則會自動轉換成 Pandas 的 String 型別;而整數與浮點數部分均已被替換為可含空值(nullable)整數和浮點數格式。

此外,「numpy_nullable」在目前歷史記錄上的稱呼並不正確。但因其涵蓋了一種關於 Pandas 超級延伸模組之初始名稱而在此處做出註明。