iPAS AI 奈米級拆解程式題解說・中級科目二 115-01
Question 45 · pandas / 分組彙總

C06 單筆最大、C04 買最多次:
「總貢獻」前 3 名到底是誰?

這題沒有附圖,四個選項本身就是四行 pandas 程式。它們長得很像,卻在回答四個不同的問題:哪 3 張收據金額最大、每人單筆最高是多少、每人加起來一共多少、每人來過幾次。這頁用一份自己設計的 20 張收據,把四行程式一個點、一對方括號地拆開,看每一步交出什麼新物件;再把 groupby、sum、nlargest 等 7 個方法的參數和預設值逐一攤開,最後說清楚題目說的「精確」和「高效」各指什麼。

pandas 2.2.3pandas 3.0.6 groupby()GroupBy 物件sum()nlargest() sort_values()head()max()value_counts()零基礎可讀

讀完這頁,你會知道

  1. 表格的一列就是一張收據,同一位客戶可以有好幾張;「總貢獻」是把他的收據全部加起來。四個選項分別算的是:單筆最大的收據(A)、每人單筆最高(B)、每人總額(C)、每人交易次數(D)。
  2. df.groupby('CustomerID')['Revenue'].sum().nlargest(3) 從左到右做出 4 個新物件:DataFrameGroupBy → SeriesGroupBy → Series → Series。點和方括號各在做什麼,為什麼 groupby 那一步還沒有加總。
  3. groupby 預設 sort=True 會照客戶編號排籃子,所以選項 B 的 head(3) 永遠是 C01、C02、C03;sort_values、head、groupby、sum、max、nlargest、value_counts 每個參數的意思和預設值(兩個 pandas 版本都實測)。
  4. 「精確」:只有 C 回答了題目真正問的問題。「高效」:nlargest 先找門檻、只排過門檻的幾個,一百萬筆資料實測比整串排序快約 3~4 倍;但只有 6 個數字時反而比較慢,這點也會實測給你看。
§0

題目

請依照電商交易紀錄情境作答: df 欄位包含 OrderID、CustomerID、Category、Revenue;customers 表包含 CustomerID 與 Region。 若分析師想找出「總貢獻營收最高的前 3 名客戶」,下列 Pandas 程式碼何者最為精確且高效?

原卷沒有附圖,四個選項就是程式。先憑直覺選一個,讀完再回來對照:

先抓題目的四個關鍵字

① 總貢獻營收:同一位客戶所有交易的 Revenue 加總。不是他「最大的那一筆」,也不是他「買了幾次」。② 前 3 名:照總額由大到小,取最大的 3 個;不是表格裡排在最前面的 3 列。③ 精確:程式算出來的,必須真的是題目問的那件事。④ 高效:不做多餘的工作;只要前 3 名,就不必把每個人都排好名次。

另外注意:題目提到的 customers 表(CustomerID 與 Region)這題用不到,它是第 47 題「合併表格」要用的;第 46、47 題和這題共用同一個電商情境。

關於本頁用的資料與版本

考卷沒有附資料。本頁自己設計了一份教學用資料 orders.csv:20 筆交易(20 張收據)、6 位客戶 C01~C06,Revenue 都是整數,全部加起來 5985 元。資料是刻意設計的,讓四個選項算出四種不同的答案:C03 買了 5 次中等金額(總額第 1 名,1550);C06 只買 1 次,但那筆 900 是全場最大;C04 買最多次(7 次),總額卻最少(385)。§6 可以下載。

本頁所有數字、輸出、警告和錯誤訊息,都是在 Python 3.13.7 上實際執行的結果,而且 pandas 2.2.3(NumPy 2.4.3)和 pandas 3.0.6(NumPy 2.5.4)兩個版本都跑過;兩版不一樣的地方會分開寫。四個選項的答案,兩版完全相同。

§1

先補 Python 基本功:讀懂這 4 個選項需要的 6 個觀念

四個選項都是「一行串到底」的寫法:從表格出發,一個點接一個點,每走一步就交出一個新東西。完全沒寫過 Python 也沒關係,下面每個觀念都直接用本題的 20 張收據舉例,中間有兩支動畫,一支用收據和籃子看懂 groupby,一支打開記憶體看選項 C 那一行生出幾個物件。

① 回顧觀念 1
工具箱與表格
→
② 一列=一張收據觀念 2
什麼叫總貢獻
→
③ 收據分籃子動畫 1
→
④ 點・方括號・串接觀念 3~6
→
⑤ 記憶體實況動畫 2

動畫 3(四個選項各算出什麼)放在 §4,和互動實驗放在一起。

觀念 1|回顧:import 搬進工具箱,read_csv 做出表格

這兩行和第 41 題完全一樣,那一頁有很詳細的拆解(工具箱裡有什麼、read_csv 在裡面怎麼做出表格、每個參數),想複習請看 第 41 題的 §1。這裡只回顧這一頁會一直用到的畫面:

記憶體=一大排置物櫃

每一格都有自己的地址(例如 0x000001A04DA17E00)。程式做出來的每樣東西(物件),都住在某一格裡。

變數=一張名牌

程式裡寫的 pd、df 只是名牌,不是東西本身,它只負責「指著」某個東西。

參照=名牌拉出去的箭頭

名牌用箭頭指向置物櫃裡的物件。沒有任何名牌指著的物件,Python 會把那一格收回來再利用。

再加上三個比喻:模組=工具箱(pd 指著 pandas 這個工具箱)、類別=設計圖(DataFrame、Series)、函式=機器(read_csv)。最重要的一句:物件一定是照某張設計圖做出來的。read_csv 是機器,它讀完檔案,在裡面照 DataFrame 設計圖做出一張表格交回來,最後等號才把名牌 df 貼上去。

import pandas as pd                 # 工具箱搬進記憶體,貼上名牌 pd
df = pd.read_csv('orders.csv')      # 機器讀檔 → 照 DataFrame 設計圖做出表格 → 貼上名牌 df
執行後,df 身上有什麼(實測): df.shape → (20, 4) ← 20 列(20 張收據)、4 欄 df.columns → Index(['OrderID', 'CustomerID', 'Category', 'Revenue'], dtype='object') (pandas 3.0.6 是 dtype='str') df.index → RangeIndex(start=0, stop=20, step=1) ← 列標籤 0~19,read_csv 自動編號 type(df) → pandas 3.0.6:<class 'pandas.DataFrame'> pandas 2.2.3:<class 'pandas.core.frame.DataFrame'>

df.dtypes(pandas 2.2.3)

OrderID       object
CustomerID    object
Category      object
Revenue        int64
dtype: object

df.dtypes(pandas 3.0.6)

OrderID         str
CustomerID      str
Category        str
Revenue       int64
dtype: object

三個文字欄在 pandas 2 是 object、pandas 3 是 str;Revenue 兩版都是 int64(64 位元的整數),所以後面加總出來的也是整數。

觀念 2|一列=一張收據:同一位客戶有好幾張,「總貢獻」就是全部加起來

print(df) 的完整輸出(兩版相同)。把每一列想成一張收據:哪一筆訂單(OrderID)、誰買的(CustomerID)、買什麼類別(Category)、多少錢(Revenue)。最左邊的 0~19 是列標籤(index),不是 CSV 裡的資料,是 read_csv 自動編的號碼。

   OrderID CustomerID     Category  Revenue
0     O001        C03  Electronics      350
1     O002        C04        Books       60
2     O003        C01  Electronics      560
3     O004        C04        Books       55
4     O005        C02  Electronics      680
5     O006        C03         Home      320
6     O007        C04     Clothing       50
7     O008        C05  Electronics      700
8     O009        C03     Clothing      310
9     O010        C04        Books       45
10    O011        C06  Electronics      900
11    O012        C02        Books       90
12    O013        C04         Home       40
13    O014        C03         Home      290
14    O015        C01         Home      540
15    O016        C04     Clothing       70
16    O017        C05     Clothing      520
17    O018        C03        Books      280
18    O019        C02     Clothing       60
19    O020        C04        Books       65

收據是照下單時間排的,同一位客戶的收據散在不同列。把它們依客戶收在一起:

客戶他的收據(金額)張數加起來(總貢獻)
C01560、54021100
C02680、90、603830
C03350、320、310、290、28051550
C0460、55、50、45、40、70、657385
C05700、52021220
C069001900

「總貢獻營收最高的前 3 名」=最右欄最大的 3 個:C03(1550)、C05(1220)、C01(1100)。注意三個反直覺的地方:全場最大的一張收據(900)是 C06 的,但他只有這一張,總額只排第 4;C04 來了 7 次最多,總額卻是最少的 385;C03 沒有任何一張超過 350,靠著 5 張加起來拿第 1。

同樣叫「前 3 名」,可以是四種完全不同的問題

問法要怎麼算本頁資料的答案對應選項
金額最大的 3 張收據,是誰的?20 張收據照金額排隊,拿前 3 張C06(900)、C05(700)、C02(680)A
每位客戶單筆最高多少?(再拿前 3 列)每人只留最大的一張C01 560、C02 680、C03 350B
總貢獻最高的 3 位客戶?每人全部加總,取最大的 3 個C03 1550、C05 1220、C01 1100C
交易次數最多的 3 位客戶?每人數有幾張C04 7 次、C03 5 次、C02 3 次D

題目問的是第三種。下面的動畫 1 用收據和籃子,把「加總」這條路從頭走一次。

看動畫 1 之前:這支要回答什麼

觀念 2 說到:同一位客戶有好幾列,「總貢獻」要把這幾列加起來。動畫 1 把 20 列交易變成 20 張收據,照選項 C 的四個動作一步一步做:分籃子(groupby)、只看金額(['Revenue'])、每籃加總(sum)、挑最大的 3 個(nlargest)。

帶著三個問題看:① groupby 做完的那一刻,有沒有算出任何數字?② C03 的 1550 是哪幾張收據加起來的?③ C06 那一筆 900 是全場最大,為什麼他不在前 3 名?

看的時候注意顏色:每位客戶一個顏色,收據、籃子、結果的顏色都對得上。

動畫 1收據分籃子:groupby → sum → nlargest1 分 46 秒・有旁白,也有字幕

收據上的訂單編號、客戶、金額,就是本頁 orders.csv 的 20 列(為了教學自己設計的資料)。籃子是比喻:真正的 GroupBy 物件不會把資料搬來搬去,只記下每位客戶在第幾列,動畫 2 會打開記憶體給你看。

看完動畫 1,你應該能回答:groupby('CustomerID') 做完時,算出總額了嗎?
還沒有。它只把每位客戶在第幾列記下來(C01 → 列 2、14;C03 → 列 0、5、8、13、17…)。要到 .sum() 才真正把金額加起來。
C03 的 1550 是怎麼來的?
C03 有 5 張收據:350 + 320 + 310 + 290 + 280 = 1550。
C06 那一筆 900 是全場最大,為什麼他不在前 3 名?
他只買了一次,總額就是 900,排第 4。前 3 名是 C03 1550、C05 1220、C01 1100。「單筆最大」和「總額最高」是兩回事。
C04 買了 7 次最多,為什麼總額墊底?
7 筆都是小額(40~70),加起來只有 385。「買最多次」也不等於「貢獻最多」。

觀念 3|點、方括號、方法串接:每一步都交出一個新物件,從左到右

點(.)讀成「的」:df.shape 是「df 的 shape」。點後面沒有括號是屬性(讀物件身上的資料,例如 df.shape → (20, 4));有括號是方法(叫物件做事,例如 df.head(3) → 前 3 列)。方法就是寫在設計圖裡、綁在物件身上的函式(機器)。

方括號:緊貼在物件後面,就是「跟這個物件要東西」

寫 物件[鑰匙] 時,Python 會去呼叫那個物件的 __getitem__ 方法,把鑰匙交給它。要到什麼,由物件的設計圖決定,所以同樣的方括號,放在不同物件後面意思不同(下表都實測過):

寫法方括號前面是誰拿鑰匙要到什麼交出的物件
df['Revenue']DataFrame(整張表)Revenue 那一欄,20 個金額Series
….head(3)['CustomerID']DataFrame(只剩 3 列的新表)CustomerID 那一欄,3 格Series
df.groupby('CustomerID')['Revenue']GroupBy 物件(分好的籃子)「每個籃子裡只看 Revenue」SeriesGroupBy
s['C03'](s 是每人總額)Series標籤 C03 那一格的值一個整數 np.int64(1550)
['sum', 'max']沒有,自己獨立出現(不是要東西)做一個清單 listlist

例如 df.groupby('CustomerID')['Revenue'] 和 df.groupby('CustomerID').__getitem__('Revenue') 交出的是同一種物件(實測都是 SeriesGroupBy)。

方法串接:點左邊交出什麼,點右邊就是「那個物件」的方法

一行裡有好幾個點時,Python 從左到右一步一步做:先做完左邊那一步、拿到一個新物件,再對這個新物件呼叫下一個方法。所以讀程式時要一直問:「現在手上的東西是什麼?」以正解 C 為例:

讀到哪裡這時候手上的東西它的設計圖(類別)
df整張表格,20 列 × 4 欄DataFrame
.groupby('CustomerID')分好籃子的「分組物件」:6 個籃子,還沒算任何東西DataFrameGroupBy
['Revenue']同樣的籃子,只看金額SeriesGroupBy
.sum()每個籃子加總:6 個數字,標籤是客戶編號Series(新的)
.nlargest(3)最大的 3 個:C03 1550、C05 1220、C01 1100Series(又一個新的)

四個選項各走了幾步、手上依序拿著什麼(全部實測 type()):

選項每一步交出的物件
ADataFrame(20 列)→ sort_values → DataFrame(20 列,重新排過)→ head(3) → DataFrame(3 列)→ ['CustomerID'] → Series(3 格)
BDataFrame → groupby → DataFrameGroupBy → ['Revenue'] → SeriesGroupBy → max() → Series(6 格)→ head(3) → Series(3 格)
CDataFrame → groupby → DataFrameGroupBy → ['Revenue'] → SeriesGroupBy → sum() → Series(6 格)→ nlargest(3) → Series(3 格)
DDataFrame → value_counts('CustomerID') → Series(6 格)→ head(3) → Series(3 格)

執行順序:從左到右、括號裡先備好、等號最後

  • 從左到右:沿著點往右走,左邊那一步做完,右邊才有東西可以叫。
  • 括號裡的材料先備好:groupby('CustomerID') 要先把字串 'CustomerID' 準備好,機器才開動;nlargest(3) 也是先準備好 3。
  • 等號最後:寫成 top = df.groupby(…)…nlargest(3) 時,右邊整串全部做完,最後才把名牌 top 貼到最後那個 Series 上。中途做出來的物件都沒有名牌。

每一步都是做一個新物件交出來,不會改到前一個:實測 df.sort_values('Revenue', ascending=False) is df 是 False,排完以後 df 的前 3 列還是 O001、O002、O003,原封不動。

深入一點:用 dis 看 Python 真正的步驟(可以先跳過)

Python 內建的 dis 模組可以把一行程式拆成直譯器實際執行的指令。下面是 Python 3.13 對選項 C(加上名牌 top)的實測輸出(節錄:省略了開頭的 RESUME 和行號欄),右邊的中文是本頁加的註解:

import dis
dis.dis("top = df.groupby('CustomerID')['Revenue'].sum().nlargest(3)")

LOAD_NAME                0 (df)                  # 找到名牌 df 指的表格
LOAD_ATTR                3 (groupby + NULL|self) # 找到它的 groupby 方法
LOAD_CONST               0 ('CustomerID')        # 準備材料
CALL                     1                       # ① 開動 groupby
LOAD_CONST               1 ('Revenue')
BINARY_SUBSCR                                    # ② [ 'Revenue' ]:跟分組物件要東西
LOAD_ATTR                5 (sum + NULL|self)
CALL                     0                       # ③ 開動 sum
LOAD_ATTR                7 (nlargest + NULL|self)
LOAD_CONST               2 (3)
CALL                     1                       # ④ 開動 nlargest
STORE_NAME               4 (top)                 # 最後才貼名牌 top
RETURN_CONST             3 (None)

指令的順序和上面說的完全一樣:① → ② → ③ → ④ 從左到右,STORE_NAME(貼名牌)在最後。

觀念 4|GroupBy 物件:分好籃子,但還沒有加總

df.groupby('CustomerID') 交出來的不是表格,而是一個分組物件,照 DataFrameGroupBy 這張設計圖做出來的。把它 print 出來,只會看到「我是哪張設計圖做的、住在哪一格置物櫃」(位址每次執行都不同):

g = df.groupby('CustomerID')
print(g)
# pandas 3.0.6:<pandas.api.typing.DataFrameGroupBy object at 0x000001A04DA17E00>
# pandas 2.2.3:<pandas.core.groupby.generic.DataFrameGroupBy object at 0x000001F7C0BE78C0>

兩版顯示的路徑不同,是同一張設計圖:pandas 3 用公開的門牌 pandas.api.typing,2.2 顯示它真正住的子工具箱 pandas.core.groupby.generic(實測兩版 isinstance(g, pd.api.typing.DataFrameGroupBy) 都是 True)。

它記得什麼?跟它要看看(兩版實測相同)

g.obj is df → True ← 箭頭指回原本那張 df,沒有把收據複製一份 g.keys → 'CustomerID' ← 照哪一欄分 g.ngroups → 6 ← 幾個籃子(len(g) 也是 6) g.groups → {'C01': [2, 14], 'C02': [4, 11, 18], 'C03': [0, 5, 8, 13, 17], 'C04': [1, 3, 6, 9, 12, 15, 19], 'C05': [7, 16], 'C06': [10]} ← 每個籃子裝的是「第幾列」:只記列標籤,不是收據本身 g.get_group('C03') → 打開 C03 的籃子,交出一張 5 列的新表格: OrderID CustomerID Category Revenue 0 O001 C03 Electronics 350 5 O006 C03 Home 320 8 O009 C03 Clothing 310 13 O014 C03 Home 290 17 O018 C03 Books 280

也可以用 for 迴圈一籃一籃拿出來:for name, basket in g: 每一圈拿到一個客戶編號和一張小表格。實測依序是 C01(2 列)、C02(3 列)、C03(5 列)、C04(7 列)、C05(2 列)、C06(1 列),照客戶編號排。

比喻和真實機制對照

比喻:groupby 像把 20 張收據照客戶分進 6 個籃子。真實機制:GroupBy 物件沒有把收據搬進新的地方,它記住的是「照 CustomerID 分」這個規則、一條指回 df 的箭頭,以及「哪幾列屬於哪一籃」的對照表(就是 g.groups)。籃子裡有幾張、加起來多少,這時候都還沒算;要等你叫 .sum()、.max() 這些方法時才真的計算。

深入一點:連「哪幾列屬於哪一籃」都是等到要用才算

pandas 在建立 GroupBy 物件時只做兩件事:檢查 CustomerID 這一欄存在(打錯字當場就出錯,實測 df.groupby('customerid') → KeyError: 'customerid'),以及把分組的規則存起來。實測它的內部快取cache:算過一次的結果先記下來,下次要用直接拿,不用重算。:剛建立時還沒有對照表(內部名稱 _codes_and_uniques),第一次問 g.ngroups 之後才出現,之後一直沿用。兩個版本都是這樣。所以「分籃子」這件事,嚴格說是第一次需要時才做、做完記住。

方法寫在哪張設計圖上:為什麼有的能用、有的不能

GroupBy 物件的方法沿著「設計圖的家譜」找,這條路線叫 MROMethod Resolution Order:找方法的順序。Python 先找物件自己的類別,找不到就往上一層的類別找。(兩版相同):

DataFrameGroupBy → GroupBy → BaseGroupBy → PandasObject → … → object SeriesGroupBy → GroupBy → BaseGroupBy → PandasObject → … → object sum、max、min、count、mean、size、head 寫在 GroupBy 這一層 → 兩種都能用(繼承) nlargest 只寫在 SeriesGroupBy → DataFrameGroupBy 沒有

子類別自動擁有上層類別寫好的方法,這叫繼承。所以 df.groupby('CustomerID').nlargest(3) 會出錯(實測 AttributeError: 'DataFrameGroupBy' object has no attribute 'nlargest'),先用 ['Revenue'] 變成 SeriesGroupBy 才有 nlargest,但那個 nlargest 的意思又不一樣(§5 的常見寫錯會講)。

觀念 5|聚合aggregation:把一群數字濃縮成一個數字,例如加總、最大值、平均、個數。函式:把一個籃子濃縮成一個數字

GroupBy 物件的 sum、max、count… 都是「每個籃子算一次,各交出一個數字」。同樣 6 個籃子,叫不同的方法,回答不同的問題(兩版實測相同):

客戶sum()
加總
max()
單筆最高
min()
單筆最低
count()
幾張
mean()
平均一張
C0111005605402550.000000
C02830680603276.666667
C0315503502805310.000000
C043857040755.000000
C0512207005202610.000000
C069009009001900.000000
  • 選項 B 用 max:回答「每人最大的一張多少」。C06 只有一張,所以他的 max、min、sum 都是 900。
  • 選項 C 用 sum:回答「每人一共多少」,這才是總貢獻。
  • 選項 D 數張數:value_counts 算的就是這一欄的 count。
  • mean() 的結果是小數(dtype float64),所以印成 276.666667;sum、max、min、count 都是整數 int64。
  • size() 也是數張數,實測結果和 count 一樣(C01 2、…、C06 1)。差別是 count 不算空格(NaN),size 連空格的列也算;本題沒有空格,所以一樣。

想一次看好幾種,可以寫 df.groupby('CustomerID')['Revenue'].agg(['sum', 'max', 'count']),交出一張 6 列 × 3 欄的表格(§6 程式 1 會印出來)。

觀念 6|Series 的標籤是客戶編號:head、sort_values、nlargest 差在哪

.sum() 交出一個 Series:一排值+一排標籤(index)+一個名字。這裡的標籤不再是 0、1、2,而是客戶編號;名字 Revenue 是從原本那一欄抄過來的:

s = df.groupby('CustomerID')['Revenue'].sum()
print(s)
CustomerID          ← 標籤這一排的名字(index 的 name)
C01    1100
C02     830
C03    1550
C04     385
C05    1220
C06     900
Name: Revenue, dtype: int64
s.index → Index(['C01', 'C02', 'C03', 'C04', 'C05', 'C06'], dtype='object', name='CustomerID') (pandas 3.0.6 是 dtype='str') s.name → 'Revenue' s['C03'] → np.int64(1550) ← 用標籤拿 s.iloc[0] → np.int64(1100) ← 用位置拿(第 0 個是 C01) s.idxmax() → 'C03' ← 最大值的標籤 s['C07'] → KeyError: 'C07' ← 沒有這個標籤

注意順序:照客戶編號排,跟金額大小無關(原因在 §2:groupby 預設 sort=True)。所以從這個 Series「拿前 3 個」有三種寫法,意思完全不同:

寫法做什麼結果(實測)
s.head(3)照目前的順序拿前 3 個,不看大小C01 1100、C02 830、C03 1550
s.sort_values(ascending=False)整串由大到小重新排隊(6 個全排)C03 1550、C05 1220、C01 1100、C06 900、C02 830、C04 385
s.sort_values(ascending=False).head(3)排好再拿前 3 個C03 1550、C05 1220、C01 1100
s.nlargest(3)直接挑最大的 3 個(由大到小)C03 1550、C05 1220、C01 1100
s.nsmallest(3)挑最小的 3 個C04 385、C02 830、C06 900

實測 s.nlargest(3).equals(s.sort_values(ascending=False).head(3)) 是 True:結果一模一樣,差在做法。nlargest 不需要把 6 個都排好名次,這就是題目說的「高效」,§2 會打開 pandas 的原始碼看它怎麼做。

把 6 個觀念合起來:記憶體裡發生什麼

① 類別(設計圖)DataFrame、Series 在 pandas 工具箱裡;DataFrameGroupBy、SeriesGroupBy 在子工具箱 pandas.core.groupby
→
② 建立物件(照設計圖做)read_csv 做出 df;選項 C 的每一步再做出一個新物件:GroupBy → SeriesGroupBy → Series → Series
→
③ 跟物件要東西、叫它做事.groupby()、['Revenue'](__getitem__)、.sum()、.nlargest(3),從左到右
top = df.groupby('CustomerID')['Revenue'].sum().nlargest(3) 跑完之後: 名牌 置物櫃裡的物件 pd ───→ pandas 工具箱(module) df ───→ DataFrame:20 列 × 4 欄(20 張收據) (沒有名牌) ① DataFrameGroupBy:記得「照 CustomerID 分」,箭頭指回 df (沒有名牌) ② SeriesGroupBy:同樣的籃子,只看 Revenue (沒有名牌) ③ Series:6 個總額(這一步才真的加總) top ───→ ④ Series:C03 1550、C05 1220、C01 1100

①②③ 只是過渡,沒有名牌指著它們。用 weakref(不會把物件留住的觀察工具)實測:一行跑完、等 Python 清理一次之後,pandas 2.2.3 和 3.0.6 的 ①②③ 都已經被回收,只剩名牌指著的 df 和 top。下面的動畫 2 把這個過程一步一步畫出來。

看動畫 2 之前:這支要回答什麼

動畫 1 的籃子是比喻。觀念 3~6 講了方法串接、GroupBy 物件、聚合和 nlargest。動畫 2 打開真正的記憶體,用一次真實執行(pandas 3.0.6)的位址,看選項 C 這一行做出了哪些物件、每個物件身上記了什麼。

帶著三個問題看:① df.groupby(...) 交出的物件,身上有沒有一份表格的複本?② 真正把金額加起來的是哪一步?③ 這一行跑完,哪些物件還留在記憶體裡?

看的時候抓三種樣子:綠色格子是物件、黃色虛線小卡寫著它是照哪張設計圖(類別)做的、紅色名牌是名字。

動畫 2打開記憶體:一行串出四個物件1 分 59 秒・有旁白,也有字幕

位址和型別取自一次真實執行(Python 3.13.7、pandas 3.0.6),每台電腦、每次執行都不一樣,這是正常的。pandas 2.2.3 只差在型別的顯示路徑(pandas.core.groupby.generic.DataFrameGroupBy)。「中途物件被回收」在兩個版本都實測過。

看完動畫 2,你應該能回答:GroupBy 物件身上有表格的複本嗎?
沒有。g.obj is df 是 True:它指回原本那張 df,另外只記著用哪一欄分組,以及「每位客戶在第幾列」。
真正把金額加起來的是哪一步?
③ .sum()。前兩步交出的 DataFrameGroupBy、SeriesGroupBy 都還沒有算任何數字。
print(df.groupby('CustomerID')) 為什麼看不到表格?
因為它不是表格,是一個 GroupBy 物件;印出來只有它的類別名稱和位址,例如 <pandas.api.typing.DataFrameGroupBy object at 0x…>。
這一行跑完,記憶體裡還留著什麼?
df,以及最後 nlargest(3) 交出的結果(Colab 會把它顯示出來)。groupby、['Revenue']、sum() 交出的三個中途物件沒有名牌,會被 Python 回收。
§2

認識這次登場的框架:pandas 的 GroupBy 與「分組 → 套用 → 合併」

pandas GroupBy
pandas 的分組工具(第 41 題介紹過 pandas 本身)

df.groupby(...) 交出的 GroupBy 物件,設計圖放在子工具箱 pandas.core.groupby,公開的門牌是 pandas.api.typing。它專門做「依某一欄分組,每組各算一個結果」。

比喻:Excel 的樞紐分析表。列放「客戶」、值放「營收的加總」,就是本題的 groupby + sum。

split-apply-combine
分組 → 套用 → 合併:資料分析最常見的套路

這個說法來自統計學家 Hadley Wickham 2011 年的論文。三步:分組(照客戶把列分開)、套用(每組各自做一件事,例如加總)、合併(把每組的結果收成一個新物件)。

資料庫語言 SQL 的 GROUP BY 也是同一個想法(下面有對照)。

orders.csv
本頁自己設計的教學資料:20 列 × 4 欄,469 位元組

一行一列,第一行是欄名。用記事本打開的前 4 行:

OrderID,CustomerID,Category,Revenue O001,C03,Electronics,350 O002,C04,Books,60 O003,C01,Electronics,560

Revenue 全是整數,read_csv 判斷成 int64。

用本題的數字走一次:分組 → 套用 → 合併

20 列20 張收據
df
→ 分組
6 個籃子groupby('CustomerID')
還沒算
→ 套用
6 個總額['Revenue'].sum()
每籃加一次
→ 合併
1 個 Series標籤=客戶編號
再 nlargest(3)
籃子(鍵)分組:哪幾列(g.groups)這幾列的 Revenue套用 sum套用 max套用 count
C012, 14560、54011005602
C024, 11, 18680、90、608306803
C030, 5, 8, 13, 17350、320、310、290、28015503505
C041, 3, 6, 9, 12, 15, 1960、55、50、45、40、70、65385707
C057, 16700、52012207002
C06109009009001

「合併」就是把右邊某一欄收成一個 Series:選 sum 就得到 C01 1100、C02 830、C03 1550、C04 385、C05 1220、C06 900。6 個總額加起來是 5985,剛好等於 20 張收據的總和:每張收據都進了某一個籃子,而且只進一個。

對照:同一件事用 SQL 怎麼寫

把 orders.csv 放進 SQLite 資料庫(Python 內建的 sqlite3,SQLite 3.50.4),下面這句 SQL 實測得到 [('C03', 1550), ('C05', 1220), ('C01', 1100)],和選項 C 一模一樣:

SELECT CustomerID, SUM(Revenue) AS total
FROM orders
GROUP BY CustomerID          -- 分組
ORDER BY total DESC          -- 由大到小
LIMIT 3                      -- 取前 3

pandas 的 groupby 對應 GROUP BY,sum() 對應 SUM(),nlargest(3) 一次做完 ORDER BY … DESC LIMIT 3。

groupby 預設 sort=True:選項 B 的 head(3) 為什麼永遠是 C01、C02、C03

收據裡客戶第一次出現的順序是 C03(第 0 列)、C04(第 1 列)、C01(第 2 列)、C02(第 4 列)、C05(第 7 列)、C06(第 10 列)。可是 groupby 的結果卻照 C01、C02…C06 排,因為 sort 參數預設是 True:把籃子照鍵(客戶編號)排序。注意它排的是 6 個客戶編號,不是金額。

預設 sort=True:….max()

CustomerID
C01    560
C02    680
C03    350
C04     70
C05    700
C06    900
Name: Revenue, dtype: int64

groupby('CustomerID', sort=False):….max()

CustomerID
C03    350
C04     70
C01    560
C02    680
C05    700
C06    900
Name: Revenue, dtype: int64

head(3) 只是「照目前順序拿前 3 個」,不看數字大小。所以選項 B 預設拿到 C01 560、C02 680、C03 350;改成 sort=False 就變成 C03 350、C04 70、C01 560(兩版實測)。換句話說,B 的答案取決於客戶編號怎麼取名、誰先來買,和「誰貢獻最多」完全無關。選項 C 就沒有這個問題:nlargest 看的是數字本身,sort=True 或 False 結果都是 C03、C05、C01(實測)。

nlargest 和 sort_values().head():「高效」到底省在哪

兩種寫法結果相同,差在工作量。sort_values 要讓每一個數字都站到正確名次;nlargest 只要找出前 n 名,其他人的名次不管。打開 pandas 原始碼(pandas/core/methods/selectn.py 的 SelectNSeries.compute,2.2.3 和 3.0.6 的做法相同),nlargest 分成這幾步,用本題的 6 個總額走一次:

步驟pandas 在做什麼本題的 6 個總額
0如果要的個數 n ≥ 資料長度,乾脆整串排序(這時省不了)n = 3,只有 6 個 → 走快速路線
1把每個數字變號(大的變成最小),問題就變成「找最小的 3 個」1100、830、1550、385、1220、900 → 全部變成負的
2用 kth_smallest 找出第 3 名的門檻值,不必把全部排好門檻 = 第 3 大的 1100
3挑出所有「過門檻」的數字C01 1100、C03 1550、C05 1220(只有 3 個)
4只把這幾個排好(穩定排序),取前 n 個C03 1550、C05 1220、C01 1100

6 個數字看不出差別;資料一大就很明顯:一百萬個數字只要前 3 名時,sort_values 要把一百萬個全部排好,nlargest 只要找到門檻,再排過門檻的 3 個。

深入一點:kth_smallest 只找第 k 名,不排其他人(可以先跳過)

kth_smallest 是 pandas 內部用編譯過的程式寫的選擇演算法selection algorithm:只為了找出「第 k 小的那一個」而設計的方法。做法是反覆挑一個基準值、把比它小的放一邊、大的放另一邊,然後只往第 k 名所在的那一邊繼續找,另一邊就不管了。。直接叫它(內部函式,兩版實測相同):20 個亂數(seed 45)找「第 3 小」(k = 2,從 0 數起):

from pandas._libs import algos as libalgos
arr = [924, 573, 731, 528, 531, 763, 706, 811, 562, 510,
       423, 779, 566, 796, 776, 594, 281, 408, 536, 671]
# kth_smallest(arr, 2) → 423      (真正排序後的前 3 個是 281、408、423)
# 做完以後 arr 被調整成:
# [281, 408, 423, 528, 531, 510, 706, 536, 562, 573,
#  671, 594, 566, 796, 776, 779, 811, 763, 731, 924]

看做完後的陣列:423 左邊都比它小、右邊都比它大,但右邊並沒有排好(528、531、510 還是亂的)。它只負責把第 k 名放到正確的位置,這就是省下來的工作。

實測速度:資料大才有差,資料很小時反而慢

資料版本nlargest(3)sort_values(ascending=False).head(3)
一百萬個亂數(seed 45)pandas 2.2.316.94 毫秒57.61 毫秒
pandas 3.0.612.43 毫秒54.13 毫秒
本題的 6 個總額pandas 2.2.3120.4 微秒29.2 微秒
pandas 3.0.6195.9 微秒40.3 微秒

(1 毫秒=千分之一秒,1 微秒=百萬分之一秒。用 timeit 量,各量 5 輪取最快的一輪平均;換一台電腦、換一次執行,數字都會不同,要看的是相對大小。)一百萬筆時,nlargest 快了約 3~4 倍,而且兩種寫法的結果實測完全相同(equals 是 True)。但只有 6 個數字時,nlargest 反而慢了好幾倍:它前面有不少準備工作(複製一份、處理空格、變號…),資料太少時這些固定成本比排序本身還貴。不過兩邊都只要零點零幾到零點二毫秒,人完全感覺不到。題目說的「高效」,指的是資料變大以後的做法:只要前幾名時,不必把全部排好。

value_counts:數「出現幾次」,不是加金額

選項 D 的 df.value_counts('CustomerID') 數的是每個客戶編號在表格裡出現幾次,也就是每人有幾張收據。打開原始碼,DataFrame 的 value_counts 其實也在分籃子:它在裡面呼叫 self.groupby(subset, …),數每個籃子有幾列,再照次數由多到少排。

print(df.value_counts('CustomerID'))
CustomerID
C04    7
C03    5
C02    3
C01    2
C05    2
C06    1
Name: count, dtype: int64
  • 最後一行的 Name: count:pandas 2.0 起,value_counts 交出的 Series 一律叫 count(改成比例 normalize=True 時叫 proportion)。
  • 寫成 df['CustomerID'].value_counts()(先取一欄,再數)結果完全相同(實測 equals 是 True)。
  • C01、C05 都是 2 次,同分的排在一起;前 3 名 C04、C03、C02 沒有同分。
  • 它和金額一點關係都沒有:就算把 C04 每張收據都改成 1 元,D 的答案也不會變(§4 可以自己試)。

這題會碰到的 pandas 2 與 pandas 3 差異

好消息:四個選項的答案,兩版完全相同。不同的地方都在「顯示」和「少用的參數」,下表全部實測:

項目pandas 2.2.3pandas 3.0.6對本題的影響
文字欄的 dtypeobjectstr選項 A 最後一行印 dtype: object 或 dtype: str
type(g) 的顯示pandas.core.groupby.generic.DataFrameGroupBypandas.api.typing.DataFrameGroupBy同一張設計圖,只是門牌寫法不同
groupby 的 axis 參數還在,但寫了會跳 FutureWarning移除,寫了直接 TypeError本題沒寫
groupby 的 observed 預設<no_default>(目前等於 False)True只影響 category 型別的分組欄;本題 CustomerID 是文字,沒影響
groupby 參數的寫法每個參數都能照位置寫as_index 以後只能寫「名字=值」本題只傳了 by
GroupBy 的 sum/max 的 skipna沒有這個參數(寫了 TypeError)有,預設 True本題沒寫
value_counts(sort=False) 的順序照客戶編號照第一次出現本題用預設 sort=True,沒影響
§3

逐行解剖

左邊是本頁補成可以執行的完整程式:第 1、3 行讀資料,第 4~7 行就是選項 A~D。點任一行可以跳到解說;右邊往下捲時,左邊會自動標出目前講到哪一行。選項那 4 行各拆成好幾張卡:先看全貌,再一步一步看。手機上會改成上下排列。

q45.py7 行

把裝在電腦裡的 pandas 套件載入記憶體,之後用 pd 這個短名字叫它。

import
關鍵字「把後面那個套件載入進來」。
pandas
套件要先安裝才有(pip install pandas;Anaconda、Google Colab 已經裝好)。全部小寫。
as pd
名牌取綽號。記憶體裡只會有名牌 pd,沒有 pandas 這個名字。

執行後 type(pd) 是 <class 'module'>:pd 指的是一個工具箱(模組物件),不是設計圖。四個步驟(查已載入清單 → 執行 pandas 的程式碼 → 登記 → 貼名牌)和常見錯誤,第 41 題的 逐行解剖講得很完整。

叫 pandas 的 read_csv 機器打開 orders.csv,把每一行切成格子、判斷每一欄的型別,做成一張表格物件,最後把名牌 df 貼上去。

df =
名牌+指派等號不是「等於」,而是「右邊做完以後,把名牌 df 貼上去」。
pd.read_csv
函式工具箱裡讀 CSV 的機器,小寫開頭,不是設計圖。
'orders.csv'
位置引數檔名字串,對應第 1 個參數 filepath_or_buffer。相對路徑:從「執行程式時所在的資料夾」找。

這一行用到的預設值(其他參數見第 41 題)

參數預設值(兩版相同)對本題的意思
sep逗號(簽名顯示 <no_default>)用逗號切欄
header'infer'第 1 行 OrderID,CustomerID,Category,Revenue 當欄名
index_colNone不拿任何一欄當列標籤,自動編 0~19
dtypeNone(自動判斷)Revenue → int64;其他三欄 → 2.2 object/3.0 str
執行後: df.shape → (20, 4) df.index → RangeIndex(start=0, stop=20, step=1) type(df) → pandas 3.0.6:<class 'pandas.DataFrame'>;pandas 2.2.3:<class 'pandas.core.frame.DataFrame'>
  • CSV 不在「目前的工作資料夾」:實測 FileNotFoundError: [Errno 2] No such file or directory: 'orders.csv'。把 CSV 和程式放同一個資料夾,並在那個資料夾執行。
  • 以為 df 就是 CSV 檔:df 是記憶體裡的表格物件,後面四個選項怎麼排、怎麼分組,都不會改到硬碟上的檔案。

把整張表格照 Revenue 由大到小重新排一次,拿最上面 3 列(3 張金額最大的收據),再從這 3 列只取 CustomerID 那一欄。

① .sort_values(…)收據照金額排隊
20 列的新表格
→
② .head(3)拿最前面 3 列
3 列的新表格
→
③ ['CustomerID']只看客戶這一欄
3 格的 Series
10    C06
7     C05
4     C02
Name: CustomerID, dtype: object       ← pandas 3.0.6 是 dtype: str

左邊的 10、7、4 是這 3 張收據原本的列標籤(O011、O008、O005),排序不會重新編號。這一行完全沒有「同一位客戶加起來」的步驟,回答的是「金額最大的 3 張收據是誰的」,不是總貢獻。

叫 df 照 Revenue 這一欄重新排列「每一列」,由大到小。整列一起搬(收據的其他欄跟著走),做成一張新表格交出來,df 自己不變。

.sort_values
方法DataFrame 設計圖上的方法(DataFrame 自己寫了一份,蓋過上層 NDFrame 的)。
'Revenue'
位置引數對應第 1 個參數 by:照哪一欄排。
ascending=False
關鍵字引數「名字=值」的寫法。ascending 是「遞增」,設成 False 就是由大到小。

sort_values 完整參數表

簽名(兩版相同):sort_values(by, *, axis=0, ascending=True, inplace=False, kind='quicksort', na_position='last', ignore_index=False, key=None)。* 後面的參數只能用「名字=值」寫。

參數選項 A 的值預設值白話
by'Revenue'(必填)照哪一欄排。可以給清單排好幾層,例如 ['CustomerID', 'Revenue']:先照客戶、同一客戶再照金額
axis—00:上下重排「列」(最常用);1:左右重排「欄」
ascendingFalseTrueTrue 由小到大,False 由大到小。排好幾欄時可以給清單,例如 [True, False]
inplace—FalseFalse:做一張新表格交出來,df 不變。True:直接改 df,回傳 None(實測),後面就不能再接 .head(3)
kind—'quicksort'用哪種排序演算法:'quicksort'、'mergesort'、'heapsort'、'stable'。只有 'stable'、'mergesort' 保證同分時維持原本的先後
na_position—'last'空格(NaN)排最後;'first' 排最前。本題沒有空格
ignore_index—FalseFalse:保留原本的列標籤(所以結果左邊是 10、7、4);True:重新編成 0、1、2(實測)
key—None排之前先把這一欄丟進一個函式轉換,照轉換後的值排(例如文字不分大小寫)

交出來的新表格(前 6 列,兩版相同)

   OrderID CustomerID     Category  Revenue
10    O011        C06  Electronics      900
7     O008        C05  Electronics      700
4     O005        C02  Electronics      680
2     O003        C01  Electronics      560
14    O015        C01         Home      540
16    O017        C05     Clothing      520

完整 20 列由大到小依序是 900、700、680、560、540、520、350、320、310、290、280、90、70、65、60、60、55、50、45、40。有兩張 60 元(O002 和 O019)同分:預設的 quicksort 實測把 O019 排在 O002 前面;加上 kind='stable' 則照原本順序,O002 在前。同分的先後只有 stable 有保證,好在它不影響前 3 張。

新手常踩的坑

  • 忘了 ascending=False:變成由小到大,前 3 張是 40、45、50 元,全是 C04 的(實測輸出 12、9、6 三列都是 C04)。
  • 把 False 寫成字串:ascending='False',實測 ValueError: For argument "ascending" expected type bool, received type str.
  • 欄名打錯:df.sort_values('revenue'),實測 KeyError: 'revenue'(大小寫要一字不差)。
  • 以為 df 被排好了:實測 df.sort_values(…) is df 是 False,排完 df 的第一列還是 O001。要留著排好的表格,請另外貼名牌,例如 ranked = df.sort_values(…)。

從上一步排好的新表格,拿最上面 3 列,再做成一張新表格。它只看「位置」,不看數字。

.head
方法寫在 NDFrame(Series 和 DataFrame 共同的上層類別),所以表格和一欄都能用。
(3)
位置引數對應參數 n:要幾列。

head 完整參數表

簽名(兩版相同,DataFrame 和 Series 一樣):head(n=5)

參數選項 A 的值預設值白話
n35拿前幾列。不寫就是 5 列。寫負數是「除了最後 |n| 列以外全部」:實測 df.head(-3) 交出 17 列
交出來的新表格(兩版相同): OrderID CustomerID Category Revenue 10 O011 C06 Electronics 900 7 O008 C05 Electronics 700 4 O005 C02 Electronics 680

head(3) 等於「照位置拿第 0、1、2 列」(和 .iloc[:3] 一樣)。它之所以拿到最大的 3 張,完全是因為上一步已經排好了;head 自己不會挑大小。

  • 把數字寫成字串 head('3'):實測 TypeError: cannot do positional indexing on RangeIndex with these indexers [3] of type str。
  • 以為 head 會挑最大的:沒有排序就 head,只是拿表格最上面幾列(例如 df.head(3) 是 O001、O002、O003)。選項 B 就是踩了這個坑。

拿 'CustomerID' 這把鑰匙跟那張 3 列的表格要一欄,交出一個 3 格的 Series:3 張大收據各是誰的。

[
取東西緊貼在 head(3) 後面:跟那張 3 列的表格要東西(呼叫它的 __getitem__)。
'CustomerID'
鑰匙欄名字串,大小寫要一字不差。
]
收尾交出 Series,名字(Name)就是欄名 CustomerID。
最後交出的 Series: 10 C06 7 C05 4 C02 Name: CustomerID, dtype: object ← pandas 3.0.6:dtype: str .tolist() → ['C06', 'C05', 'C02']

選項 A 的兩個問題

  • 看的是單張收據,不是每人總額:C06 那張 900 是全場最大,可是他只買過這一次;總額第 1 的 C03 沒有任何一張進前 3。
  • 同一位客戶可能出現兩次:如果拿 5 張,head(5)['CustomerID'] 實測是 C06、C05、C02、C01、C01,C01 的 560 和 540 都上榜。「前 3 張收據」不等於「前 3 位客戶」。

兩個實測過的等價寫法:['CustomerID'] 和 head(3) 先後對調,結果相同;df.nlargest(3, 'Revenue')['CustomerID'] 也相同(DataFrame 的 nlargest 必須告訴它照哪一欄,只寫 df.nlargest(3) 會出現 TypeError: DataFrame.nlargest() missing 1 required positional argument: 'columns')。

先照客戶把收據分進 6 個籃子,每個籃子只看金額、只留最大的那一張,做成 6 格的 Series;再照目前的順序拿前 3 格。

① .groupby('CustomerID')分 6 個籃子
→
② ['Revenue']只看金額
→
③ .max()每籃取最大
6 格 Series
→
④ .head(3)拿前 3 格

①② 和第 6 行(正解 C)一模一樣,詳細拆解在第 6 行的 ①②,這裡先看它們交出什麼:① 一個 DataFrameGroupBy(分好 6 個籃子,還沒算)、② 一個 SeriesGroupBy(同樣的籃子,只看 Revenue)。B 和 C 只差在第 ③④ 步。

CustomerID
C01    560
C02    680
C03    350
Name: Revenue, dtype: int64

這是 C01、C02、C03 三個人「各自最大的一張」,既不是總額,也不是照金額挑出來的前 3 名。

叫 SeriesGroupBy 物件「每個籃子各找最大值」。這一步才真的計算,做出一個新的 Series:標籤是客戶編號,值是那個人最大的一張收據。

.max
方法寫在 GroupBy 這一層,SeriesGroupBy 是繼承來的(§1 觀念 4)。
()
呼叫沒有給參數,全部用預設值。

max 完整參數表(SeriesGroupBy.max)

簽名:pandas 3.0.6 max(numeric_only=False, min_count=-1, skipna=True, engine=None, engine_kwargs=None);pandas 2.2.3 沒有 skipna。

參數預設值白話
numeric_onlyFalse只算數字欄。這裡已經只剩 Revenue 一欄,有沒有設都一樣
min_count-1籃子裡至少要有幾個「不是空格」的值才算,不夠就給 NaN。-1 代表不設門檻
skipnaTrue(只有 pandas 3)跳過空格(NaN)。pandas 2.2.3 寫了這個參數,實測 TypeError: GroupBy.max() got an unexpected keyword argument 'skipna'
engineNone用哪個計算引擎:None 就是用 pandas 內建、編譯好的 cython 程式;'numba' 要另外安裝 numba 套件
engine_kwargsNone給 numba 引擎的額外設定
.max() 交出的完整 Series(兩版相同): CustomerID C01 560 C02 680 C03 350 C04 70 C05 700 C06 900 Name: Revenue, dtype: int64
  • 以為 max 就是「最有貢獻」:max 只看每人最大的一張。C03 每張都不大(最大 350),總額卻是第 1。
  • 以為把 head 換成 nlargest 就對了:….max().nlargest(3) 實測是 C06 900、C05 700、C02 680,答案是「單筆最大的 3 位客戶」,還是不是總貢獻。問題出在 max,不只出在 head。

從 6 格的 Series 拿最前面 3 格。因為 groupby 預設 sort=True 把籃子照客戶編號排好了,最前面 3 格永遠是 C01、C02、C03。

.head(3)
方法和選項 A 的 head 是同一個方法(寫在 NDFrame),參數只有 n,預設 5。這次點前面是 Series。

選項 A 的 head 能拿到大的,是因為前面先排序過;選項 B 前面沒有照金額排序,只有照客戶編號排,所以 head(3) 拿到的是「編號最小的 3 位」。§2 實測過:改成 groupby('CustomerID', sort=False),同一行就變成 C03 350、C04 70、C01 560(照第一次出現的順序)。答案會隨「客戶編號怎麼取、誰先來買」而變,代表它根本沒在回答「誰貢獻最多」。

  • 把 head 當成「前幾名」:head 是「前幾個」(位置),nlargest 才是「前幾名」(數值)。

照客戶把收據分進 6 個籃子,每個籃子只看金額、全部加起來,得到每位客戶的總貢獻;再從這 6 個總額挑出最大的 3 個。

① .groupby('CustomerID')DataFrameGroupBy
6 個籃子,還沒算
→
② ['Revenue']SeriesGroupBy
只看金額
→
③ .sum()Series:6 個總額
這一步才計算
→
④ .nlargest(3)Series:3 個
步驟交出的物件內容有名牌嗎
①DataFrameGroupBy照 CustomerID 分組的規則+指回 df 的箭頭沒有
②SeriesGroupBy同樣的 6 個籃子,只看 Revenue沒有
③Series(6 格)C01 1100、C02 830、C03 1550、C04 385、C05 1220、C06 900沒有
④Series(3 格)C03 1550、C05 1220、C01 1100(選項裡沒有等號,所以也沒有)

想看中間每一步,就自己一步一步貼名牌,最後結果和一行寫完一模一樣(實測):

g = df.groupby('CustomerID')     # ①
sg = g['Revenue']                # ②
s = sg.sum()                     # ③
top = s.nlargest(3)              # ④
print(top)
CustomerID
C03    1550
C05    1220
C01    1100
Name: Revenue, dtype: int64

拆開寫好除錯,每一步都能 print 出來檢查(§6 程式 2 就是這樣寫);寫成一行比較精簡。Python 做的事情一樣。

叫 df「照 CustomerID 這一欄分組」。它交出一個分組物件:記得分組規則、指著原本的 df,第一次需要時再算出每個籃子是哪幾列。加總、最大值這些都還沒算。

df
物件L3 做出來的表格。
.groupby
方法DataFrame 設計圖自己寫的方法。
'CustomerID'
位置引數對應第 1 個參數 by:照哪一欄分。這一欄每一種不同的值,就是一個籃子(一個「鍵」)。

groupby 完整參數表

簽名:pandas 3.0.6 groupby(by=None, level=None, *, as_index=True, sort=True, group_keys=True, observed=True, dropna=True);pandas 2.2.3 groupby(by=None, axis=<no_default>, level=None, as_index=True, sort=True, group_keys=True, observed=<no_default>, dropna=True)。

參數本題pandas 2.2.3 預設pandas 3.0.6 預設白話
by'CustomerID'NoneNone照什麼分:一個欄名、好幾個欄名的清單(分好幾層)……
axis—<no_default>(=0,已不建議用)已移除沿哪個方向分。2.2 寫了會跳 FutureWarning;3.0 寫了直接出錯
level—NoneNone改照「列標籤」的某一層分組,而不是照欄位
as_index—TrueTrueTrue:鍵(客戶編號)變成結果的列標籤,所以結果是 Series;False:留成一般欄位,結果是 DataFrame
sort—TrueTrue把籃子照鍵排序(C01…C06);False:照第一次出現的順序。選項 B 的關鍵
group_keys—TrueTrue只影響 .apply():結果要不要多一層「客戶」標籤。本題用不到
observed—<no_default>(目前等於 False)True只在分組欄是 category分類型別:事先列好「可能出現哪些值」的欄位,例如把客戶編號限定為 C01~C07。 型別時有作用:要不要列出「有登記、但沒有任何資料」的類別
dropna—TrueTrue鍵是空的(NaN)的列,要不要丟掉

pandas 3 的簽名多了一個 *:as_index 以後的參數只能用「名字=值」寫。

參數改一下會怎樣(都實測過)

寫法結果
df.groupby('CustomerID', sort=False)['Revenue'].sum()C03 1550、C04 385、C01 1100、C02 830、C05 1220、C06 900(照第一次出現);後面接 .nlargest(3) 結果不變
df.groupby('CustomerID', as_index=False)['Revenue'].sum()交出 DataFrame:兩欄 CustomerID、Revenue,列標籤 0~5(見下方)
先把 O020 的 CustomerID 清成空的,再 ….sum()C04 變成 320:O020 那張 65 元因為沒有客戶,不進任何籃子。加 dropna=False 就多一列 NaN 65
把 CustomerID 轉成 category,類別多登記一個沒有交易的 C07,再 ….sum()pandas 2.2.3:多一列 C07 0,並跳 FutureWarning;pandas 3.0.6:沒有 C07。寫 observed=False 兩版都列出 C07 0
df.groupby('CustomerID')['Revenue'].apply(lambda x: x.nlargest(1))兩層標籤:C01 2 560、C02 4 680…;加 group_keys=False 只剩原本的列標籤 2、4、0…
df.set_index('CustomerID')['Revenue'].groupby(level='CustomerID').sum().nlargest(3)C03 1550、C05 1220、C01 1100:照列標籤分組,答案一樣
df.groupby('CustomerID', axis=0)pandas 2.2.3:FutureWarning: The 'axis' keyword in DataFrame.groupby is deprecated and will be removed in a future version.;pandas 3.0.6:TypeError: DataFrame.groupby() got an unexpected keyword argument 'axis'

as_index=False 的輸出(兩版相同):

  CustomerID  Revenue
0        C01     1100
1        C02      830
2        C03     1550
3        C04      385
4        C05     1220
5        C06      900

pandas 2.2.3 的 observed 警告原文:FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning.

執行後

type → pandas 3.0.6:pandas.api.typing.DataFrameGroupBy pandas 2.2.3:pandas.core.groupby.generic.DataFrameGroupBy print(…) → <pandas.api.typing.DataFrameGroupBy object at 0x…> ← 不是表格 .obj is df → True .ngroups → 6 .groups → {'C01': [2, 14], 'C02': [4, 11, 18], 'C03': [0, 5, 8, 13, 17], …}

新手常踩的坑

  • 欄名打錯:df.groupby('customerid') 在建立這一步就出錯,實測 KeyError: 'customerid'。
  • 以為 groupby 完就有結果:print 出來只有 <… object at 0x…>。一定要接 sum、max、count 這類方法才會算出數字。
  • 直接在分組物件上 nlargest:df.groupby('CustomerID').nlargest(3),實測 AttributeError: 'DataFrameGroupBy' object has no attribute 'nlargest'。

跟分組物件要 'Revenue' 這一欄:籃子還是同樣 6 個,但每個籃子裡只看金額,不管 OrderID、Category。交出一個新的 SeriesGroupBy 物件,仍然還沒算。

[
取東西緊貼在 groupby(…) 後面:跟 DataFrameGroupBy 物件要東西(它的 __getitem__)。
'Revenue'
鑰匙要加總的那一欄。
]
收尾交出 SeriesGroupBy(2.2:pandas.core.groupby.generic.SeriesGroupBy;3.0:pandas.api.typing.SeriesGroupBy)。

SeriesGroupBy 身上也有一個 .obj:裡面就是 Revenue 那 20 個金額(實測和 df['Revenue'] 的值相同)。分組規則沿用上一步,只是換成「只看這一欄」。

忘了 ['Revenue'] 會怎樣:連文字欄也被「加總」

df.groupby('CustomerID').sum() 不會報錯,但它會把每一欄都加總。文字的「加法」是把字串接起來,所以 OrderID、Category 變成一長串(兩版實測,終端機太窄時中間那欄用 ... 省略):

                                 OrderID  ... Revenue
CustomerID                                ...
C01                             O003O015  ...    1100
C02                         O005O012O019  ...     830
C03                 O001O006O009O014O018  ...    1550
C04         O002O004O007O010O013O016O020  ...     385
C05                             O008O017  ...    1220
C06                                 O011  ...     900

[6 rows x 3 columns]

被省略的 Category 也被接起來了,例如 C01 變成 ElectronicsHome。Revenue 那欄是對的,但得到的是一張 DataFrame,不是 Series,後面直接接 .nlargest(3) 會出錯。只想加數字欄可以寫 sum(numeric_only=True),最清楚的還是先選好 ['Revenue']。

新手常踩的坑

  • 欄名打錯:['revenue'],實測 KeyError: 'Column not found: revenue'(訊息和一般表格的 KeyError 不一樣)。
  • 寫成雙層方括號 [['Revenue']]:交出的是 DataFrameGroupBy,.sum() 變成只有一欄的 DataFrame,再接 .nlargest(3) 實測 TypeError: DataFrame.nlargest() missing 1 required positional argument: 'columns';要寫成 .nlargest(3, 'Revenue') 才行。
  • 寫成 df.groupby('CustomerID').Revenue 也可以跑(實測結果相同),但欄名有空格、或和方法撞名時就不行,方括號最保險。

叫 SeriesGroupBy 物件把每個籃子裡的金額加起來。這時候 pandas 才真的去讀那 20 個金額、一籃一籃加總,做出一個新的 Series:標籤是客戶編號,值是總額。

.sum
方法寫在 GroupBy 這一層(§1 觀念 4),SeriesGroupBy 繼承來的。
()
呼叫一定要有括號。沒有括號只拿到方法本身,不會加總。

sum 完整參數表(SeriesGroupBy.sum)

簽名:pandas 3.0.6 sum(numeric_only=False, min_count=0, skipna=True, engine=None, engine_kwargs=None);pandas 2.2.3 沒有 skipna,其他相同。

參數預設值白話實測
numeric_onlyFalse只加數字欄這裡只剩 Revenue,沒差;對整張表 df.groupby('CustomerID').sum(numeric_only=True) 就只剩 Revenue 一欄
min_count0籃子裡至少要有幾個「不是空格」的值才加,不夠就給 NaNsum(min_count=3):C01、C05、C06 只有 2、2、1 張,變成 NaN,整欄變成小數 float64
skipnaTrue(只有 pandas 3)跳過空格本題沒有空格,pandas 3 寫 skipna=False 結果不變;pandas 2.2.3 寫了 → TypeError: GroupBy.sum() got an unexpected keyword argument 'skipna'
engineNone計算引擎(預設用 pandas 內建的 cython;'numba' 要另外安裝)—
engine_kwargsNone給 numba 引擎的設定—
.sum() 交出的新 Series(兩版相同): CustomerID C01 1100 C02 830 C03 1550 C04 385 C05 1220 C06 900 Name: Revenue, dtype: int64 type → pandas.Series(2.2:pandas.core.series.Series) index.name → 'CustomerID' name → 'Revenue'

順序是 C01~C06(groupby 預設 sort=True),還沒照金額排。6 個加起來 5985,等於 20 張收據的總和。

  • 忘了括號:….sum.nlargest(3),實測 AttributeError: 'function' object has no attribute 'nlargest':.sum 只是那台機器本身,還沒開動。
  • 以為 sum 會順便排好:sum 只負責加總,順序照客戶編號;要前 3 名還得接 nlargest。直接 .sum().head(3) 會拿到 C01 1100、C02 830、C03 1550(實測),C02 根本不在前 3 名。

叫上一步的 Series「把值最大的 3 個挑出來,由大到小排」。交出一個 3 格的新 Series:C03 1550、C05 1220、C01 1100。

.nlargest
方法Series 設計圖自己寫的方法。名字就是 n largest:最大的 n 個。對應的 nsmallest 挑最小的。
(3)
位置引數對應參數 n。

nlargest 完整參數表(Series.nlargest)

簽名(兩版相同):nlargest(n=5, keep='first')

參數本題的值預設值白話
n35要幾個。不寫就是 5 個:實測 .nlargest() 交出 C03、C05、C01、C06、C02。n 比資料還多時全部交出(nlargest(10) 交出 6 個);nlargest(0) 交出空的 Series
keep—'first'同分卡在名次邊界時留誰:'first' 留先出現的、'last' 留後出現的、'all' 全部留下(可能超過 n 個)

本題的 6 個總額沒有同分,keep 怎麼設都一樣。假設 C06 的總額也是 1100,和 C01 並列第 3(實測):

keep結果
'first'(預設)C03 1550、C05 1220、C01 1100(C01 排在前面,先出現)
'last'C03 1550、C05 1220、C06 1100
'all'C03 1550、C05 1220、C01 1100、C06 1100(4 個)
最後交出的 Series(兩版相同): CustomerID C03 1550 C05 1220 C01 1100 Name: Revenue, dtype: int64 .index.tolist() → ['C03', 'C05', 'C01'] ← 前 3 名客戶的名單 .equals(s.sort_values(ascending=False).head(3)) → True

它和「整串排好再 head(3)」結果相同,但不必把每個人都排好名次:§2 打開原始碼看過,它先用 kth_smallest 找出第 3 名的門檻,只排過門檻的幾個。這就是題目說的「高效」。

  • 對文字欄用 nlargest:df['CustomerID'].nlargest(3),實測 TypeError: Cannot use method 'nlargest' with dtype object(pandas 3 是 dtype str)。nlargest 只能用在數字上。
  • 把 3 寫成字串:nlargest('3'),實測 TypeError: '<=' not supported between instances of 'str' and 'int'。
  • 在 sum 之前就 nlargest:df.groupby('CustomerID')['Revenue'].nlargest(1) 可以跑,但意思是「每位客戶自己最大的 1 張收據」(§5 常見寫錯有輸出)。

數 CustomerID 這一欄每個值出現幾次(每人有幾張收據),由多到少排好,交出一個 Series;再拿前 3 格。完全沒有看金額。

.value_counts
方法DataFrame 設計圖自己寫的方法:數「每種組合出現幾次」。
'CustomerID'
位置引數對應參數 subset:只看這一欄。
.head(3)
方法和 A、B 的 head 相同:照目前順序拿前 3 格。因為 value_counts 已經照次數排好,這次拿到的是次數最多的 3 個。

value_counts 完整參數表(DataFrame.value_counts)

簽名(兩版相同):value_counts(subset=None, normalize=False, sort=True, ascending=False, dropna=True)

參數選項 D 的值預設值白話+實測
subset'CustomerID'None看哪幾欄。None 是「所有欄一起看」:每列的 4 個值組合都不同,每種都是 1 次。寫成清單 ['CustomerID'] 時,結果的標籤會變成 MultiIndex(多層標籤),印出來的空白比較多
normalize—FalseTrue:改成比例,C04 0.35、C03 0.25、C02 0.15、C01 0.10、C05 0.10、C06 0.05,名字變成 proportion
sort—True照次數排。False 不排:pandas 2.2.3 照客戶編號(C01、C02…),pandas 3.0.6 照第一次出現(C03、C04、C01…)
ascending—FalseFalse 由多到少;True 由少到多,.head(3) 會變成 C06 1、C01 2、C05 2
dropna—True空的(NaN)不數
value_counts('CustomerID') 交出的 Series(6 格): C04 7、C03 5、C02 3、C01 2、C05 2、C06 1 ← 名字是 count .head(3) 之後(兩版相同): CustomerID C04 7 C03 5 C02 3 Name: count, dtype: int64

§2 看過原始碼:DataFrame 的 value_counts 在裡面呼叫 groupby,數每個籃子有幾列,所以它也是分籃子,只是「套用」的是數張數。Series 也有 value_counts(參數 normalize、sort、ascending、bins、dropna),df['CustomerID'].value_counts() 結果相同。

  • 把「次數」當成「金額」:C04 來了 7 次最多,總額卻只有 385,是 6 個人裡最少的。
  • 以為結果的名字是 CustomerID:pandas 2.0 起叫 count(最後一行 Name: count)。
  • 欄名打錯:df.value_counts('customerid'),實測 KeyError: 'customerid'。
§4

互動實驗:收據分籃子,四個選項各拿收據做什麼?

下面的工具用 JavaScript 照 pandas 的規則即時計算:排序、分組、每籃加總/取最大/數張數、nlargest 的同分規則都一樣。本頁用 Node.js 跑過同一段計算程式,和 pandas 2.2.3、3.0.6 實際輸出逐字比對了 411 種情況(原始資料,加上隨機改一張收據的 400 種和刻意做出同分的 10 種),全部一致。只有一個例外要先說:選項 A 遇到同分的收據時,pandas 預設的排序不保證誰先誰後,這裡固定照原本的列順序(等於加上 kind='stable'),工具裡出現同分時會提醒你。

收據分籃子:四個選項各看什麼?
每一格是一張收據(df 的一列),左上角小字是列標籤,顏色代表客戶。先按「分籃子」看 groupby 做了什麼,再按 A~D 看四個選項;最下面可以改任一張收據的金額,看哪些選項的答案跟著變。
20 張收據

6 個籃子
程式印出來的結果(和 pandas 2.2.3 一字不差;pandas 3.0.6 只有 A 最後一行是 dtype: str)

    
金額:900 元(原本 900 元)

表格比較四個選項「原始資料」和「改過之後」的答案,橘色代表變了。金額可以拖到 0~2000 元(每格 5 元)。

可以試試看

① 按 A:收據重新排隊,亮起來的是 O011、O008、O005 三張,屬於 C06、C05、C02,沒有任何籃子。② 按 B:每籃框出最大的一張,但被選中的永遠是前三籃 C01、C02、C03。③ 把 O017(C05 的 520 元)拖到 900:A 的前 3 張變成 O011、O017、O008,C05 出現兩次;C 的第一名換成 C05(1600)。④ 把 O004(C04 的 55 元)拖到 1000:A 的第一張和 C 的第二名都變成 C04(總額 1330),但 D 完全不動,因為 D 只數張數。⑤ 把 O011 拖到 100:A 換成 C05、C02、C01,C 一點都沒變,因為 C06 本來就不在總額前 3 名。

看動畫 3 之前:這支要回答什麼

上面的實驗讓你自己按四個選項。動畫 3 用同一疊收據,把四個選項一個一個演一次,再把四個答案排在一起;最後用 40 根柱子說明題目裡的「高效」:為什麼 nlargest 不用把全部數字排好。

帶著三個問題看:① 選項 A 拿到的 3 列,是 3 位客戶還是 3 筆交易?② 選項 B 的 head(3) 為什麼剛好是 C01、C02、C03?③ nlargest 比「先排序再取前 3」少做了什麼?

動畫 3四個選項各算出什麼2 分 4 秒・有旁白,也有字幕

速度是在這台電腦實測一百萬個亂數取前 3(pandas 3.0.6:12.4 ms 對 54.1 ms;pandas 2.2.3:16.9 ms 對 57.6 ms),每台電腦的數字都不一樣;20 筆的小資料感覺不到差別。40 根柱子的動畫只是示意。

看完動畫 3,你應該能回答:選項 A 拿到的是 3 位客戶,還是 3 筆交易?
3 筆交易:單筆金額最大的 O011 900、O008 700、O005 680,再只留下客戶欄,變成 C06、C05、C02。它不是客戶總額;如果同一位客戶有兩筆大單,他還會出現兩次。
選項 B 的 head(3) 為什麼剛好是 C01、C02、C03?
groupby 預設 sort=True,會照客戶編號排好;head(3) 只拿最前面 3 列,跟金額大小無關。而且 max 取的是每位客戶的單筆最高,不是總額。
選項 D 算的是什麼?
交易次數(結果名稱是 count):C04 7 次、C03 5 次、C02 3 次。C04 次數最多,總額卻最少。
nlargest 比「先排序再取前 3」少做了什麼?
不把全部數字排序。它先找出第 3 名的門檻值,只把超過門檻的少數幾個拿出來排;sort_values 則要把整串排好,才拿前 3 個。
§5

回到題目:逐項對答案

先把四行程式在本頁資料上的實際輸出排在一起(兩個 pandas 版本相同,只有 A 最後一行的 dtype 不同):

選項程式在回答的問題實際結果是不是「總貢獻前 3 名」
A金額最大的 3 張收據是誰的C06、C05、C02(那 3 張是 900、700、680)不是:單筆,不是總額
B客戶編號最前面 3 位,各自單筆最高多少C01 560、C02 680、C03 350不是:最大值+拿前 3 列
C每人總額,最大的 3 位C03 1550、C05 1220、C01 1100是
D交易次數最多的 3 位C04 7、C03 5、C02 3不是:次數,不是金額

四個答案沒有一個相同。A、B、D 都是語法完全正確、跑得出結果的程式,錯在回答了別的問題。這種錯誤 Python 不會提醒你,只能靠自己想清楚「要算什麼」。

(A) df.sort_values('Revenue', ascending=False).head(3)['CustomerID'] 錯

把 20 張收據照金額排隊、拿前 3 張,看的是「單筆交易」。結果 10 C06、7 C05、4 C02:左邊是收據的列標籤,右邊是客戶。它找到的是單筆最大的 3 筆交易,完全沒有「同一位客戶加起來」:C06 那張 900 最大,但他總共只買這一次;總額第 1 的 C03 一張都沒上榜。而且同一位客戶可以出現兩次(拿 5 張時 C01 就出現兩次),「前 3 張收據」不等於「前 3 位客戶」。

(B) df.groupby('CustomerID')['Revenue'].max().head(3) 錯

錯兩次。第一,max() 是每人「最大的一張」,不是總額:C03 每張都不大(最高 350),總額卻是第 1。第二,head(3) 只拿前 3 列,不看大小;groupby 預設 sort=True 把籃子照客戶編號排,所以結果永遠是 C01、C02、C03。就算把 head 換成 nlargest,….max().nlargest(3) 也只會得到 C06 900、C05 700、C02 680,還是單筆最大。

(C) df.groupby('CustomerID')['Revenue'].sum().nlargest(3) 正解

照客戶分籃子 → 只看金額 → 每籃加總 → 挑最大的 3 個,剛好是「總貢獻營收最高的前 3 名」的定義。結果 C03 1550、C05 1220、C01 1100,標籤是客戶編號、值是總額,一眼就能看。最後一步用 nlargest 而不是先整串排序再 head,不必替每位客戶排名次,資料越大越省工。

(D) df.value_counts('CustomerID').head(3) 錯

value_counts 數的是每個客戶編號出現幾次,也就是交易次數:C04 7 次、C03 5 次、C02 3 次。它完全沒有看 Revenue。C04 來得最勤,但每張只有 40~70 元,總額 385 是 6 個人裡最少的;買得多不等於買得貴。

題目的「精確」和「高效」各在說什麼

精確:程式算出來的,就是題目要的東西。「總貢獻」=加總(sum),「前 3 名」=照總額取最大的 3 個(nlargest)。A 算單筆、B 算單筆又拿錯位置、D 算次數,三個都不精確,只有 C 精確。

高效:同樣精確的寫法還有 ….sum().sort_values(ascending=False).head(3),結果和 C 完全相同(實測 equals 是 True),但它要把每位客戶都排好名次。nlargest 先找出第 3 名的門檻,只排過門檻的幾個。§2 實測一百萬個數字時快了約 3~4 倍;不過只有 6 位客戶時,nlargest 因為準備工作較多反而稍慢(都是零點幾毫秒以內)。題目的「高效」說的是資料變大時的做法。

常見寫錯(兩版實測結果相同)
  1. 忘了 ['Revenue']:df.groupby('CustomerID').sum() 不會報錯,但每一欄都被「加總」,文字被接起來:C01 的 OrderID 變成 O003O015,C04 變成 O002O004O007O010O013O016O020,Category 也一樣。只要 Revenue 那欄,請先選 ['Revenue'](或寫 sum(numeric_only=True))。
  2. 在分組物件上直接 nlargest:df.groupby('CustomerID').nlargest(3) → AttributeError: 'DataFrameGroupBy' object has no attribute 'nlargest'。nlargest 只寫在 SeriesGroupBy 和 Series 的設計圖上。
  3. nlargest 放在 sum 前面:df.groupby('CustomerID')['Revenue'].nlargest(1) 可以跑,但它是 SeriesGroupBy 的 nlargest,意思是「每位客戶自己最大的 1 張收據」,交出兩層標籤(客戶+原本的列標籤):
    CustomerID
    C01         2     560
    C02         4     680
    C03         0     350
    C04         15     70
    C05         7     700
    C06         10    900
    Name: Revenue, dtype: int64
  4. 欄名打錯:['revenue'] → KeyError: 'Column not found: revenue';df.sort_values('revenue') → KeyError: 'revenue';df.groupby('customerid') → KeyError: 'customerid'。
  5. sum 忘了括號:….sum.nlargest(3) → AttributeError: 'function' object has no attribute 'nlargest'。

換個問法,答案就換一行程式

四個錯誤選項不是「爛程式」,它們在回答別的問題。下表全部用本頁資料實測:

如果題目問寫法本頁資料的答案
總貢獻最高的 3 位客戶(本題)df.groupby('CustomerID')['Revenue'].sum().nlargest(3)C03 1550、C05 1220、C01 1100
單筆金額最大的 3 筆交易選項 A,或 df.nlargest(3, 'Revenue')O011(C06)900、O008(C05)700、O005(C02)680
交易次數最多的 3 位客戶選項 DC04 7、C03 5、C02 3
單筆最高金額最高的 3 位客戶….max().nlargest(3)C06 900、C05 700、C02 680
平均每筆最高的 3 位客戶….mean().nlargest(3)C06 900.0、C05 610.0、C01 550.0
總貢獻最低的 3 位客戶….sum().nsmallest(3)C04 385、C02 830、C06 900

表中的 …. 代表 df.groupby('CustomerID')['Revenue']。

§6

動手跑跑看:下載教學資料,兩個 pandas 版本都跑一次

先下載本頁的教學資料 orders.csv(20 列 × 4 欄,469 位元組),和下面的程式放在同一個資料夾,在那個資料夾開終端機執行 python 檔名.py。用 Google Colab 的話,先從左側「檔案」面板把 CSV 上傳到工作階段。同一個情境的 customers.csv(客戶地區)是第 47 題合併表格用的,這題用不到。

程式 1:四個選項各印出來,再加一張總表

在 .py 檔裡,選項那樣「只寫一行運算」不會顯示任何東西,所以這裡都包上 print()。Jupyter/Colab 的儲存格只會自動顯示最後一行的結果。

import pandas as pd

df = pd.read_csv('orders.csv')
print('pandas 版本:', pd.__version__)
print('表格大小(列, 欄):', df.shape)

print('--- (A) 金額最大的 3 張收據,是誰的 ---')
print(df.sort_values('Revenue', ascending=False).head(3)['CustomerID'])
print('--- (B) 每人單筆最高,再拿前 3 列 ---')
print(df.groupby('CustomerID')['Revenue'].max().head(3))
print('--- (C) 每人加總,再挑最大的 3 個(正解)---')
print(df.groupby('CustomerID')['Revenue'].sum().nlargest(3))
print('--- (D) 每人有幾張收據 ---')
print(df.value_counts('CustomerID').head(3))

# 加碼:一次看每位客戶的總額、單筆最高、交易次數
print(df.groupby('CustomerID')['Revenue'].agg(['sum', 'max', 'count']))
實際跑出來的結果(pandas 2.2.3;pandas 3.0.6 只差兩行)
pandas 版本: 2.2.3                       ← pandas 3 是 3.0.6
表格大小(列, 欄): (20, 4)
--- (A) 金額最大的 3 張收據,是誰的 ---
10    C06
7     C05
4     C02
Name: CustomerID, dtype: object          ← pandas 3 是 dtype: str
--- (B) 每人單筆最高,再拿前 3 列 ---
CustomerID
C01    560
C02    680
C03    350
Name: Revenue, dtype: int64
--- (C) 每人加總,再挑最大的 3 個(正解)---
CustomerID
C03    1550
C05    1220
C01    1100
Name: Revenue, dtype: int64
--- (D) 每人有幾張收據 ---
CustomerID
C04    7
C03    5
C02    3
Name: count, dtype: int64
             sum  max  count
CustomerID
C01         1100  560      2
C02          830  680      3
C03         1550  350      5
C04          385   70      7
C05         1220  700      2
C06          900  900      1

「← pandas 3 是…」是本頁加的註解,程式不會印出來。

程式 2:把選項 C 拆開,每一步都貼上名牌看看

import pandas as pd

df = pd.read_csv('orders.csv')

g = df.groupby('CustomerID')          # ① 分籃子
print(g)                              # 只印出「哪張設計圖做的、住在哪」
print('籃子數:', g.ngroups)
print('每個籃子是哪幾列:', g.groups)
print('g 指著原本那張 df 嗎?', g.obj is df)
print(g.get_group('C03'))             # 打開 C03 的籃子看看

sg = g['Revenue']                     # ② 每個籃子只看金額
print(type(sg))

s = sg.sum()                          # ③ 這時候才真的加總
print(type(s))
print(s)

top = s.nlargest(3)                   # ④ 挑最大的 3 個
print(top)
print('前 3 名客戶:', top.index.tolist())
實際跑出來的結果(pandas 3.0.6)
<pandas.api.typing.DataFrameGroupBy object at 0x000001A04DA17E00>
籃子數: 6
每個籃子是哪幾列: {'C01': [2, 14], 'C02': [4, 11, 18], 'C03': [0, 5, 8, 13, 17], 'C04': [1, 3, 6, 9, 12, 15, 19], 'C05': [7, 16], 'C06': [10]}
g 指著原本那張 df 嗎? True
   OrderID CustomerID     Category  Revenue
0     O001        C03  Electronics      350
5     O006        C03         Home      320
8     O009        C03     Clothing      310
13    O014        C03         Home      290
17    O018        C03        Books      280
<class 'pandas.api.typing.SeriesGroupBy'>
<class 'pandas.Series'>
CustomerID
C01    1100
C02     830
C03    1550
C04     385
C05    1220
C06     900
Name: Revenue, dtype: int64
CustomerID
C03    1550
C05    1220
C01    1100
Name: Revenue, dtype: int64
前 3 名客戶: ['C03', 'C05', 'C01']

pandas 2.2.3 只有這三行不同(其他完全一樣):

<pandas.core.groupby.generic.DataFrameGroupBy object at 0x000001F7C0BE78C0>
<class 'pandas.core.groupby.generic.SeriesGroupBy'>
<class 'pandas.core.series.Series'>

第一行的位址(0x… 那一串)是那個物件住的置物櫃號碼,每台電腦、每次執行都不一樣,這是正常的。

程式 3:常見寫錯,以及「高效」到底差多少

import time
import numpy as np
import pandas as pd

df = pd.read_csv('orders.csv')

# 寫錯 1:忘了 ['Revenue'],連文字欄也被「加總」:字串會被接起來
print(df.groupby('CustomerID').sum()['OrderID'])

# 寫錯 2:GroupBy 物件(DataFrameGroupBy)沒有 nlargest
try:
    df.groupby('CustomerID').nlargest(3)
except AttributeError as e:
    print('AttributeError:', e)

# 寫錯 3:在 sum() 之前就 nlargest:變成「每位客戶自己最大的 1 筆」
print(df.groupby('CustomerID')['Revenue'].nlargest(1))

# 「高效」:一百萬個亂數取最大的 3 個,各跑 5 次取平均
big = pd.Series(np.random.default_rng(45).random(1_000_000))
for name, f in [('nlargest(3)', lambda: big.nlargest(3)),
                ('sort_values().head(3)', lambda: big.sort_values(ascending=False).head(3))]:
    t0 = time.perf_counter()
    for _ in range(5):
        f()
    print(name, '平均', round((time.perf_counter() - t0) / 5 * 1000, 2), '毫秒')
print('兩種寫法結果一樣嗎?', big.nlargest(3).equals(big.sort_values(ascending=False).head(3)))

# 只有 6 個數字時呢?各跑 1000 次取平均
s = df.groupby('CustomerID')['Revenue'].sum()
for name, f in [('nlargest(3)', lambda: s.nlargest(3)),
                ('sort_values().head(3)', lambda: s.sort_values(ascending=False).head(3))]:
    t0 = time.perf_counter()
    for _ in range(1000):
        f()
    print('6 個數字', name, '平均', round((time.perf_counter() - t0) / 1000 * 1e6, 1), '微秒')
實際跑出來的結果:前半兩版相同,時間每次都不同
CustomerID
C01                        O003O015
C02                    O005O012O019
C03            O001O006O009O014O018
C04    O002O004O007O010O013O016O020
C05                        O008O017
C06                            O011
Name: OrderID, dtype: object             ← pandas 3 是 dtype: str
AttributeError: 'DataFrameGroupBy' object has no attribute 'nlargest'
CustomerID
C01         2     560
C02         4     680
C03         0     350
C04         15     70
C05         7     700
C06         10    900
Name: Revenue, dtype: int64

後半是計時,這一次的實測:

pandas 2.2.3

nlargest(3) 平均 20.28 毫秒
sort_values().head(3) 平均 59.25 毫秒
兩種寫法結果一樣嗎? True
6 個數字 nlargest(3) 平均 123.3 微秒
6 個數字 sort_values().head(3) 平均 31.6 微秒

pandas 3.0.6

nlargest(3) 平均 13.35 毫秒
sort_values().head(3) 平均 54.07 毫秒
兩種寫法結果一樣嗎? True
6 個數字 nlargest(3) 平均 199.7 微秒
6 個數字 sort_values().head(3) 平均 43.1 微秒
  • 一百萬筆:nlargest 這次快了約 2.9 倍(2.2.3)和 4.0 倍(3.0.6);6 個數字:nlargest 反而慢了約 4~5 倍。和 §2 用 timeit 量的數字略有不同,計時本來就會隨電腦和當下的負載變動,看相對大小就好。
  • 寫錯 3 的第一行 CustomerID 後面有幾個空白,是第二層標籤(原本的列標籤)沒有名字留下的位置。
新出現的語法是什麼白話
.agg(['sum', 'max', 'count'])GroupBy 的方法一次套用好幾個聚合函式,交出一張表格,每個函式一欄
g.ngroups、g.groups、g.objGroupBy 的屬性(沒有括號)籃子數、每個籃子是哪幾列、指回的原表格
g.get_group('C03')GroupBy 的方法打開某一個籃子,交出那幾列組成的新表格
top.index.tolist()屬性+方法拿出標籤那一排,再變成一般的 Python 清單
try: … except AttributeError as e:錯誤處理先試著執行,出了這種錯就改做 except 底下的事,程式不會中斷;e 是那個錯誤物件
lambda: big.nlargest(3)匿名函式把一行程式包成一台「還沒開動的小機器」,之後用 f() 才執行
for name, f in [(…), (…)]:for 迴圈清單裡每一組(名字、小機器)各跑一次縮排裡的程式
np.random.default_rng(45).random(1_000_000)NumPy 亂數用種子 45 做一個亂數產生器,產生一百萬個 0~1 的小數;種子固定,每次產生的數字都一樣。1_000_000 的底線只是方便閱讀
time.perf_counter()計時讀一個很精準的碼錶,前後相減就是經過的秒數
.equals(…)Series 的方法兩個 Series 的標籤和值是否完全相同
§7

一頁總結+自我檢測

這題要帶走的 5 件事
  1. 表格的一列是一張收據。「總貢獻」=同一位客戶的收據全部加總;「前 3 名」=照總額取最大的 3 個。答案是 C:df.groupby('CustomerID')['Revenue'].sum().nlargest(3) → C03 1550、C05 1220、C01 1100。
  2. 方法串接從左到右,每一步交出一個新物件:DataFrame → groupby → DataFrameGroupBy → ['Revenue'] → SeriesGroupBy → sum() → Series(6 格)→ nlargest(3) → Series(3 格)。方括號是「跟前面那個物件要東西」,要到什麼由物件決定。
  3. GroupBy 物件只記得分組規則、指回原本的 df,不複製收據,也還沒加總;叫 sum、max、count 這些聚合方法時才真的計算。
  4. A 看單張收據、B 看每人最大一張而且 head 只拿前 3 列(groupby 預設 sort=True,永遠是 C01~C03)、D 數張數:語法都對,但回答的是別的問題。
  5. head 是「前幾個」(照位置),nlargest 是「前幾名」(照數值)。nlargest 和 sort_values(ascending=False).head() 結果相同,但它先找門檻、只排過門檻的幾個,資料大時省很多(一百萬筆約快 3~4 倍)。
print(df.groupby('CustomerID')) 會印出一張表格嗎?
不會。它印出 <pandas.api.typing.DataFrameGroupBy object at 0x…>(pandas 2.2 是 pandas.core.groupby.generic.DataFrameGroupBy):這是一個照 DataFrameGroupBy 設計圖做出來的分組物件,只記得「照 CustomerID 分」和一條指回 df 的箭頭(g.obj is df 是 True),還沒有算任何東西。要接 sum、max 之類的方法才有數字。
選項 C 那一行,一共做出幾個新物件?各是哪張設計圖做的?
4 個:DataFrameGroupBy(groupby)→ SeriesGroupBy(['Revenue'])→ Series(sum,6 格)→ Series(nlargest,3 格)。選項裡沒有等號,4 個都沒有名牌;前 3 個是過渡,實測一行跑完後會被回收。
為什麼選項 B 的結果一定是 C01、C02、C03?
groupby 預設 sort=True,把籃子照客戶編號排成 C01~C06;head(3) 只照目前順序拿前 3 個,不看大小。改成 sort=False 會變成 C03、C04、C01(照第一次出現的順序),答案跟著客戶編號和下單順序跑,和貢獻大小無關。
選項 A 拿 3 張收據,為什麼可能不到 3 位客戶?
A 是排「收據」,同一位客戶的兩張大收據都可能上榜。本頁資料拿 5 張時(head(5)),結果是 C06、C05、C02、C01、C01,C01 的 560 和 540 都在裡面。
C04 交易次數最多(7 次),為什麼不在總貢獻前 3 名?
他的 7 張收據都很小(40~70 元),加起來只有 385,是 6 個人裡最少的。選項 D 數的是次數,所以 C04 排第 1;選項 C 加的是金額,所以 C04 排最後。
s.nlargest(3) 和 s.sort_values(ascending=False).head(3) 結果一樣嗎?差在哪?
一樣(實測 equals 是 True)。差在做法:sort_values 把 6 個全部排好名次,再拿前 3;nlargest 先用 kth_smallest 找出第 3 名的門檻(1100),只把過門檻的 3 個排好。一百萬筆時 nlargest 快約 3~4 倍;只有 6 個數字時它的準備工作反而讓它慢一點。
寫成 df.groupby('CustomerID').sum()(少了 ['Revenue'])會怎樣?
不會報錯,但每一欄都被加總:Revenue 是對的,OrderID 和 Category 的字串被接起來(C01 的 OrderID 變成 O003O015),而且交出的是 DataFrame,後面直接接 .nlargest(3) 會出錯。要先選 ['Revenue']。
nlargest(3) 遇到同分的第 3 名怎麼辦?
看參數 keep,預設 'first':留在 Series 裡先出現的那個。'last' 留後出現的,'all' 全部留下(可能超過 3 個)。本題 6 個總額沒有同分,所以 keep 不影響答案。