在資料分析中,使用 Pandas 的 groupby() 方法進行分組運算是常見的需求。然而,當需要計算眾數時,Pandas 內建的 mode() 函式在處理多個眾數值時會產生一些挑戰。本文將探討如何利用自定義函式來解決這個問題,並深入研究 groupby()apply() 和視窗函式的應用技巧,以及如何將這些技術應用於時間序列分析和資料視覺化。討論如何處理分組後的眾數計算,特別是當一個分組中存在多個眾數值時,如何使用自定義函式來獲得所需的結果。同時,也將涵蓋如何使用 agg() 方法進行更有效率的分組聚合操作,以及如何避免 apply() 方法可能帶來的效能問題。

Group By 操作中的模式計算挑戰與自定義解決方案

在進行資料分組(Group By)操作時,計算眾數(Mode)可能會遇到多值問題。pandas 函式庫並未直接提供處理此類別情況的明確方案,因此需要開發者自行定義合適的處理邏輯。

眾數計算的多種預期結果

對於一個分組後的資料集,若某分組存在多個眾數值,主要有三種可能的處理預期:

  1. 傳回眾數值的列表:將所有眾數值以列表形式傳回,例如對於 group_a 傳回 [42, 555]。此方法會導致結果的資料型別變為 object,可能引發後續處理問題。

  2. 選擇單一眾數值:從多個眾數值中選擇一個傳回,例如對於 group_a 傳回 42555。此方法需要定義明確的選擇規則。

  3. 重複索引傳回眾數值:對 group_a 傳回兩次,分別對應不同的眾數值(42 和 555)。這種做法與 pandas 現有的分組聚合邏輯不符,可能導致不一致的使用體驗。

自定義眾數計算函式

為瞭解決上述問題,可以定義自定義的眾數計算函式,並透過 pd.core.groupby.DataFrameGroupBy.agg 方法應用這些函式:

scalar_or_list_mode 函式

def scalar_or_list_mode(ser: pd.Series):
    """
    計算眾數,若有多個眾數則傳回列表,否則傳回單一值。
    """
    result = ser.mode()
    if len(result) > 1:
        return result.tolist()
    elif len(result) == 1:
        return result.iloc[0]
    return pd.NA

scalar_or_bust_mode 函式

def scalar_or_bust_mode(ser: pd.Series):
    """
    計算眾數,若無眾數則傳回 NA,否則傳回第一個眾數值。
    """
    result = ser.mode()
    if len(result) == 0:
        return pd.NA
    return result.iloc[0]

應用自定義函式於分組資料

df.groupby("group").agg(
    scalar_or_list=pd.NamedAgg(column="value", aggfunc=scalar_or_list_mode),
    scalar_or_bust=pd.NamedAgg(column="value", aggfunc=scalar_or_bust_mode),
)

Group By Apply 的使用場景

當需要進行更複雜的操作,而非簡單的聚合或轉換時,可以使用 pd.core.groupby.DataFrameGroupBy.apply。此方法提供了更大的靈活性,但也可能導致結果不明確或在不同版本間出現相容性問題。

自定義 mode_for_apply 函式

def mode_for_apply(df: pd.DataFrame):
    """
    對分組後的 DataFrame 計算眾數。
    """
    return df["value"].mode()

df.groupby("group").apply(mode_for_apply, include_groups=False)

重點注意事項

  1. 函式引數型別註解:在 mode_for_apply 中,引數被註解為 pd.DataFrame,因為 apply 方法會將整個 DataFrame 傳遞給自定義函式。

  2. 版本相容性:在較新版本的 pandas 中,可能需要或不需要 include_groups=False 引數,應根據實際使用的 pandas 版本進行調整。

  3. 輸出結果檢查:透過在自定義函式中加入 print 陳述式,可以觀察傳遞給函式的資料內容,有助於除錯和理解 apply 方法的工作機制。

視窗操作與群組運算的深度解析

在 pandas 中,GroupBy 操作是一種強大的資料分析工具,而視窗操作(Window Operations)則提供了一種靈活的方式來計算滾動或擴充套件的資料指標。本文將探討這兩種操作的原理、應用場景以及如何有效地使用它們。

群組運算(Group By)與 apply 方法的陷阱

當使用 pd.core.groupby.DataFrameGroupBy.apply 時,pandas 會將資料分組並傳遞給使用者自定義的函式。這個函式的傳回值將決定輸出的形狀。如果傳回值是 pd.Series,pandas 將自動建立一個具有多級索引的輸出。

def mode_for_apply(df: pd.DataFrame):
    # 自定義函式邏輯
    return df.mode().iloc[0]

df.groupby("group").apply(mode_for_apply)

內容解密:

  • mode_for_apply 函式計算每個分組的眾數。
  • df.groupby("group").apply(mode_for_apply) 將資料分組並應用 mode_for_apply 函式。
  • 輸出結果具有多級索引,第一級是分組值,第二級是原始資料的行索引。

然而,濫用 apply 方法可能導致效能問題或程式碼在不同 pandas 版本之間出現相容性問題。如果函式最終傳回單一值,應優先使用 agg 方法。

def sum_values(df: pd.DataFrame):
    return df["value"].sum()

df.groupby("group").agg(sum_values)

內容解密:

  • sum_values 函式計算每個分組的總和。
  • 使用 agg 方法替代 apply 以提高效能和程式碼穩定性。

視窗操作(Window Operations)詳解

視窗操作允許在資料的滾動或擴充套件視窗上進行計算。常見的應用包括計算滾動平均值、移動總和等。

滾動視窗(Rolling Window)

ser = pd.Series([0, 1, 2, 4, 8, 16], dtype=pd.Int64Dtype())
ser.rolling(2).sum().astype(pd.Int64Dtype())

內容解密:

  • ser.rolling(2) 定義了一個視窗大小為 2 的滾動視窗。
  • .sum() 對每個視窗內的資料進行求和。
  • 輸出結果是每個視窗的總和,第一個值為 <NA> 因為無法形成完整的視窗。

使用 min_periods 引數可以調整視窗大小的最小需求:

ser.rolling(2, min_periods=1).sum().astype(pd.Int64Dtype())

內容解密:

  • min_periods=1 表示即使視窗大小不足 2,也會進行計算。

擴充套件視窗(Expanding Window)

ser.expanding().mean().astype(pd.Float64Dtype())

內容解密:

  • .expanding() 定義了一個擴充套件視窗,包含所有之前的資料。
  • .mean() 計算每個擴充套件視窗的平均值。
  • 輸出結果是每個擴充套件視窗的平均值。

圖表翻譯:

此圖示展示了滾動視窗和擴充套件視窗的操作過程。

  graph LR
    A[資料序列] -->|滾動視窗|> B[視窗大小 = 2]
    B --> C[求和]
    C --> D[輸出結果]
    
    A -->|擴充套件視窗|> E[包含所有之前資料]
    E --> F[計算平均值]
    F --> G[輸出結果]

圖表翻譯: 此圖表展示了滾動視窗和擴充套件視窗的計算過程。滾動視窗根據設定的大小進行資料計算,而擴充套件視窗則包含所有之前的資料進行計算。

使用滾動視窗函式進行時間序列分析

在進行時間序列分析時,滾動視窗函式(Rolling Window Functions)是一種非常有用的工具。透過這種函式,我們可以輕鬆地計算移動平均值(Moving Averages),並且將結果視覺化。

計算移動平均值

首先,我們可以使用 rolling() 函式來計算移動平均值。以下是一個範例:

import matplotlib.pyplot as plt
plt.ion()

df.assign(
    ma30=df["Close"].rolling(30).mean().astype(pd.Float64Dtype()),
    ma60=df["Close"].rolling(60).mean().astype(pd.Float64Dtype()),
    ma90=df["Close"].rolling(90).mean().astype(pd.Float64Dtype()),
).plot()

內容解密:

  • rolling(30) 表示計算 30 天的移動平均值。
  • mean() 計算平均值。
  • astype(pd.Float64Dtype()) 確保資料型別正確。
  • assign() 用於新增計算出的移動平均值欄位。
  • plot() 將結果視覺化。

使用擴充套件視窗函式進行年度累計計算

對於「年初至今」(Year-to-Date)或「季度至今」(Quarter-to-Date)的計算,我們可以結合 groupby() 和擴充套件視窗函式(Expanding Window Functions)來實作。

df.groupby(pd.Grouper(freq="YS")).expanding().agg(["min", "max", "mean"])

內容解密:

  • pd.Grouper(freq="YS") 將資料按年度起始日期分組。
  • expanding() 對每個分組進行擴充套件視窗計算。
  • agg(["min", "max", "mean"]) 計算最小值、最大值和平均值。

視覺化年度累計結果

將結果視覺化可以更直觀地理解資料的變化趨勢:

df.groupby(pd.Grouper(freq="YS")).expanding().agg(["min", "max", "mean"]).droplevel(axis=1, level=0).reset_index(level=0, drop=True).plot()

內容解密:

  • droplevel(axis=1, level=0) 用於簡化欄位名稱。
  • reset_index(level=0, drop=True) 重設索引以便繪圖。

選擇每年度最高評分的電影

在資料分析中,選取某個欄位在分組內的最大值是一項常見操作。以下範例展示如何找出每年度最高評分的電影。

首先,讀取電影資料集並選取需要的欄位:

df = pd.read_csv("data/movie.csv", usecols=["movie_title", "title_year", "imdb_score"], dtype_backend="numpy_nullable")

內容解密:

  • usecols=["movie_title", "title_year", "imdb_score"] 選取需要的欄位。
  • dtype_backend="numpy_nullable" 設定資料型別後端。

接著,修正 title_year 欄位的資料型別:

df["title_year"] = df["title_year"].astype(pd.Int16Dtype())

內容解密:

  • title_year 轉換為整數型別,以正確表示年份。

找出每年度最高評分的電影

有幾種方法可以實作這一目標。以下介紹兩種常見的方法:

  1. 排序後分組選取
df.sort_values(["title_year", "imdb_score"]).groupby("title_year")[["movie_title"]].agg(top_rated_movie=pd.NamedAgg("movie_title", "last"))

內容解密:

  • 先按 title_yearimdb_score 排序。
  • 分組後選取每組最後一個值(即最高評分的電影)。
  1. 使用 idxmax
df.set_index("movie_title").groupby("title_year").agg(top_rated_movie=pd.NamedAgg("imdb_score", "idxmax"))

內容解密:

  • movie_title 設定為索引。
  • 使用 idxmax 直接選取每組內 imdb_score 最高的電影標題。

資料分組與聚合分析

在處理大量資料時,分組與聚合是常見且重要的資料分析技術。本章節將探討如何使用 pandas 函式庫進行資料的分組與聚合,特別是在電影評分和棒球資料分析中的實際應用。

電影評分分析

假設我們有一份包含電影標題、發行年份和IMDb評分的資料集,我們希望找出每年評分最高的電影。以下是一個簡單的範例:

import pandas as pd

# 讀取電影資料
df = pd.read_parquet("data/movies.parquet")

# 按照年份分組並找出最高評分的電影
def top_rated(df: pd.DataFrame):
    top_rating = df["imdb_score"].max()
    top_rated = df[df["imdb_score"] == top_rating]["movie_title"].unique()
    if len(top_rated) == 1:
        return top_rated[0]
    else:
        return top_rated

result = df.groupby("title_year").apply(top_rated, include_groups=False).to_frame().rename(columns={0: "top_rated_movie(s)"})
print(result)

內容解密:

  • groupby("title_year") 將資料按照電影發行年份進行分組。
  • apply(top_rated) 對每個分組應用 top_rated 函式,找出該年份最高評分的電影。
  • 如果某年份有多部電影具有相同的最高評分,則傳回這些電影的列表。

棒球資料分析

在棒球資料分析中,我們需要計算每個球員每年的擊球率(batting average)。擊球率是透過將球員的總安打數除以總打數來計算的。

# 讀取棒球資料
df = pd.read_parquet("data/mlb_batting_lines.parquet")

# 按照年份和球員ID分組,計算總打數和總安打數
result = (
    df.groupby(["year", "id"]).agg(
        total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),
        total_h=pd.NamedAgg(column="h", aggfunc="sum")
    )
    .assign(avg=lambda x: x["total_h"] / x["total_ab"])
    .drop(columns=["total_ab", "total_h"])
)

print(result)

內容解密:

  • groupby(["year", "id"]) 將資料按照年份和球員ID進行分組。
  • agg 方法計算每個球員每年的總打數(total_ab)和總安打數(total_h)。
  • assign 方法計算擊球率(avg),即總安打數除以總打數。
  • 最後,丟棄不再需要的 total_abtotal_h 列。

棒球資料分析:瞭解打擊率的分佈與標準化

在棒球資料分析中,打擊率是一項重要的指標。然而,在計算打擊率時,我們需要考慮資料品質的問題。某些球員在一季中的打席次數可能很少,這可能會導致打擊率的計算出現偏差。

資料品質問題

當球員在一季中的打席次數很少時,打擊率的計算可能會出現以下問題:

  • 除以零的錯誤:當球員沒有任何打席記錄時,打擊率的計算會出現除以零的錯誤,從而導致 NaN(Not a Number)的結果。
  • 小樣本偏差:當球員的打席次數很少時,打擊率的計算可能會受到小樣本偏差的影響,從而導致不準確的結果。

解決資料品質問題

為了避免上述問題,我們可以設定一個門檻值,例如至少400次打席,以確保資料的品質。以下是使用Pandas進行資料處理的範例程式碼:

(
    df.groupby(["year", "id"]).agg(
        total_ab=pd.NamedAgg(column="ab", aggfunc="sum"),
        total_h=pd.NamedAgg(column="h", aggfunc="sum")
    )
    .loc[lambda df: df["total_ab"] > 400]
    .assign(avg=lambda x: x["total_h"] / x["total_ab"])
    .drop(columns=["total_ab", "total_h"])
)

程式碼解密:

  1. 分組匯總: 使用 groupby 方法按照 yearid 對資料進行分組,並計算每個球員在每個賽季中的總打席次數 (total_ab) 和總安打次數 (total_h)。
  2. 篩選資料: 使用 loc 方法篩選出總打席次數大於400的球員,以確保資料品質。
  3. 計算打擊率: 使用 assign 方法計算每個球員的打擊率 (avg),即總安打次數除以總打席次數。
  4. 刪除無用欄位: 使用 drop 方法刪除 total_abtotal_h 欄位,以簡化資料。

分析打擊率的分佈

為了了解打擊率的分佈情況,我們可以使用小提琴圖(Violin Plot)來視覺化資料。首先,我們需要將資料轉換為Seaborn所需的格式:

sns_df = averages.reset_index()
years = sns_df["year"].unique()
cat = pd.CategoricalDtype(sorted(years), ordered=True)
sns_df["year"] = sns_df["year"].astype(cat)

程式碼解密:

  1. 重置索引: 使用 reset_index 方法將 averages DataFrame 的索引重置為預設的整數索引。
  2. 建立有序分類別變數: 使用 pd.CategoricalDtype 建立一個有序分類別變數 cat,以確保年份的順序正確。
  3. 轉換年份欄位: 使用 astype 方法將 year 欄位轉換為有序分類別變數 cat

接下來,我們可以使用Seaborn繪製小提琴圖:

mask = (sns_df["year"] >= 2000) & (sns_df["year"] < 2010)
fig, ax = plt.subplots()
sns.violinplot(
    data=sns_df[mask],
    ax=ax,
    x="avg",
    y="year",
    order=sns_df.loc[mask, "year"].unique(),
)
ax.set_xlim(0.15, 0.4)
plt.show()

程式碼解密:

  1. 篩選資料: 使用 mask 篩選出2000年至2009年的資料。
  2. 建立子圖: 使用 plt.subplots 建立一個子圖。
  3. 繪製小提琴圖: 使用 sns.violinplot 繪製小提琴圖,以展示每個賽季中打擊率的分佈情況。
  4. 設定x軸範圍: 使用 ax.set_xlim 設定x軸的範圍,以確保不同賽季之間的比較是公平的。

標準化打擊率

為了比較不同賽季中頂尖球員的表現,我們可以對打擊率進行標準化。標準化的方法是將每個球員的打擊率減去該賽季的平均打擊率,然後除以該賽季的標準差。這樣可以消除不同賽季之間的差異,使得比較更加公平。

透過這種方法,我們可以更好地瞭解頂尖球員在不同賽季中的表現,並對他們的能力進行更準確的評估。