一. 前言:資料很多,不代表報表就有答案 #
拍拍君每次拿到銷售明細,最常看到的問題不是「怎麼讀 CSV」, 而是讀完之後,大家開始手動複製、貼上、加總,再做一張新的 Excel。
一開始只有幾十筆時還能撐,到了幾萬筆就會出現熟悉的災情:
- 同一份資料,每個人算出的營收不一樣;
- 空白地區在彙總時悄悄消失;
sum()、count()、size()混著用,筆數越算越少;- 多層欄名匯出 CSV 後,沒人知道怎麼讀;
- Pivot Table 看起來漂亮,卻無法追查原始計算。
這篇不做泛泛的 Pandas 入門。 我們直接從一份「逐筆訂單」開始,建立一條可以重跑、檢查、匯出的報表流程:
- 清理欄位與型別;
- 用
groupby()做單層與多層聚合; - 用 named aggregation 產生穩定欄名;
- 正確處理缺值、分類欄與日期;
- 用
pivot_table()做管理報表; - 加上總計、排序、格式化與驗證;
- 匯出適合人看,也適合程式再讀的檔案。
如果你的資料已經大到單機記憶體放不下,可以改看 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() 可以想成三個動作:
- Split:依照欄位把資料切成多組;
- Apply:每組各自計算;
- 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),
)
但如果內建的 sum、mean、min、max 能完成工作,優先用內建聚合。
通常它比逐組執行 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,234、35.2% 這類顯示字串。
原始數值與展示格式應分開保存。
結語 #
Pandas 報表真正重要的,不是記住多少種聚合函式, 而是讓「輸入、規則、輸出、驗證」都看得懂、跑得回去。
拍拍君建議你把今天的重點收成六句:
- 分組前先清理型別與業務狀態;
- 用 named aggregation 直接產生穩定欄名;
- 分清楚
size、count、nunique; - 明確決定是否保留缺值分組;
groupby()做長表,pivot_table()做閱讀型交叉表;- 每份報表都要驗證分組前後的總額。
做到這些,報表就不只是「今天看起來沒問題」, 而是一段明天、下個月、換人維護後仍然可信的程式。