💡 先搞懂問題
虛構的「晴空咖啡」有大安、信義、板橋三家門市,收銀系統每天匯出一張訂單表:每一列是一筆訂單,記錄門市、品項、杯數與金額。店長每天早上只想知道幾件事:昨天每家門市賣了多少錢、哪一家最好、每個品項平均一筆多少。這些問題的共同點是「依某一欄分組,再對每組算一個數字」,在試算表裡叫樞紐分析,在 SQL 裡是 GROUP BY,在 pandas 裡就是 groupby。
剛學 Python 的人常先寫迴圈:開一個字典,逐列把金額加到對應的門市底下。這樣寫能動,但有三個麻煩。第一,只要有一筆金額是缺值(NaN,Not a Number),加進去整組就變成 nan;第二,算完的字典還得自己轉回表格才能排序或畫圖;第三,想改算平均或筆數,整段迴圈又要重寫。df.groupby('store')['amount'].sum() 一行就處理掉這三件事。
真正讓人卡住的是 groupby 之後的結果:為什麼門市名稱跑到最左邊、不是一般欄位?為什麼 count() 算出來比 size() 少一筆?為什麼結果的順序和原表不一樣,head(3) 拿到的卻不是前三名?輸出預測題最愛在這些地方設陷阱,所以這一頁的重點不是背語法,而是看清楚每一步的資料長什麼樣子。
生活比喻:月底把收據分堆對帳
晴空咖啡的會計林小姐每天收到三家門市送來的一疊收據。她的做法很固定:先依收據上的門市名稱分成三堆,再拿計算機一堆一堆加總,最後在一張「各門市營收」表上寫下結果,門市名稱寫在最左邊一欄。今天有一張信義店的收據被咖啡潑到,金額糊掉看不清楚。加總時她只能跳過這張;可是老闆問「信義今天開了幾張收據」,答案應該是三張還是兩張?這取決於你問的是「收據有幾張」,還是「看得清楚金額的收據有幾張」。
groupby('store');每堆按計算機是 apply(套用),也就是後面接的 sum()、mean();寫成一張表是 combine(合併),pandas 官方文件把這三步合稱 split-apply-combine。表上最左邊的門市名稱,就是結果的索引(index),它的名稱是 store。糊掉的金額是 NaN:sum()、mean() 會略過它,count() 也不算它;只有 size() 數的是「這一堆有幾列」,NaN 照樣算進去。
這個比喻有三個地方和實際不同。第一,林小姐真的把收據搬成三堆,pandas 的 groupby() 卻不搬任何資料,它只回傳一個 DataFrameGroupBy 物件,記下「哪幾列屬於哪一組」,要等你呼叫 sum() 這類方法才真正計算。第二,林小姐可能照收到的順序寫表,pandas 預設會把組依鍵排序,中文鍵依 Unicode 碼位排,所以「信義」排在「大安」前面。第三,如果有收據沒寫門市,林小姐會打電話問,pandas 預設(dropna=True)則直接把鍵是缺值的列排除在所有組之外,不會提醒你。
🎮 互動實驗室一:拆分—套用—合併動畫
下面是晴空咖啡某天的 10 筆訂單(虛構示意),第 4 列信義店那筆拿鐵的金額漏登,是 NaN。先選分組欄、要彙總的值欄與彙總函式,也可以勾選 sort=False 或 as_index=False,再按「播放」。動畫會依序把每一列丟進它所屬的組、在每組裡算出結果,最後拼成結果表,並列出和 pandas 3 實際 print 一模一樣的輸出。
🎮 互動實驗室二:agg、transform、filter 形狀對照
同樣是依門市分組,接上三種不同的方法,輸出的列數完全不同。切換方法與函式,看左邊 10 列輸入變成右邊幾列輸出、索引是什麼。選到 filter 時,拉動門檻滑桿,看哪幾家門市整組被留下。每次切換,下方都會列出 pandas 實際 print 的結果。
🎮 互動實驗室三:「印出什麼」情境卡
12 張卡都用實驗室一的同一份 df(10 筆訂單,第 4 列 amount 是 NaN)。讀程式、選出 print 的結果,答完會說明正解怎麼來,選錯也會說明你選的那個答案其實是哪一種寫法的輸出。所有答案都是在 pandas 3 實際執行過的結果。
📘 原理補完
1. groupby 物件:先分好組,還沒算
df.groupby('store') 回傳的是 DataFrameGroupBy 物件,裡面只記錄「每個鍵對應哪幾列」,印出來看不到任何數字。後面接上選欄(['amount'] 變成 SeriesGroupBy)和彙總方法,才完成套用與合併。官方文件把這個流程叫 split-apply-combine:依鍵拆成多組、每組獨立套用一個函式、再把各組結果合併成新的物件。拆分的依據可以是一個欄名、多個欄名組成的串列、一個等長的 Series,甚至是索引層級(level=)。
2. 彙總函式:誰會略過 NaN、全是 NaN 時得到什麼
同一組資料,換不同的彙總函式,答案差在「怎麼對待缺值」。下表右欄以實驗室裡的信義店為例(amount 為 90、NaN、55);「全是 NaN 的組」一欄以信義店的拿鐵為例,那一組只有一列、金額正好是 NaN。最容易忽略的是 sum():全是缺值時它回傳 0.0 而不是 NaN,因為預設 min_count=0,想讓它回傳 NaN 要寫 sum(min_count=1)。
| 寫法 | 算什麼 | 遇到 NaN | 全是 NaN 的組 | 信義店 amount |
|---|---|---|---|---|
| size() | 每組有幾列 | 照算 | 列數(這裡是 1) | 3 |
| count() | 每組非缺值的個數 | 不算 | 0 | 2 |
| sum() | 加總 | 略過 | 0.0(min_count=0) | 145.0 |
| mean() | 平均,分母是非缺值個數 | 略過 | NaN | 72.5 |
| max()/min() | 最大/最小 | 略過 | NaN | 90.0/55.0 |
| nunique() | 不重複值有幾種 | 預設不算 | 0 | 2 |
| first() | 每組第一個非缺值 | 略過(skipna=True) | NaN | 90.0 |
3. 結果是 Series 還是 DataFrame
判斷規則只有兩條。第一,選了「一個欄名」(單層中括號 ['amount'])再彙總,得到 Series;選了「欄名串列」(雙層中括號 [['amount']])或沒選欄直接彙總,得到 DataFrame。第二,as_index=True(預設)時鍵變成結果的索引;as_index=False 時鍵留在一般欄位、索引改成 0、1、2,結果一定是 DataFrame。已經算好的 Series 想把索引變回欄位,接 reset_index() 即可,效果和 as_index=False 相同。
還有兩個例外要記。不選欄直接 df.groupby('store').size() 會得到沒有名稱的 Series;as_index=False 搭配 size() 時,數字放在一個叫 size 的新欄位,而不是原本的值欄名。
loc['大安'] 取;中間的門市是一般欄位,可以直接拿去 merge 或當 seaborn 的 x 軸。題目問 shape 時,Series 只有一個數字 (3,),這是最常見的干擾點。4. 排序:預設依鍵,不是依值
groupby 的 sort 參數預設為 True,結果依鍵排序;文字鍵的排序依 Unicode 碼位,「信」是 U+4FE1、「大」是 U+5927、「板」是 U+677F,所以順序是信義、大安、板橋,和筆畫、注音都無關。sort=False 則依鍵第一次出現的順序。如果鍵是類別型(category),順序跟著類別定義走。不管哪一種,這都是「鍵的順序」,不是名次。要排名,要先彙總,再用 nlargest(n),或 sort_values(ascending=False).head(n)。
nlargest(2, 'amount'),少了欄名會出現 TypeError。5. 多欄分組與 MultiIndex
鍵給一個串列,例如 groupby(['store', 'item']),會依兩欄的組合分組,結果的索引變成兩層的 MultiIndex(多層索引)。只有實際出現過的組合才會成為一組:三家門市乘三種品項有 9 種組合,但板橋那天沒賣可頌,所以只有 8 組,shape 是 (8,)。取值要用 tuple,例如 loc[('大安', '拿鐵')];想把內層索引攤成欄位,用 unstack(),沒出現的組合會補 NaN,整數也因此變成浮點數。
6. agg:多欄、多函式與具名彙總
agg(全名 aggregate)讓你一次算好幾個指標。傳函式名稱串列,例如 agg(['sum', 'max']),如果同時選了兩欄,欄位會變成兩層(值欄 × 函式),shape 是 3 列 4 欄;傳字典 {'amount': 'sum', 'qty': 'max'} 可以每欄指定不同函式。實務上最推薦的是 具名彙總(named aggregation):agg(總額=('amount', 'sum'), 筆數=('amount', 'count')),等號左邊直接成為欄名,欄位只有一層,後續好處理得多。
r[('amount', 'sum')],shape 是 (3, 4)。下面的具名彙總只有一層,欄名就是等號左邊的名字,shape 是 (3, 3)。兩張表都以 store 為索引。7. transform 與 filter:形狀和 agg 不一樣
agg 每組濃縮成一列。transform 則把每組的結果「廣播」回該組的每一列,輸出和原表等長、索引也和原表相同,所以能直接指派成新欄,最常見的用途是算組內占比(df['amount'] / transform('sum'))和用組內平均補缺值。filter 對每組問一個 True/False 的問題,答 True 的整組原封不動留下,保留原本的索引,列數介於 0 和原表之間。相對地,把 agg 的結果直接指派成新欄(df['x'] = df.groupby('store')['amount'].sum())會整欄都是 NaN,因為結果的索引是門市名稱,對不上原表的 0 到 9。
8. 判斷步驟:看到一行 groupby 先問這五件事
- 鍵是什麼:一欄還是多欄?多欄就是 MultiIndex,組數只算實際出現的組合。鍵有缺值時,預設
dropna=True會把那些列排除。 - 對哪一欄算:單層中括號得 Series,雙層中括號或不選欄得 DataFrame;不選欄時
count()會對每一欄各算一次。 - 用什麼函式:size 數列、count 數非缺值、sum/mean/max 略過 NaN;全是 NaN 的組 sum 是 0.0。
- 輸出幾列:agg 系列每組一列,transform 和原表等長,filter 是原表的子集。
- 順序與鍵的位置:預設依鍵排序、鍵在索引;
sort=False依出現順序,as_index=False或reset_index()讓鍵回到欄位。要排名另外接 nlargest 或 sort_values。
9. 兩段帶註解的完整範例
第一段是日常報表:看清楚 groupby 物件、count 與 size 的差異、具名彙總,以及 reset_index 把索引變回欄位。註解裡的數字都是實際執行的輸出。
import pandas as pd
import numpy as np
df = pd.DataFrame({
'store': ['大安', '信義', '大安', '板橋', '信義', '大安', '板橋', '信義', '大安', '板橋'],
'item': ['拿鐵', '美式', '美式', '拿鐵', '拿鐵', '可頌', '美式', '可頌', '拿鐵', '拿鐵'],
'qty': [2, 1, 3, 1, 2, 2, 2, 1, 1, 3],
'amount': [240, 90, 270, 120, np.nan, 110, 180, 55, 120, 360],
})
g = df.groupby('store') # 只是「分好組」的物件,還沒計算
print(type(g).__name__, g.ngroups) # DataFrameGroupBy 3
total = g['amount'].sum() # Series:index 是 store,依鍵排序
print(total.to_dict()) # {'信義': 145.0, '大安': 740.0, '板橋': 660.0}
print(g['amount'].count().tolist()) # [2, 4, 3]:NaN 不算
print(g.size().tolist()) # [3, 4, 3]:算列數,NaN 也算
report = g.agg(總額=('amount', 'sum'), # 具名彙總:欄名=等號左邊
筆數=('amount', 'count'),
杯數=('qty', 'sum'))
print(report.columns.tolist()) # ['總額', '筆數', '杯數']
flat = total.reset_index() # index 變回一般欄位
print(flat.columns.tolist(), flat.shape) # ['store', 'amount'] (3, 2)
第二段是 transform、filter 與排名的實務組合:算每筆訂單占門市營收的比例、用門市自己的平均補缺值、只留下營收夠大的門市,最後取前兩名。
import pandas as pd
import numpy as np
df = pd.DataFrame({
'store': ['大安', '信義', '大安', '板橋', '信義', '大安', '板橋', '信義', '大安', '板橋'],
'amount': [240, 90, 270, 120, np.nan, 110, 180, 55, 120, 360],
})
# transform:每一列拿到「自己那一組」的結果,長度和原表一樣
df['店總額'] = df.groupby('store')['amount'].transform('sum')
df['占比'] = (df['amount'] / df['店總額']).round(3)
print(df.loc[0, ['店總額', '占比']].tolist()) # [740.0, 0.324]
# 組內平均補缺值:信義的 NaN 補成信義自己的平均 72.5
fill = df.groupby('store')['amount'].transform('mean')
df['amount'] = df['amount'].fillna(fill) # pandas 3:用賦值,不用 inplace
print(df.loc[4, 'amount']) # 72.5
# filter:整組留下或整組丟掉,留下的列保持原本的 index
big = df.groupby('store').filter(lambda g: g['amount'].sum() >= 500)
print(big.index.tolist()) # [0, 2, 3, 5, 6, 8, 9]
# 排名:先彙總,再用 nlargest;head 只是依鍵排序後的前幾個
s = df.groupby('store')['amount'].sum()
print(s.head(2).index.tolist()) # ['信義', '大安']
print(s.nlargest(2).index.tolist()) # ['大安', '板橋']
10. 容易寫錯或考錯的地方
head 不是排名:groupby 結果依鍵排序,head(3) 只是「鍵排在前面的三組」,tail(3) 也一樣。選項裡出現 sort_values().head(3) 也要小心,sort_values 預設是升冪,拿到的是最小的三個。
size 與 count:題目刻意放一個缺值,就是要你分辨這兩個。count() 對 DataFrame 使用時,每一欄各自計數,所以同一組的各欄數字可能不同。
Series 與 DataFrame 的方法簽名不同:Series.nlargest(n) 只要一個數字;DataFrame.nlargest(n, columns) 一定要指定欄名,漏掉會丟出 TypeError。as_index=False 讓結果變成 DataFrame,後面的 nlargest 就得改寫。
shape 的寫法:Series 的 shape 是 (3,),只有列數;DataFrame 是 (3, 2)。干擾選項常把 Series 寫成 (3, 1)。
mean 的分母:mean 只除以非缺值的個數,所以信義是 145 ÷ 2 = 72.5,不是 145 ÷ 3。把 NaN 當 0 的選項(48.33)就是故意設計的干擾。
全是 NaN 的組:sum 回傳 0.0,mean、max 回傳 NaN。報表上的 0 可能其實是「沒有資料」,需要區分時寫 sum(min_count=1)。
鍵是缺值的列會消失:預設 dropna=True,鍵是 NaN 的列不屬於任何組,加總後總數會比原表少;要保留就寫 dropna=False,NaN 會成為獨立的一組。
agg 結果不能直接當新欄:agg 的索引是鍵,指派回原表會對不上而全部變 NaN;要每列一個值請用 transform,或先 agg 再 merge 回去。
版本差異:pandas 3.0(2026 年 1 月發布)起,文字欄預設型別是 str,所以結果的索引 dtype 也顯示為 str;類別型鍵的 observed 預設改為 True,只列出實際出現的類別;Copy-on-Write 成為預設,df['amount'].fillna(..., inplace=True) 不會改到 df,要寫成 df['amount'] = df['amount'].fillna(...)。舊教材裡看到 object 型別或 observed=False 的結果,要用新版的規則重新判讀。
✅ 自我檢測
6 題原創的程式閱讀題,題目都使用下面這份虛構的「青松物流」出貨表,每一題都從這份原始資料開始,所有輸出都以 pandas 3 實際執行確認過。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6
import pandas as pd
ship = pd.DataFrame({'region': ['北', '南', '北', '中', '南', '北'],
'type': ['冷藏', '常溫', '常溫', '冷藏', '冷藏', '冷藏'],
'kg': [12, 30, None, 8, 25, 20]})
🎯 重點整理
- groupby 是 split-apply-combine:依鍵拆組、每組套用函式、合併成結果;groupby 物件本身還沒計算。
- size 數列數(含 NaN),count 數非缺值;sum、mean、max 略過 NaN,全是 NaN 的組 sum 得 0.0。
- 單層中括號得 Series、雙層得 DataFrame;預設鍵在索引,as_index=False 或 reset_index() 讓鍵回到欄位。
- 結果預設依鍵排序(中文依 Unicode),head 不是排名;排名用 nlargest 或 sort_values(ascending=False)。
- 多欄分組產生 MultiIndex,只有出現過的組合;unstack 攤成寬表時缺的組合補 NaN。
- agg 每組一列,transform 和原表等長,filter 回傳原表子集;具名彙總讓欄位只有一層。