💡 先搞懂問題
晴空咖啡的會員系統有兩張表。顧客表記錄每位會員的編號 cid、姓名與所在城市;訂單表每一列是一筆消費,只記了 cid 和金額。行銷部想知道「台北的會員一共消費多少」,城市在顧客表、金額在訂單表,所以得先把兩張表依 cid 對起來。在試算表裡這是 VLOOKUP,在 SQL 裡是 JOIN,在 pandas 裡就是 merge。
merge 一行就寫完,麻煩在它幾乎不會報錯,算錯了也不會提醒你。訂單表裡有一筆 C09,顧客表找不到這個人(可能是測試資料或已刪除的帳號),用預設的接法,這筆 150 元的訂單就安靜地消失了。反過來,如果顧客表裡 C01 因為改地址被建檔了兩次,他的每一筆訂單都會被複製成兩列,營收跟著灌水。有時候金額欄還會從 120 變成 120.0,因為有一列對不到、被補了缺值。
所以讀 merge 的程式,要回答三個問題:用哪一欄當鍵、對不到的列怎麼處理、鍵有沒有重複。這三個問題決定結果有幾列、哪些欄位會出現 NaN。
生活比喻:社團迎新的報名表與繳費紀錄
社團總務小陳手上有兩份名單:一份是迎新活動的報名表(學號、姓名),一份是銀行匯款的繳費紀錄(學號、金額)。他要做一張「誰報了名、繳了多少」的總表。只列兩邊都有的人,是最保守的做法;以報名表為主、沒繳費的人金額欄留白,方便催繳;以繳費紀錄為主,可以找出「有人匯了錢卻沒報名」;兩份全部列出、對不到的留白,則是完整對帳。
麻煩出在重複。如果某位同學匯了兩次款(分期繳),他在總表上自然會出現兩列;但如果報名表上他也不小心被登記了兩次,認真照表核對的話,兩筆報名乘兩筆匯款,總表上他就有四列,金額看起來翻倍。另外,小陳偶爾也會把下學期的報名表直接疊在這學期的下面,那只是把兩疊紙疊成一疊,完全不需要核對學號。
on='cid'。只列兩邊都有的人是 how='inner'(預設);以報名表為主是 how='left';以繳費紀錄為主是 how='right';兩份全列是 how='outer'。留白的欄位就是 NaN。一位同學匯兩次款是一對多(one-to-many),兩邊都重複是多對多(many-to-many),列數是兩邊重複次數相乘。把兩疊紙疊在一起不核對學號,是 pd.concat。
這個比喻有三個地方要修正。第一,小陳看到同一位同學被登記兩次,會停下來問是不是重複;pandas 不會,它照規則把每一對相等的鍵都配一次,列數直接相乘,所以要自己加 validate 檢查。第二,小陳會把「B10901」和「b10901」認成同一個人,pandas 只認完全相等的值,大小寫、前後空白都會造成對不到;鍵的型別也要一致,一邊是字串、一邊是整數時,pandas 3 會直接丟出 ValueError。第三,總表的列順序有規則:inner 與 left 保留左表的鍵順序,right 保留右表的,outer 則把鍵依字典順序排序。
🎮 互動實驗室一:四種 how 的連線動畫
左邊是顧客表(4 位會員,其中 C04 沒下過單),右邊是訂單表(5 筆,C01 有兩筆、C09 是查無此人的孤兒訂單)。切換 how,連線會畫出哪幾對列配成一列,被丟掉的列會淡掉、補 NaN 的列會標出來。勾選「顧客表重複建檔 C01」,看多對多讓列數怎麼變多。下方的結果表與 print 輸出都是 pandas 3 實際執行的結果。
🎮 互動實驗室二:筆數預測器
左表 L 有 a 列鍵是 A、另有一列 B;右表 R 有 b 列鍵是 A、另有一列 C。調整 a、b 與 how,先從四個數字裡猜 len(L.merge(R, on='k', how=…)) 是多少,按下後才揭曉連線與算式,並列出四種 validate 寫法哪些會丟出 MergeError。每一組數字都在 pandas 3 實跑核對過。
🎮 互動實驗室三:merge、join 還是 concat
每張卡是一個原創的資料整理情境,請從六種寫法裡選出最適合的一個。判斷時先問:兩張表要不要「依某個鍵比對」?要比對的話,是用欄位還是索引當鍵、對不到的列要不要留?不比對的話,是上下接(加列)還是左右接(加欄)?答完右邊的工具箱會亮起正解。
📘 原理補完
1. merge 的配對規則:每一對相等的鍵產生一列
left.merge(right, on='cid', how='inner') 和 pd.merge(left, right, on='cid') 是同一件事。pandas 會找出兩邊鍵值相等的每一對列,每一對產生結果的一列,左表的欄位在前、右表的欄位在後,鍵欄只保留一份。how 只決定「對不到的列怎麼辦」:inner 丟掉、left 保留左表的、right 保留右表的、outer 兩邊都保留,保留下來那一側以外的欄位補 NaN。沒寫 on 時,pandas 會拿兩表所有同名欄位當鍵,欄名剛好重複時容易出錯,所以建議一律明確寫出 on。
| how | 保留哪些鍵 | 對不到的欄位 | 列的順序 | 對應 SQL | 晴空咖啡列數 |
|---|---|---|---|---|---|
| 'inner'(預設) | 兩邊都有的 | (不會出現) | 左表鍵的順序 | INNER JOIN | 4 |
| 'left' | 左表全部 | 右表欄位補 NaN | 左表鍵的順序 | LEFT JOIN | 5(多 C04) |
| 'right' | 右表全部 | 左表欄位補 NaN | 右表鍵的順序 | RIGHT JOIN | 5(多 C09) |
| 'outer' | 兩邊聯集 | 兩邊都可能補 NaN | 鍵依字典順序排序 | FULL OUTER JOIN | 6 |
| 'cross' | 不看鍵 | 不會有 | 左表順序 | CROSS JOIN | 4 × 5 = 20 |
| 'left_anti' | 只在左表的鍵 | 右表欄位全是 NaN | 左表鍵的順序 | LEFT JOIN … IS NULL | 1(C04) |
最後兩列比較少見:cross 是兩表所有列的兩兩組合(笛卡兒積),left_anti/right_anti 是 pandas 3.0 新增的反連接,直接取出「只在一邊出現」的列。舊版本要用 indicator 篩 left_only 才做得到,後面第 5 節會看到。
2. 一對多、多對多:列數是乘出來的
同一個鍵在左表出現 m 次、在右表出現 n 次,merge 後就產生 m × n 列。一對一(m=n=1)列數不變;一對多是正常的業務關係,例如一位顧客有很多筆訂單,結果的列數跟著「多」的那一邊;麻煩的是多對多,通常代表某一張表裡有不該重複的鍵。列數變多的副作用是顧客那一側的數值被複製,接著做 sum 就會重複計算。要在合併時就把關,可以加 validate:'one_to_one'(兩邊鍵都要唯一)、'one_to_many'(左表鍵唯一)、'many_to_one'(右表鍵唯一)、'many_to_many'(不檢查),條件不符時丟出 pandas.errors.MergeError。
3. 對不到的欄位:NaN 與型別變化
left、right、outer 會把對不到的那一側欄位補成缺值。文字欄在 pandas 3 是 str 型別,可以直接放 NaN,型別不變;整數欄(NumPy 的 int64)放不下 NaN,整欄會升格成 float64,所以訂單編號 101 會顯示成 101.0。inner 不會產生缺值,型別維持 int64。想保留整數,可以在合併後轉成 pandas 的可空整數(nullable integer) Int64(大寫 I),缺值會顯示為 <NA>;或在合併前就先把欄位轉成 Int64。
4. 欄名不同、欄名撞名:left_on/right_on 與 suffixes
兩表的鍵欄名不同時,用 left_on='customer_id', right_on='cid' 分別指定,結果會保留兩個鍵欄,需要時再 drop 掉其中一個。兩表除了鍵之外還有同名欄位時,pandas 不會覆蓋,而是加上後綴,預設 suffixes=('_x', '_y'),左表的加 _x、右表的加 _y;寫成 suffixes=('_送貨', '_會員') 可讀性好很多。鍵本身的型別也要一致,例如左邊 cid 是字串、右邊是整數時,pandas 3 會丟出 ValueError,提醒你兩邊型別不同。
5. indicator=True 與反連接
indicator=True 會在結果最後加一欄 _merge,型別是 category,值是 both、left_only、right_only 三種之一,告訴你每一列是怎麼來的。搭配 outer 就是對帳表:value_counts() 一行看出有幾筆配不上。只留 left_only 的列,就是反連接(anti-join):找出「左表有、右表沒有」的資料,例如從未下單的顧客、帳號表裡沒有登入紀錄的閒置帳號。pandas 3.0 起也可以直接寫 how='left_anti'。
6. merge、join、concat 的分工
三個工具的差別在「依什麼對齊」。merge 依欄位的值配對,最有彈性,也是讀程式時最常見的。join 是 DataFrame 的方法,用右表的索引當鍵,預設 how='left';可以用 on='欄名' 指定左表的某一欄去對右表的索引;兩表有同名欄時必須給 lsuffix 或 rsuffix,否則會報錯。反過來,merge 也能處理索引:on 可以寫索引層級的名稱,或用 left_index=True/right_index=True,所以 join 能做的 merge 都做得到,join 只是寫起來比較短。pd.concat 不比對任何鍵值,只是把多張表接起來:axis=0(預設)上下疊、增加列,欄名不同的地方補 NaN;axis=1 左右並排、增加欄,依索引對齊,預設 join='outer',索引對不到的位置補 NaN。上下疊時原本的索引會重複,常加 ignore_index=True 重新編號,或用 keys= 標示每一塊的來源。
| 寫法 | 依什麼對齊 | 預設 | 結果變化 | 典型用途 |
|---|---|---|---|---|
| left.merge(right, on=…) | 欄位的值 | how='inner' | 依配對數決定列數 | 訂單補上顧客資料、對帳 |
| left.join(right) | 右表的索引(左表用索引或 on 欄位) | how='left' | 以左表為主,加欄 | 以日期、編號為索引的表並排 |
| pd.concat([a, b]) | 不比對,axis=0 依欄名排欄位 | axis=0、join='outer' | 加列,欄位取聯集 | 多個月份、多家門市的同格式報表 |
| pd.concat([a, b], axis=1) | 索引 | join='outer' | 加欄,索引取聯集 | 同一批樣本的不同特徵並排 |
7. 判斷步驟:寫或讀一行合併程式時
- 要不要比對鍵:不比對、只是接起來,用 concat;上下加列是 axis=0,左右加欄是 axis=1。
- 鍵在哪裡:鍵在欄位用 merge(欄名不同就 left_on/right_on),鍵在右表的索引用 join 或 merge(right_index=True)。
- 對不到的列要不要留:只要配得上的用 inner,以某一表為主用 left 或 right,對帳或找差異用 outer 加 indicator。
- 鍵會不會重複:先用
df['cid'].duplicated().any()或value_counts()看一下,合併時加 validate;列數 = 每個鍵兩邊次數相乘再加總。 - 合併後檢查:比對合併前後的列數與金額總和、看哪些欄出現 NaN 或型別變成 float64、撞名的欄位是否加了後綴。
8. 兩段帶註解的完整範例
第一段:四種 how 的列數、indicator 對帳、把 float64 轉回可空整數、兩種反連接寫法,以及 validate 防呆。註解裡的數字都是實際執行的輸出。
import pandas as pd
customers = pd.DataFrame({'cid': ['C01', 'C02', 'C03', 'C04'],
'name': ['王小明', '林雅婷', '陳志豪', '張美玲'],
'city': ['台北', '台中', '高雄', '台北']})
orders = pd.DataFrame({'oid': [101, 102, 103, 104, 105],
'cid': ['C01', 'C02', 'C01', 'C09', 'C03'],
'amount': [120, 260, 90, 150, 55]})
for how in ['inner', 'left', 'right', 'outer']:
print(how, len(customers.merge(orders, on='cid', how=how))) # 4 5 5 6
full = customers.merge(orders, on='cid', how='outer', indicator=True)
print(full['_merge'].value_counts().to_dict()) # {'both': 4, 'left_only': 1, 'right_only': 1}
print(full['oid'].dtype) # float64:C04 沒訂單,整數欄被 NaN 撐成浮點數
full = full.astype({'oid': 'Int64', 'amount': 'Int64'}) # 可空整數:缺值顯示 <NA>
print(full['oid'].tolist()) # [101, 103, 102, 105, <NA>, 104]
no_order = full.loc[full['_merge'] == 'left_only', 'name'].tolist()
print(no_order) # ['張美玲']:沒下過單的顧客(anti-join)
orphan = orders.merge(customers, on='cid', how='left_anti') # pandas 3.0 起才有的寫法
print(orphan['oid'].tolist()) # [104]:孤兒訂單
try:
orders.merge(customers, on='cid', validate='many_to_one') # 每筆訂單最多對到一位顧客
print('many_to_one 檢查通過')
customers.merge(orders, on='cid', validate='one_to_one')
except pd.errors.MergeError as e:
print('MergeError') # orders 的 C01 有兩筆,不是一對一
第二段:欄名不同與撞名的處理、以索引為鍵的 join,以及 concat 的上下疊與左右並排。
import pandas as pd
orders = pd.DataFrame({'oid': [101, 102, 103],
'customer_id': ['C01', 'C02', 'C01'],
'city': ['台北', '台中', '新竹']}) # 送貨城市
members = pd.DataFrame({'cid': ['C01', 'C02'],
'city': ['台北', '台中']}) # 會員登記的城市
# 鍵的欄名不同:left_on/right_on;同名的非鍵欄位加上後綴
r = orders.merge(members, left_on='customer_id', right_on='cid',
suffixes=('_送貨', '_會員'))
print(r.columns.tolist()) # ['oid', 'customer_id', 'city_送貨', 'cid', 'city_會員']
# join:以「右表的索引」當鍵,預設 how='left'
j = orders.join(members.set_index('cid'), on='customer_id', rsuffix='_會員')
print(j.shape) # (3, 4)
# concat axis=0:欄位相同的表上下疊,ignore_index 重新編號
jan = pd.DataFrame({'oid': [1, 2], 'amount': [100, 200]})
feb = pd.DataFrame({'oid': [3], 'amount': [300]})
print(pd.concat([jan, feb], ignore_index=True).index.tolist()) # [0, 1, 2]
# concat axis=1:依索引左右並排,對不到的位置補 NaN
x = pd.DataFrame({'rev': [100, 200]}, index=['d1', 'd2'])
y = pd.DataFrame({'temp': [30, 28]}, index=['d2', 'd3'])
print(pd.concat([x, y], axis=1).shape) # (3, 2):d1、d2、d3 都留下
9. 容易寫錯或考錯的地方
預設是 inner:沒寫 how 就是 inner,對不到的列直接消失,不會有任何警告。題目問「合併後有幾列」,先找出兩邊都有的鍵,再看每個鍵的重複次數。
列數是乘法:鍵在左表出現 m 次、右表 n 次,就是 m × n 列;把各個鍵的乘積加起來,再加上 left/right/outer 保留下來的未配對列。干擾選項常給 m + n 或較大的那一個。
left 不保證列數不變:很多人以為 left join 之後列數一定等於左表,但右表的鍵若重複,左表那一列一樣會被複製。想確保列數不變,加 validate='many_to_one'。
出現 .0 的原因:整數欄只要被補了一個 NaN 就變 float64;inner 不會補 NaN,所以 inner 的結果通常維持 int64。轉回整數要用可空的 Int64,直接 astype('int64') 遇到 NaN 會報錯。
NaN 鍵不會被當成「對不到」:pandas 的 merge 會把兩邊鍵都是 NaN 的列配在一起,這和 SQL 的 NULL 不相等不同。合併前最好先處理鍵的缺值。
outer 會排序:inner、left 保留左表鍵的順序,right 保留右表的,outer 依鍵排序;問「第一列是誰」時要先確認 how。
concat 不是 merge:concat(axis=1) 依索引對齊、不看欄位的值;兩張表索引不同(例如一張 0~4、一張被篩選過)時,會出現一堆 NaN 列。上下疊之後索引會重複,用 loc[0] 可能取到兩列。
版本差異:pandas 3.0 新增 how='left_anti'、'right_anti',merge 與 join 都能用;文字欄預設型別是 str,缺值統一顯示 NaN;copy 參數在 3.0 起已無作用(Copy-on-Write)。在 2.x 版本上跑 left_anti 會報錯,教材若用 indicator 篩 left_only,兩種寫法結果相同。
✅ 自我檢測
6 題原創的程式閱讀題,前提資料是下面這兩張虛構的「北辰書店」員工表與部門表,每一題都從這份原始資料開始;所有輸出都以 pandas 3 實際執行確認過。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6
import pandas as pd
emp = pd.DataFrame({'eid': [1, 2, 3], 'dept': ['D1', 'D2', 'D9']})
dept = pd.DataFrame({'dept': ['D1', 'D2', 'D3'], 'dname': ['業務', '研發', '客服']})
🎯 重點整理
- merge 把鍵相等的每一對列配成一列;how 只決定對不到的列:inner 丟、left/right 留一邊、outer 全留,預設是 inner。
- 列數是乘法:同一個鍵左邊 m 列、右邊 n 列就產生 m × n 列;多對多常代表有不該重複的鍵。
- 對不到的欄位補 NaN,整數欄因此變 float64;要保留整數用可空的 Int64。
- 鍵欄名不同用 left_on/right_on,同名的非鍵欄位加後綴(預設 _x、_y,可用 suffixes 改)。
- indicator=True 加上 _merge 欄,篩 left_only 就是反連接;pandas 3.0 起可寫 how='left_anti'。validate 在鍵重複時丟出 MergeError。
- merge 比對欄位值、join 用右表索引且預設 left、concat 不比對鍵:axis=0 加列、axis=1 依索引加欄。