🗺️ 程式互動式地圖
AI 線・Pandas

pandas groupby:分組、彙總、再組回來

一行 groupby 背後做了三件事:把列拆成幾組、每組各算一次、再把結果拼回一張表。這一頁把三個步驟攤開來看,弄清楚 sum、mean、size、count 各算什麼,結果什麼時候是 Series、什麼時候是 DataFrame,以及 agg、transform、filter 回傳的形狀為什麼不一樣。

拆分—套用—合併動畫 agg/transform/filter 形狀對照 印出什麼:12 張情境卡

💡 先搞懂問題

虛構的「晴空咖啡」有大安、信義、板橋三家門市,收銀系統每天匯出一張訂單表:每一列是一筆訂單,記錄門市、品項、杯數與金額。店長每天早上只想知道幾件事:昨天每家門市賣了多少錢、哪一家最好、每個品項平均一筆多少。這些問題的共同點是「依某一欄分組,再對每組算一個數字」,在試算表裡叫樞紐分析,在 SQL 裡是 GROUP BY,在 pandas 裡就是 groupby。

剛學 Python 的人常先寫迴圈:開一個字典,逐列把金額加到對應的門市底下。這樣寫能動,但有三個麻煩。第一,只要有一筆金額是缺值(NaN,Not a Number),加進去整組就變成 nan;第二,算完的字典還得自己轉回表格才能排序或畫圖;第三,想改算平均或筆數,整段迴圈又要重寫。df.groupby('store')['amount'].sum() 一行就處理掉這三件事。

真正讓人卡住的是 groupby 之後的結果:為什麼門市名稱跑到最左邊、不是一般欄位?為什麼 count() 算出來比 size() 少一筆?為什麼結果的順序和原表不一樣,head(3) 拿到的卻不是前三名?輸出預測題最愛在這些地方設陷阱,所以這一頁的重點不是背語法,而是看清楚每一步的資料長什麼樣子。

做法 A:自己寫迴圈累加 totals = {} for s, a in zip(df['store'], df['amount']): totals[s] = totals.get(s, 0) + a ① 一筆 NaN,信義整組變 nan ② 結果是字典,還要轉成表 ③ 改算平均或筆數,要重寫 做法 B:一行 groupby df.groupby('store')['amount'].sum() store (示意) 信義145.0 大安740.0 板橋660.0 ✓ NaN 自動略過,信義是 90+55 ✓ 結果是 Series,門市變成索引 ✓ 換成 mean()、count() 只改最後一段
上半是逐列累加的迴圈:信義那筆漏登的金額會讓總和變成 nan,算完還得自己轉表格。下半是 groupby 的寫法,右邊白框是它的結果:門市名稱變成左邊的索引(斜體的 store 是索引名稱),順序也不是原表的出現順序。後面每個實驗室都在拆解這張小表是怎麼來的。

生活比喻:月底把收據分堆對帳

晴空咖啡的會計林小姐每天收到三家門市送來的一疊收據。她的做法很固定:先依收據上的門市名稱分成三堆,再拿計算機一堆一堆加總,最後在一張「各門市營收」表上寫下結果,門市名稱寫在最左邊一欄。今天有一張信義店的收據被咖啡潑到,金額糊掉看不清楚。加總時她只能跳過這張;可是老闆問「信義今天開了幾張收據」,答案應該是三張還是兩張?這取決於你問的是「收據有幾張」,還是「看得清楚金額的收據有幾張」。

回到程式:依門市分堆就是 split(拆分),也就是 groupby('store');每堆按計算機是 apply(套用),也就是後面接的 sum()、mean();寫成一張表是 combine(合併),pandas 官方文件把這三步合稱 split-apply-combine。表上最左邊的門市名稱,就是結果的索引(index),它的名稱是 store。糊掉的金額是 NaN:sum()、mean() 會略過它,count() 也不算它;只有 size() 數的是「這一堆有幾列」,NaN 照樣算進去。
林小姐對帳(比喻) pandas 的對應 一疊收據 DataFrame 的每一列 依門市分成三堆 split:groupby('store') 每堆按計算機加總 apply:['amount'].sum() 寫成各門市營收表 combine:結果 Series 表的最左邊寫門市名 索引 index(名稱 store) 金額被咖啡潑糊的收據 NaN:sum、count 都略過 數這一堆有幾張收據 size():連 NaN 一起算
左欄是對帳流程,右欄是 pandas 的正式說法。前四列是 split-apply-combine 的三步加上原始資料;第五列提醒你結果的「門市」不是普通欄位而是索引;最後兩列是 count 與 size 的分界:一個數「有值的」,一個數「有幾列」。

這個比喻有三個地方和實際不同。第一,林小姐真的把收據搬成三堆,pandas 的 groupby() 卻不搬任何資料,它只回傳一個 DataFrameGroupBy 物件,記下「哪幾列屬於哪一組」,要等你呼叫 sum() 這類方法才真正計算。第二,林小姐可能照收到的順序寫表,pandas 預設會把組依鍵排序,中文鍵依 Unicode 碼位排,所以「信義」排在「大安」前面。第三,如果有收據沒寫門市,林小姐會打電話問,pandas 預設(dropna=True)則直接把鍵是缺值的列排除在所有組之外,不會提醒你。

🎮 互動實驗室一:拆分—套用—合併動畫

下面是晴空咖啡某天的 10 筆訂單(虛構示意),第 4 列信義店那筆拿鐵的金額漏登,是 NaN。先選分組欄、要彙總的值欄與彙總函式,也可以勾選 sort=False 或 as_index=False,再按「播放」。動畫會依序把每一列丟進它所屬的組、在每組裡算出結果,最後拼成結果表,並列出和 pandas 3 實際 print 一模一樣的輸出。

分組欄 by
值欄
彙總函式
選項
原始資料 df(10 列 × 4 欄,示意)
① 拆分 split
② 套用 apply
③ 合併 combine
按「播放」或「一步一步」開始。
畫面說明:左邊是原始資料,右邊三格是等一下要分出來的組。

🎮 互動實驗室二:agg、transform、filter 形狀對照

同樣是依門市分組,接上三種不同的方法,輸出的列數完全不同。切換方法與函式,看左邊 10 列輸入變成右邊幾列輸出、索引是什麼。選到 filter 時,拉動門檻滑桿,看哪幾家門市整組被留下。每次切換,下方都會列出 pandas 實際 print 的結果。

方法
函式
輸入 df:10 列
→
print 的結果(pandas 3 實際輸出)

  
畫面說明:

🎮 互動實驗室三:「印出什麼」情境卡

12 張卡都用實驗室一的同一份 df(10 筆訂單,第 4 列 amount 是 NaN)。讀程式、選出 print 的結果,答完會說明正解怎麼來,選錯也會說明你選的那個答案其實是哪一種寫法的輸出。所有答案都是在 pandas 3 實際執行過的結果。

得分 0 / 0
連續答對 0
畫面說明:先找出程式最後一步回傳的是 Series 還是 DataFrame,再追蹤順序與缺值。

📘 原理補完

1. groupby 物件:先分好組,還沒算

df.groupby('store') 回傳的是 DataFrameGroupBy 物件,裡面只記錄「每個鍵對應哪幾列」,印出來看不到任何數字。後面接上選欄(['amount'] 變成 SeriesGroupBy)和彙總方法,才完成套用與合併。官方文件把這個流程叫 split-apply-combine:依鍵拆成多組、每組獨立套用一個函式、再把各組結果合併成新的物件。拆分的依據可以是一個欄名、多個欄名組成的串列、一個等長的 Series,甚至是索引層級(level=)。

原表 df(示意)① 拆分 → ② 套用 sum③ 合併 0 大安 240 1 信義 90 2 大安 270 3 板橋 120 4 信義 NaN 5 大安 110 6 板橋 180 7 信義 55 8 大安 120 9 板橋 360 信義(列 1、4、7) 90 NaN 55 sum → 145.0 大安(列 0、2、5、8) 240 270 110 120 sum → 740.0 板橋(列 3、6、9) 120 180 360 sum → 660.0 store 信義145.0 大安740.0 板橋660.0 Name: amountdtype: float64 組的順序是信義、大安、板橋:預設 sort=True,依鍵的 Unicode 碼位排序
左邊 10 列依門市上色,細線是「這一列屬於哪一組」,只是記下列位置,並沒有複製資料。中間每組各自加總,信義那格的 NaN 被略過;右邊合併時,鍵成為結果的索引,值欄名稱成為 Series 的 Name。dtype 是 float64,因為 amount 欄本來就因為有 NaN 而是浮點數。

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()每組非缺值的個數不算02
sum()加總略過0.0(min_count=0)145.0
mean()平均,分母是非缺值個數略過NaN72.5
max()/min()最大/最小略過NaN90.0/55.0
nunique()不重複值有幾種預設不算02
first()每組第一個非缺值略過(skipna=True)NaN90.0
信義這一組(示意) 列 item qty amount 1 美式 1 90.0 4 拿鐵 2 NaN 7 可頌 1 55.0 紅底那一列:列還在,只是 amount 沒有值 size()3數列數,NaN 也算 amount count()2只數有值的 qty count()3qty 沒有缺值 amount sum()145.090+55 amount mean()72.5145÷2,不是÷3
左邊是信義那一組的三列,紅底那列的 amount 是 NaN。右邊五個結果裡,size 和 qty 的 count 都是 3,只有 amount 的 count 少一筆;mean 的分母跟著 count 走,所以是 145 除以 2。題目給 size 和 count 兩個選項時,先看那一欄有沒有缺值。

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 的新欄位,而不是原本的值欄名。

['amount'].sum()as_index=False[['amount']].sum() store 信義145.0 大安740.0 板橋660.0 Name: amount storeamount 0信義145.0 1大安740.0 2板橋660.0 storeamount 信義145.0 大安740.0 板橋660.0 Series shape (3,) DataFrame (3, 2) DataFrame (3, 1) 左邊接 .reset_index() 就變成中間那張;淺色底的一欄是索引,不是一般欄位
三張表的數字完全一樣,差別只在門市放在哪裡。左邊與右邊的門市都在淺色底的索引裡,要用 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)。

sort=False:第一次出現 大安 信義 板橋 原表第 0、1、3 列各自第一次出現 預設 sort=True:依碼位 信義 大安 板橋 U+4FE1U+5927U+677F s = df.groupby('store')['amount'].sum() 之後: s.head(2) 信義 145.0、大安 740.0 只是鍵排序後的前兩個 s.nlargest(2) 大安 740.0、板橋 660.0 依值由大到小,真正的前兩名
上排是同一份資料兩種鍵的順序:左邊照出現先後,右邊照 Unicode 碼位。下排紅框是最常見的錯誤,以為 head(2) 是前兩名;綠框才是依總額取前兩名。DataFrame 版的 nlargest 必須指定欄名,例如 nlargest(2, 'amount'),少了欄名會出現 TypeError。

5. 多欄分組與 MultiIndex

鍵給一個串列,例如 groupby(['store', 'item']),會依兩欄的組合分組,結果的索引變成兩層的 MultiIndex(多層索引)。只有實際出現過的組合才會成為一組:三家門市乘三種品項有 9 種組合,但板橋那天沒賣可頌,所以只有 8 組,shape 是 (8,)。取值要用 tuple,例如 loc[('大安', '拿鐵')];想把內層索引攤成欄位,用 unstack(),沒出現的組合會補 NaN,整數也因此變成浮點數。

groupby(['store','item'])['qty'].sum() storeitem 信義可頌1 拿鐵2 美式1 大安可頌2 拿鐵3 美式3 板橋拿鐵4 美式2 unstack() item可頌拿鐵美式 信義1.02.01.0 大安2.03.03.0 板橋NaN4.02.0 Series (8,) → DataFrame (3, 3) 板橋沒賣可頌:補 NaN,整數變 float64
左邊是兩層索引的 Series:外層 store 只在每組第一列顯示,這也是 pandas print 的樣子。右邊是 unstack 之後的寬表,內層 item 變成欄。注意左邊只有 8 列,因為 groupby 只為實際出現過的組合建組;寬表才把缺的那一格補成 NaN。

6. agg:多欄、多函式與具名彙總

agg(全名 aggregate)讓你一次算好幾個指標。傳函式名稱串列,例如 agg(['sum', 'max']),如果同時選了兩欄,欄位會變成兩層(值欄 × 函式),shape 是 3 列 4 欄;傳字典 {'amount': 'sum', 'qty': 'max'} 可以每欄指定不同函式。實務上最推薦的是 具名彙總(named aggregation):agg(總額=('amount', 'sum'), 筆數=('amount', 'count')),等號左邊直接成為欄名,欄位只有一層,後續好處理得多。

g[['qty','amount']].agg(['sum','max']) qty amount summaxsummax 信義42145.090.0 大安83740.0270.0 板橋63660.0360.0 g.agg(總額=('amount','sum'), 筆數=('amount','count'), 杯數=('qty','sum')) 總額筆數杯數 信義145.024 大安740.048 板橋660.036
上面的表欄位有兩層,要取「amount 的 sum」得寫 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。

agg('sum'):3 列transform('sum'):10 列filter(總和≥500):7 列 信義 145.0 大安 740.0 板橋 660.0 索引=鍵,每組一列 0 740.0 1 145.0 2 740.0 3 660.0 4 145.0 5 740.0 6 660.0 7 145.0 8 740.0 9 660.0 0 大安 240.0 2 大安 270.0 3 板橋 120.0 5 大安 110.0 6 板橋 180.0 8 大安 120.0 9 板橋 360.0 保留原 index:0 2 3 5 6 8 9 信義總和 145,整組丟掉
三欄的輸入都是同一張 10 列的表。左邊 agg 每組一列;中間 transform 每列拿到自己那一組的總額,顏色和原表一一對應,所以能直接當新欄;右邊 filter 只決定整組的去留,留下的列保有原本的索引編號。

8. 判斷步驟:看到一行 groupby 先問這五件事

  1. 鍵是什麼:一欄還是多欄?多欄就是 MultiIndex,組數只算實際出現的組合。鍵有缺值時,預設 dropna=True 會把那些列排除。
  2. 對哪一欄算:單層中括號得 Series,雙層中括號或不選欄得 DataFrame;不選欄時 count() 會對每一欄各算一次。
  3. 用什麼函式:size 數列、count 數非缺值、sum/mean/max 略過 NaN;全是 NaN 的組 sum 是 0.0。
  4. 輸出幾列:agg 系列每組一列,transform 和原表等長,filter 是原表的子集。
  5. 順序與鍵的位置:預設依鍵排序、鍵在索引;sort=False 依出現順序,as_index=False 或 reset_index() 讓鍵回到欄位。要排名另外接 nlargest 或 sort_values。
我要的結果長什麼樣? 每組濃縮成一列 和原表一樣長 整組留下或丟掉 agg/sum/mean transform filter 要排名再接nlargest(n) 占比、組內補缺值可直接指派成新欄 回傳原表的子集保留原本的 index 接著要 merge 或畫圖? as_index=False/reset_index() 要數「幾筆」? 列數用 size,有值的用 count
上半是選方法:先決定輸出要幾列,再挑 agg、transform 或 filter。下半是兩個常接在後面的決定:鍵要不要回到欄位,以及「筆數」到底要不要算缺值。程式閱讀題也可以倒過來用這張圖:看輸出列數,就知道程式用了哪一種方法。

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]})

🎯 重點整理

  1. groupby 是 split-apply-combine:依鍵拆組、每組套用函式、合併成結果;groupby 物件本身還沒計算。
  2. size 數列數(含 NaN),count 數非缺值;sum、mean、max 略過 NaN,全是 NaN 的組 sum 得 0.0。
  3. 單層中括號得 Series、雙層得 DataFrame;預設鍵在索引,as_index=False 或 reset_index() 讓鍵回到欄位。
  4. 結果預設依鍵排序(中文依 Unicode),head 不是排名;排名用 nlargest 或 sort_values(ascending=False)。
  5. 多欄分組產生 MultiIndex,只有出現過的組合;unstack 攤成寬表時缺的組合補 NaN。
  6. agg 每組一列,transform 和原表等長,filter 回傳原表子集;具名彙總讓欄位只有一層。