快轉到主要內容
  1. 教學文章/

Pandas GroupBy 實戰:聚合、Pivot Table 與報表整理

·8 分鐘· loading · loading · ·
Python Pandas GroupBy Pivot Table Data-Analysis Reporting
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 111: 本文

一. 前言:資料很多,不代表報表就有答案
#

拍拍君每次拿到銷售明細,最常看到的問題不是「怎麼讀 CSV」, 而是讀完之後,大家開始手動複製、貼上、加總,再做一張新的 Excel。

一開始只有幾十筆時還能撐,到了幾萬筆就會出現熟悉的災情:

  • 同一份資料,每個人算出的營收不一樣;
  • 空白地區在彙總時悄悄消失;
  • sum()count()size() 混著用,筆數越算越少;
  • 多層欄名匯出 CSV 後,沒人知道怎麼讀;
  • Pivot Table 看起來漂亮,卻無法追查原始計算。

這篇不做泛泛的 Pandas 入門。 我們直接從一份「逐筆訂單」開始,建立一條可以重跑、檢查、匯出的報表流程:

  1. 清理欄位與型別;
  2. groupby() 做單層與多層聚合;
  3. 用 named aggregation 產生穩定欄名;
  4. 正確處理缺值、分類欄與日期;
  5. pivot_table() 做管理報表;
  6. 加上總計、排序、格式化與驗證;
  7. 匯出適合人看,也適合程式再讀的檔案。

如果你的資料已經大到單機記憶體放不下,可以改看 DuckDB 分析 CSV 與 Parquet; 想比較另一套 DataFrame API,則可以參考 Polars 實戰

二. 安裝與準備資料
#

本文以 Python 3.12 與目前穩定版 Pandas API 為基準。 用 uv 建立一個乾淨專案:

uv init pandas-report-demo
cd pandas-report-demo
uv add pandas

如果你習慣 pip

python -m pip install pandas

建立 report.py,先準備一份刻意帶有缺值與取消訂單的資料:

import pandas as pd

orders = pd.DataFrame(
    {
        "order_id": [
            "A001", "A002", "A003", "A004",
            "A005", "A006", "A007", "A008",
        ],
        "ordered_at": [
            "2026-08-01", "2026-08-01", "2026-08-02", "2026-08-02",
            "2026-08-03", "2026-08-03", "2026-08-04", "2026-08-04",
        ],
        "region": ["北區", "南區", "北區", None, "南區", "北區", "南區", "北區"],
        "channel": ["官網", "門市", "門市", "官網", "官網", "官網", "門市", "門市"],
        "product": ["咖啡", "茶", "咖啡", "茶", "咖啡", "茶", "咖啡", "茶"],
        "quantity": [2, 1, 3, 2, 1, 4, 2, 1],
        "unit_price": [180, 120, 180, 120, 180, 120, 180, 120],
        "status": ["paid", "paid", "paid", "paid", "cancelled", "paid", "paid", "paid"],
    }
)

真正的專案通常會從 CSV 讀入:

orders = pd.read_csv(
    "orders.csv",
    dtype={
        "order_id": "string",
        "region": "string",
        "channel": "category",
        "product": "category",
        "status": "category",
    },
    parse_dates=["ordered_at"],
)

先把型別講清楚,通常比聚合完才補救省事。 尤其日期若還是字串,跨月份排序與時間分組很容易出錯。

三. 報表前先建立資料契約
#

不要急著 groupby()。 一份可信的報表,第一步是定義「哪些資料可以進來」。

required_columns = {
    "order_id",
    "ordered_at",
    "region",
    "channel",
    "product",
    "quantity",
    "unit_price",
    "status",
}

missing = required_columns - set(orders.columns)
if missing:
    raise ValueError(f"缺少必要欄位:{sorted(missing)}")

接著統一型別,計算衍生欄位:

orders = orders.assign(
    ordered_at=lambda df: pd.to_datetime(df["ordered_at"], errors="raise"),
    quantity=lambda df: pd.to_numeric(df["quantity"], errors="raise"),
    unit_price=lambda df: pd.to_numeric(df["unit_price"], errors="raise"),
)

orders["revenue"] = orders["quantity"] * orders["unit_price"]
orders["month"] = orders["ordered_at"].dt.to_period("M").astype("string")

最後明確定義報表只計算已付款訂單:

paid = orders.loc[orders["status"].eq("paid")].copy()

這裡的 .copy() 不是裝飾。 它表示我們要建立一份獨立的報表輸入,後面新增欄位時不會混淆原始資料。

你也可以先做幾個便宜但有效的檢查:

if paid["order_id"].duplicated().any():
    duplicates = paid.loc[paid["order_id"].duplicated(), "order_id"].tolist()
    raise ValueError(f"訂單編號重複:{duplicates}")

if paid["quantity"].le(0).any():
    raise ValueError("quantity 必須大於 0")

if paid["unit_price"].lt(0).any():
    raise ValueError("unit_price 不可為負數")

四. GroupBy 的核心:Split、Apply、Combine
#

groupby() 可以想成三個動作:

  1. Split:依照欄位把資料切成多組;
  2. Apply:每組各自計算;
  3. Combine:把結果合併成新的 Series 或 DataFrame。

最基本的地區營收是:

region_revenue = paid.groupby("region")["revenue"].sum()
print(region_revenue)

預設結果會把分組鍵放在 index。 報表工作中,拍拍君通常更喜歡保留一般欄位:

region_revenue = (
    paid.groupby("region", as_index=False)["revenue"]
    .sum()
    .sort_values("revenue", ascending=False)
)

as_index=False 可以省掉後面的 reset_index(), 也讓輸出結構更接近 CSV 或資料庫查詢結果。

size()count()nunique() 不一樣
#

計算訂單數時,這三個方法很容易被混用:

summary = paid.groupby("region", dropna=False).agg(
    row_count=("order_id", "size"),
    non_null_order_ids=("order_id", "count"),
    unique_orders=("order_id", "nunique"),
)
  • size:每組有幾列,包含其他欄位有缺值的列;
  • count:指定欄位有幾個非缺值;
  • nunique:指定欄位有幾個不同值。

若一張訂單可能拆成多個品項列,報表的「訂單數」通常應該用 nunique, 而不是用資料列數冒充。

五. Named Aggregation:讓欄名從一開始就乾淨
#

一份地區報表通常不只需要營收總和:

region_summary = (
    paid.groupby("region", as_index=False, dropna=False)
    .agg(
        order_count=("order_id", "nunique"),
        units_sold=("quantity", "sum"),
        revenue=("revenue", "sum"),
        average_order_value=("revenue", "mean"),
        first_order_at=("ordered_at", "min"),
        last_order_at=("ordered_at", "max"),
    )
    .sort_values("revenue", ascending=False)
)

這種 輸出欄名=(來源欄位, 聚合函式) 的寫法就是 named aggregation。 它有三個實務優點:

  • 欄名穩定,不必事後猜 MultiIndex 在說什麼;
  • 每個指標的來源欄位與算法都很清楚;
  • 匯出 CSV、寫測試、串 BI 工具都比較方便。

若要加入自訂指標,可以傳入函式:

def paid_high_value_count(values: pd.Series) -> int:
    return int(values.ge(300).sum())


region_summary = paid.groupby("region", as_index=False, dropna=False).agg(
    order_count=("order_id", "nunique"),
    revenue=("revenue", "sum"),
    high_value_rows=("revenue", paid_high_value_count),
)

但如果內建的 summeanminmax 能完成工作,優先用內建聚合。 通常它比逐組執行 Python 函式更直接,也更容易維護。

六. 多欄分組與 MultiIndex 整理
#

想比較每個地區、每個通路的表現,只要傳入欄位清單:

region_channel = (
    paid.groupby(
        ["region", "channel"],
        as_index=False,
        dropna=False,
        observed=True,
    )
    .agg(
        order_count=("order_id", "nunique"),
        units_sold=("quantity", "sum"),
        revenue=("revenue", "sum"),
    )
    .sort_values(["region", "revenue"], ascending=[True, False])
)

observed=True 在分類欄位上只保留資料中真的出現過的組合, 避免自動展開大量「理論上可能、實際上不存在」的分類組合。

如果你用傳統的多函式聚合,結果可能出現多層欄名:

multi_columns = paid.groupby("region", dropna=False).agg(
    {
        "quantity": ["sum", "mean"],
        "revenue": ["sum", "mean"],
    }
)

真的需要整理時,可以明確攤平:

multi_columns.columns = [
    "_".join(part for part in column if part)
    for column in multi_columns.columns.to_flat_index()
]
multi_columns = multi_columns.reset_index()

不過對固定報表來說,named aggregation 通常更好。 攤平 MultiIndex 比較適合指標清單由設定檔動態產生的情境。

七. 缺值分組:不要讓「未知」悄悄消失
#

groupby() 的分組鍵若有缺值,預設不會把它當成一組。 這可能讓報表總額小於原始資料總額。

without_unknown = paid.groupby("region")["revenue"].sum()

with_unknown = paid.groupby("region", dropna=False)["revenue"].sum()

如果「未知地區」本身就是需要追蹤的資料品質問題,常見做法有兩種。

第一種是保留缺值組:

report = (
    paid.groupby("region", as_index=False, dropna=False)
    .agg(revenue=("revenue", "sum"))
)

第二種是在分組前填入明確標籤:

paid["region_report"] = paid["region"].fillna("未標記")

report = paid.groupby("region_report", as_index=False).agg(
    revenue=("revenue", "sum"),
)

兩種都可以,重點是不要無意識地接受預設行為。 而且填入「未標記」只適合報表呈現,不一定要回寫成原始業務資料。

八. Pivot Table:把長表轉成管理報表
#

groupby() 適合產生整齊的長表, pivot_table() 則適合把某個維度展開成欄,做出試算表式報表。

例如把地區放在列、通路放在欄:

revenue_pivot = pd.pivot_table(
    paid,
    values="revenue",
    index="region",
    columns="channel",
    aggfunc="sum",
    fill_value=0,
    margins=True,
    margins_name="總計",
    dropna=False,
    observed=True,
)

重要參數可以這樣記:

參數 用途
values 要計算的數值欄
index 報表列維度
columns 要展開的欄維度
aggfunc 重複組合出現時如何聚合
fill_value 結果缺值的顯示值
margins 是否加入總計
dropna 是否排除含缺值的分組鍵
observed 分類欄是否只保留已出現組合

pivot()pivot_table() 不完全相同。 前者只做重塑,若同一個 index/column 組合有多筆資料會報錯; 後者會用 aggfunc 聚合,因此更適合交易明細。

同時放多個指標
#

management_pivot = pd.pivot_table(
    paid,
    values=["quantity", "revenue"],
    index=["region", "product"],
    columns="channel",
    aggfunc={
        "quantity": "sum",
        "revenue": "sum",
    },
    fill_value=0,
    dropna=False,
    observed=True,
)

這時欄名自然會是 MultiIndex。 若要匯出成扁平 CSV,可以整理欄名:

management_export = management_pivot.reset_index()
management_export.columns = [
    "_".join(str(part) for part in column if str(part))
    if isinstance(column, tuple)
    else str(column)
    for column in management_export.columns.to_flat_index()
]

九. 先驗證總額,再相信漂亮表格
#

報表最危險的狀況不是程式報錯,而是程式順利跑完但數字不對。 至少加入以下一致性檢查:

source_total = paid["revenue"].sum()
report_total = region_summary["revenue"].sum()

if source_total != report_total:
    raise AssertionError(
        f"營收總額不一致:source={source_total}, report={report_total}"
    )

浮點金額可能有精度問題時,改用近似比較:

import math

if not math.isclose(source_total, report_total, rel_tol=1e-9, abs_tol=0.01):
    raise AssertionError("營收總額超出容許誤差")

再檢查關鍵規則:

assert region_summary["revenue"].ge(0).all()
assert region_summary["rank"].notna().all()
assert math.isclose(region_summary["revenue_share"].sum(), 1.0)

若報表每天執行,還可以記錄:

  • 輸入資料列數;
  • 被排除的取消訂單數;
  • 分組鍵缺值數;
  • 報表總額;
  • 產生時間與來源檔名。

這些 metadata 發生突變時,往往比人工看表更早發現問題。

十. 匯出與常見陷阱
#

適合程式再讀的 CSV 應保留原始數值:

region_summary.to_csv(
    "region-summary.csv",
    index=False,
    encoding="utf-8-sig",
)

utf-8-sig 對部分試算表軟體的中文相容性較友善。 金額與百分比不要在核心資料中提早改成顯示字串,否則後續很難再計算。

1. 分組後總額變少
#

先檢查分組鍵是否有缺值,並考慮 dropna=False

2. count() 算出的筆數不一致
#

count() 忽略指定欄位的缺值。 要算資料列用 size,要算唯一訂單用 nunique

3. CSV 出現兩三層奇怪欄名
#

固定報表優先用 named aggregation; 動態聚合則用 to_flat_index() 明確攤平。

4. Pivot Table 出現大量零值組合
#

分類欄位可使用 observed=True,只保留實際出現的組合。

5. 日期排序變成字典順序
#

在讀入時就用 parse_dates,或立刻呼叫 pd.to_datetime()

6. 報表只能看,不能再計算
#

不要在核心資料裡混入 NT$ 1,23435.2% 這類顯示字串。 原始數值與展示格式應分開保存。

結語
#

Pandas 報表真正重要的,不是記住多少種聚合函式, 而是讓「輸入、規則、輸出、驗證」都看得懂、跑得回去。

拍拍君建議你把今天的重點收成六句:

  1. 分組前先清理型別與業務狀態;
  2. 用 named aggregation 直接產生穩定欄名;
  3. 分清楚 sizecountnunique
  4. 明確決定是否保留缺值分組;
  5. groupby() 做長表,pivot_table() 做閱讀型交叉表;
  6. 每份報表都要驗證分組前後的總額。

做到這些,報表就不只是「今天看起來沒問題」, 而是一段明天、下個月、換人維護後仍然可信的程式。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 111: 本文

相關文章

Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出
·8 分鐘· loading · loading
Python Streamlit Data-Editor CSV Validation Developer-Tools
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools
Python mmap 實戰:記憶體映射、隨機存取與大型檔案搜尋
·7 分鐘· loading · loading
Python Mmap Memory-Mapped-File Filesystem Performance Standard-Library
Python unicodedata 實戰:文字正規化、搜尋與去重
·6 分鐘· loading · loading
Python Unicodedata Unicode Text-Normalization Search Standard-Library