JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

清理與合併

真實資料是髒的:遺漏值、離群值、重複,以及依鍵合併表格、又不遺失或重複列的細膩功夫。

動手前,先替資料做健檢

想像一位同事丟給你一份有五萬筆線上訂單的試算表,說:「幫我算上個月各國的營收。」你的直覺也許是馬上開始修東西。請忍住。清理的第一份工作不是去改任何東西,而是去看。我們把這個仔細的第一眼稱為「資料剖析(profiling)」:數一數有幾列、檢查每一欄裝的是不是你預期的那種值,並留意哪裡看起來不對勁。盲目地清理,會讓你悄悄地把資料弄壞;先剖析,才能睜著眼睛清理。

回想前一篇我們想要的形狀:一張整齊資料(tidy data)表,每一列是一個觀測值、每一欄是一個變數。剖析,就是檢查你手上這張真實的表離那個理想有多遠。對每一欄,你問三個問題:這是什麼資料型別(數字、日期、類別,還是自由文字)?它實際上裝著哪些值、範圍多大?又遺漏了多少?你是在動手改任何一格之前,先建立起一張心智圖像。

目標形狀:每列一個觀測值、每欄一個變數、每格一個值。剖析衡量的,就是你那份雜亂檔案與這個目標之間的差距。

一個格狀圖,列標示為觀測值(每筆一張客戶訂單),欄標示為變數(訂單編號、日期、金額、國家),每一格裝著單一個值。

df.shape                              # (rows, columns)
df.info()                             # types + non-null counts
df.describe()                         # min, max, mean, quartiles
df['country'].value_counts(dropna=False)
df.isna().sum()                       # missing values per column
用 pandas 做的六十秒剖析。value_counts 加上 dropna=False 會連「國家為空白」有幾筆都顯示出來;isna().sum() 則給出每一欄的遺漏值數量。

遺漏值:它「為什麼」缺,才是重點

幾乎每一份真實資料集都有破洞——空白格、NULL、像「N/A」這樣的佔位符,或那個陰險的、其實代表「沒人填」的 0。我們很容易把所有的空缺都用同一招處理。但關於一個遺漏值,最重要的問題從來不是「怎麼填它」,而是「它為什麼會缺?」這個答案,決定了忽略它究竟無傷大雅,還是會悄悄毒害你的結論。

統計學家把遺漏情況分成三種用白話就能說清的狀況。第一種,完全隨機遺漏(MCAR):實驗儀器隨機掉了一些讀數——這些空缺與任何事都無關,所以你留下來的那些列,仍是一個公正的樣本。第二種,隨機遺漏(MAR,這名字有點誤導):一個值缺不缺,取決於你「有」記錄到的其他欄位。例如,年輕使用者比較常跳過「收入」欄,但在同一個年齡層裡,缺不缺是隨機的。第三種,非隨機遺漏(MNAR):一個值之所以缺,正是因為它的值本身——高收入者不願透露收入。最後這一種,才是危險的那一種。

所以在你做任何事之前,先把空缺數清楚、定位出來,並自問:這個遺漏的模式,合理地說是隨機的嗎?一欄有 2% 隨機留白,是個小麻煩;一欄有 40% 留白、而且空缺集中在你最重要的那群客戶身上,那本身就是一項發現——也是一個警訊:任何對那一欄的分析,都立在搖晃的地基上。

插補,要小心

有時你不能直接把空缺丟掉——你需要一張完整的表來餵模型或畫圖。用一個合理的猜測值去填補遺漏值,稱為插補。最簡單的版本,是把空白換成該欄的中心。對於像收入這種偏斜的欄位,中位數(把值排序後位在正中間的那個)會比平均數更安全,因為一位億萬富翁會把平均數往上拉,卻幾乎動不了中位數。

回想一下,平均數是把所有值加起來再除以個數:

\bar{x} \;=\; \frac{1}{n}\sum_{i=1}^{n} x_i

一欄的平均數。它會被極端值往那邊拉;中位數不會。這就是為什麼對偏斜的欄位,我們通常用中位數來插補。

有一個小習慣能讓你保持誠實:在你填一欄之前,先加一個記錄「該值原本是否遺漏」的真/假新欄位。這樣模型——還有未來的你——都還看得出哪些列是猜出來的,你也能檢查「是否曾遺漏」這件事本身會不會預測結果。用平均數、中位數插補是不錯的起點;更花俏的方法(用其他欄位去預測那個遺漏值,或產生好幾個合理的填補值再合併)確實存在,但規則都一樣:絕不要插補完,就把填出來的數字當成「量到的」來談。

median = df['income'].median()
df['income_missing'] = df['income'].isna()   # remember where
df['income'] = df['income'].fillna(median)   # then fill
留下軌跡的中位數插補:income_missing 這個旗標會告訴你(與你的模型)哪 200 列是猜出來的、而非量出來的。

離群值:是錯誤,還是訊號?

離群值(outlier)是一個遠離其餘資料的值。在我們的訂單表裡,你可能看到年齡是 999、訂單金額是 -50,或單筆購買高達 25 萬美元。新手的錯誤,是一看到極端值就刪掉。正確的問題,和處理遺漏值時一樣:它為什麼會在那裡?有些離群值純粹是錯誤——999 是不可能的年齡,幾乎肯定是個佔位符;負的金額是一筆被誤標成銷售的退款。這些你該修正或移除。但另一些離群值是真實而重要的——那筆 25 萬美元的訂單,可能正是你最大、最有價值的客戶。刪掉它,等於抹去你最想研究的那個訊號。

要有系統地找出候選的離群值,兩條簡單的規則很有用。第一條用四分位數。把資料排序,找出 Q1(位在四分之一處的值)與 Q3(位在四分之三處的值);兩者之間的距離,也就是四分位距(IQR),是資料中間的那 50%。一個常見的慣例,是把落在這個中間帶外、超過 1.5 倍四分位距的值都標記出來:

\text{fences} \;=\; \bigl[\, Q_1 - 1.5\,(Q_3 - Q_1),\;\; Q_3 + 1.5\,(Q_3 - Q_1) \,\bigr]

1.5×IQR 的「圍籬」。落在這範圍外的值會被標記出來檢視——是「標記」,不是自動刪除。這正是盒鬚圖的鬚所畫出的東西。

第二條規則用標準差,一個衡量「典型離散程度」的量。一個值的 z 分數,是它離平均數有幾個標準差;對大致呈鐘形的資料,z 超過大約 3 就算不尋常。不過要小心:平均數與標準差本身,正是會被你在獵捕的那些離群值給拉著跑的,所以四分位數規則往往是更穩當的選擇。

z \;=\; \frac{x - \bar{x}}{s}

z 分數:以標準差(s)為單位、衡量距離平均數有多遠。好用,但對它本該偵測的那些離群值很敏感。

訂單金額的直方圖與盒鬚圖。長長的右尾,以及鬚之外那個孤零零的點,就是離群值——這張圖要你去「調查」它們,而不是刪掉它們。

一張右偏的購買金額直方圖,旁邊是一張盒鬚圖,其右鬚遠在最右端那個標示著一筆超大訂單的單一圓點之前就結束了。

去重複與主鍵

重複,是指本不該出現第二次的列卻出現了好幾次。它們會在表單被送出兩次、兩個系統被合併、或某次合併(下一節)出錯時偷偷溜進來。它們危險,正是因為它們看起來就像普通資料:1,000 筆真實訂單加上 50 筆意外副本,只會單純地回報成 1,050 筆訂單,而你的營收總額會高出 5%,卻看不到任何明顯的錯誤。

防止這件事的工具,是「鍵」。主鍵(primary key)是一欄(或一組欄位),其值能唯一辨識每一列——訂單用 order_id、客戶用 customer_id。如果主鍵盡了本分,就不會有兩列共用同一個值。所以,檢查重複最乾淨的測試就是:有沒有任何一個鍵值出現了不只一次?要注意「重複」可以有兩種意思——整列一模一樣的副本,或是兩列共用同一個鍵、其他欄位卻不一致(同一個 order_id 卻有兩個不同的金額)。第二種更糟,因為現在你得決定哪個版本才是對的。

df.duplicated(subset=['order_id']).sum()        # how many repeats?
df = df.drop_duplicates(subset=['order_id'], keep='last')
先數出重複的 order_id,再讓每個 id 只留一列。keep='last' 是一個刻意的選擇——通常最新的那筆是修正過的,但要有意識地決定,別盲目地用預設值。
  1. 先決定「兩列相同」的依據是什麼——整列,還是像 order_id 這樣的鍵。
  2. 先數重複的數量,這樣在刪除任何東西之前,你就知道問題有多大。
  3. 若重複的鍵在其他欄位上不一致,選一條「該留哪一筆」的規則(最新的時間戳記、最完整的那一列),並把它寫下來。
  4. 去除重複後,重新檢查鍵現在是否唯一——這是你「確實成功了」的保證。

合併:內部、左側,以及「列數爆炸」陷阱

真實的分析,幾乎總是需要把多張表組合起來。你的訂單表有 customer_id,卻沒有客戶的國家;另一張獨立的客戶表才有國家。透過比對 customer_id 把它們縫在一起,就叫做合併(join/merge)。你用來比對的那個共用欄位,稱為「合併鍵」。整個操作的成敗,全繫於那把鍵是否乾淨——這正是我們先講鍵與重複的原因。

合併的「類型」,決定了那些找不到配對的列會怎樣。內部合併(inner join)只保留兩邊都配上對的列——客戶確實存在於客戶表裡的那些訂單,其餘一概不留。左側合併(left join)則保留左表(你的訂單)的每一列,能配上的就附上客戶資訊,配不上的就留白。對於「替我的訂單補上國家,且不要弄丟任何一筆訂單」這個需求,你通常要的是左側合併——悄無聲息地弄丟列,是合併毀掉一份分析最常見的方式之一。內部合併會默默地丟掉每一筆 customer_id 不在客戶表裡的訂單。

以 customer_id 做的內部合併 vs 左側合併。內部只留配對成功的列;左側則保留所有訂單、配不上的就留白。同一份資料,列數卻不同。

兩張表以 customer_id 合併:內部合併只顯示三列配對成功的列,左側合併則顯示全部五列訂單,其中兩列的國家欄是空的。

現在來看那個連資深分析師都會中招的陷阱:列數爆炸。當鍵至少在一邊是唯一的(每筆訂單恰好對應一位客戶——「多對一」)時,合併是安全、可預期的。但假設客戶表不小心把同一個 customer_id 列了兩次。現在每一筆配對到的訂單,都會同時和「兩列」客戶資料配成對,於是那些客戶的訂單數翻倍——營收灌水,而這份重複看起來就像真實資料。用數學講,某個鍵產生的輸出列數,是它在兩邊各出現幾次的「乘積」:

N_{\text{out}} \;=\; \sum_{k}\, L_k \times R_k

合併的輸出列數,對每個鍵 k 加總:左邊 L_k 份,乘以右邊 R_k 份。若兩邊都有重複(2×2),單單一個鍵就會生出 4 列——這就是「爆炸」。

SELECT o.order_id, o.amount, c.country
FROM orders AS o
LEFT JOIN customers AS c
  ON o.customer_id = c.customer_id;
同一個左側合併,用 SQL 寫——保留每一筆訂單,能配上客戶的就附上國家。SQL 與 pandas 表達的是同一個想法。
before = len(orders)
out = orders.merge(customers, on='customer_id',
                   how='left', validate='many_to_one')
assert len(out) == before        # left join must not change row count
有護欄的左側合併:validate 會抓出重複的鍵,assert 則證明列數從未變動。用很低的成本,替最昂貴的合併錯誤投保。

一套你信得過的清理流程

上面每一招單獨用都有用,但它們真正的威力,在於一個有紀律的「順序」。用手清理——在試算表裡點來點去、這裡刪一列、那裡敲個修正——感覺很快,其實是場災難:你記不得自己做了什麼、下個月重做不出來,也沒人能查核你。解方是可重現的清理:每一個更動都活在腳本裡,於是從原始資料到乾淨資料的這條路,是被寫下來、可重複、且可被審閱的。

一套從整篇指南自然流出的工作流程:

  1. 先剖析。以唯讀方式載入原始資料並觀察——型別、範圍、遺漏數、鍵是否唯一——在改任何東西之前。
  2. 修正結構與型別。先到達一張整齊資料表(每列一個觀測值),並讓每一欄都是正確的型別(日期是日期、數字是數字)。
  3. 處理遺漏值——逐欄決定要丟棄、標記,還是插補,並記下原因。
  4. 調查離群值——自動標記、人工判斷,修掉錯誤的、留下真實的。
  5. 去除重複,並在你依賴某把鍵之前,確認它是唯一的。
  6. 帶著護欄合併——比對前後列數,並驗證合併的類型。
  7. 把乾淨的表存成一個「新」檔,並把腳本納入版本控制,讓整條路徑都可重現。

請注意這個順序並非隨意:你先剖析再更動、先去重複再合併(這樣重複才不會爆炸)、並存成新檔讓原始保持原始。清理不光鮮,也不是大家會放進簡報的那部分——但建立在髒資料上的模型或圖表,會「自信地出錯」,而那是最糟的一種錯。把這部分做好、把它寫下來、讓它按一個鍵就能再跑一次,那麼下游的一切都會更輕鬆、也更值得信賴。這正是整潔的資料清理之所以重要的原因。