在資料分析中,使用 Pandas 的 groupby() 方法進行分組運算是常見的需求。然而,當需要計算眾數時,Pandas 內建的 mode() 函式在處理多個眾數值時會產生一些挑戰。本文將探討如何利用自定義函式來解決這個問題,並深入研究 groupby()、apply() 和視窗函式的應用技巧,以及如何將這些技術應用於時間序列分析和資料視覺化。討論如何處理分組後的眾數計算,特別是當一個分組中存在多個眾數值時,如何使用自定義函式來獲得所需的結果。同時,也將涵蓋如何使用 agg() 方法進行更有效率的分組聚合操作,以及如何避免 apply() 方法可能帶來的效能問題。
Group By 操作中的模式計算挑戰與自定義解決方案
在進行資料分組(Group By)操作時,計算眾數(Mode)可能會遇到多值問題。pandas 函式庫並未直接提供處理此類別情況的明確方案,因此需要開發者自行定義合適的處理邏輯。
眾數計算的多種預期結果
對於一個分組後的資料集,若某分組存在多個眾數值,主要有三種可能的處理預期:
- 
傳回眾數值的列表:將所有眾數值以列表形式傳回,例如對於 group_a傳回[42, 555]。此方法會導致結果的資料型別變為object,可能引發後續處理問題。
- 
選擇單一眾數值:從多個眾數值中選擇一個傳回,例如對於 group_a傳回42或555。此方法需要定義明確的選擇規則。
- 
重複索引傳回眾數值:對 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)
重點注意事項
- 
函式引數型別註解:在 mode_for_apply中,引數被註解為pd.DataFrame,因為apply方法會將整個 DataFrame 傳遞給自定義函式。
- 
版本相容性:在較新版本的 pandas 中,可能需要或不需要 include_groups=False引數,應根據實際使用的 pandas 版本進行調整。
- 
輸出結果檢查:透過在自定義函式中加入 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轉換為整數型別,以正確表示年份。
找出每年度最高評分的電影
有幾種方法可以實作這一目標。以下介紹兩種常見的方法:
- 排序後分組選取
df.sort_values(["title_year", "imdb_score"]).groupby("title_year")[["movie_title"]].agg(top_rated_movie=pd.NamedAgg("movie_title", "last"))
內容解密:
- 先按 title_year和imdb_score排序。
- 分組後選取每組最後一個值(即最高評分的電影)。
- 使用 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_ab和total_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"])
)
程式碼解密:
- 分組匯總: 使用 groupby方法按照year和id對資料進行分組,並計算每個球員在每個賽季中的總打席次數 (total_ab) 和總安打次數 (total_h)。
- 篩選資料: 使用 loc方法篩選出總打席次數大於400的球員,以確保資料品質。
- 計算打擊率: 使用 assign方法計算每個球員的打擊率 (avg),即總安打次數除以總打席次數。
- 刪除無用欄位: 使用 drop方法刪除total_ab和total_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)
程式碼解密:
- 重置索引: 使用 reset_index方法將averagesDataFrame 的索引重置為預設的整數索引。
- 建立有序分類別變數: 使用 pd.CategoricalDtype建立一個有序分類別變數cat,以確保年份的順序正確。
- 轉換年份欄位: 使用 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()
程式碼解密:
- 篩選資料: 使用 mask篩選出2000年至2009年的資料。
- 建立子圖: 使用 plt.subplots建立一個子圖。
- 繪製小提琴圖: 使用 sns.violinplot繪製小提琴圖,以展示每個賽季中打擊率的分佈情況。
- 設定x軸範圍: 使用 ax.set_xlim設定x軸的範圍,以確保不同賽季之間的比較是公平的。
標準化打擊率
為了比較不同賽季中頂尖球員的表現,我們可以對打擊率進行標準化。標準化的方法是將每個球員的打擊率減去該賽季的平均打擊率,然後除以該賽季的標準差。這樣可以消除不同賽季之間的差異,使得比較更加公平。
透過這種方法,我們可以更好地瞭解頂尖球員在不同賽季中的表現,並對他們的能力進行更準確的評估。
 
            