上一篇我們先從整張資料表開始做資料剖析,確認了資料粒度(Grain)、缺失值(Missing Value)、重複資料(Duplicate)、日期與時段。其中我們已經碰到第一個很重要的觀念:沒有紀錄(Record),不代表缺失紀錄(Missing Record)。原本看到 6 月缺少凌晨 2~4 時的資料,很容易直接認為「資料缺漏」,但跨月比較之後才發現,這很可能與捷運實際營運時間有關。
這篇我們繼續檢查剩下兩個很重要的欄位,站點和人次。而這次我們會遇到一個更有趣的問題,看起來不合理的資料,真的就是錯誤資料嗎?
先從一個看起來非常合理的假設開始。捷運站既然可以讓乘客進站,也可以讓乘客出站,那麼,進站欄位出現的車站,理論上應該也會出現在出站欄位吧?
# 檢查進出站數據
# 先把兩邊曾經出現過的站點整理成 Set
totalinsta=set(df["進站"].unique())
totaloutsta=set(df["出站"].unique())
# 第一段 如果只是想快速確認兩個集合有哪些元素沒有對上
# station_diff = totaloutsta ^ totalinsta
# print(f"沒有對齊的車站: {station_diff}")
# 第二段
also_insta_not_outsta = totalinsta - totaloutsta
print(f"進站有但出站沒有的車站: {also_insta_not_outsta}")
also_outsta_not_insta = totaloutsta - totalinsta
print(f"出站有但進站沒有的車站: {also_outsta_not_insta}")
# result
# 進站有但出站沒有的車站: {'橋和', '新埔民生', '大坪林', '板新', 'Y板橋', '中和', '十四張', '幸福', '新北產業園區', '中原', '景安', '頭前庄', '景平', '秀朗橋'}
# 出站有但進站沒有的車站: {'O頭前庄', 'G大坪林', 'O景安'}
如果只是想快速確認兩個集合有哪些元素沒有對上,可以使用^稱為對稱差集(symmetric difference)。它會找出只存在其中一個集合,而沒有同時存在兩邊的元素。但這樣有一個缺點,我們只知道「有些站沒有對上」,卻不知道到底是進站有、出站沒有?還是出站有、進站沒有?
因此我們再用第二段code 將他們分為進出站兩個Set。這邊可以看到不只兩邊沒有對上還有出現代號在前面的導致命名不一致。如果看到這裡,很容易產生幾個直覺。例如:「是不是站名寫錯了?」或是,「G大坪林 應該直接改成大坪林吧?」
但如果我們真的這樣做,很可能反而把正確的資料弄錯。因為到目前為止,我們只知道:資料跟我們原本的預期不一樣。我們還不知道,資料本身有錯。所以先不要清理資料。下一步應該是確認:
跨月比較之後,這個現象並不是只出現在單一月份。所以接下來就要回頭查來源文件(Source Documentation)。回到政府資料開放平台後,我發現已經有人詢問過類似的問題。
官方回覆指出:
自 2023/5/23 起,環狀線營運權責移轉由新北捷運公司負責,因此臺北捷運公司的運量統計出站資料不再納入部分環狀線車站。
我們原本的假設是,每個站都能進出,所以進站集合應該等於出站集合。但是資料來源實際的 coverage rule 是臺北捷運公司提供的出站統計範圍,受到營運權責影響。
因此,站點值域不一致(Station Domain Mismatch) 不一定是資料品質錯誤(Data Quality Error)。它可能只是資料來源本身的來源涵蓋規則(Source Coverage Rule)。如果我們沒有先查清楚,就直接寫:assert in_stations == out_stations那Pipeline反而會把一份符合官方規則的資料判定成錯誤。
站點確認完之後,再來看最後一個欄位:人次。我們可以先想,怎麼樣的人次一定不合理?最容易想到的一條規則就是:人次不能< 0。畢竟這個欄位記錄的是人數,不可能有「負 3 個人」從一個站搭到另一個站。
negative_rows = df[df["人次"] < 0]
print(negative_rows.shape[0])
負數很好判斷,但另一邊就沒這麼簡單了:人次太大,多少才叫太大?接著我們想要了解一整欄的資料概況,我們可以透過下面這行:
print(df["人次"].describe())
# # Result
# count 8.096760e+06
# mean 7.707193e+00
# std 2.247584e+01
# min 0.000000e+00
# 25% 0.000000e+00
# 50% 1.000000e+00
# 75% 6.000000e+00
# max 2.223000e+03
結果顯示這份資料的平均值約為 7.71,標準差約 22.48,最小值為 0,第一四分位數為 0,中位數為 1,第三四分位數為 6,最大值則高達 2,223。中位數只有 1,最大值卻有 2,223,第一眼真的很容易覺得:「這應該是 Outlier 吧?」我們可以再把最大值對應的資料找出來,結果是
2026–06–04 17:00,南港展覽館 → 南港,2,223 人。
它確實是一個非常極端的數值,但目前我們沒有任何資料規則可以證明 人次 > 2000就是不合理。所以現在能確定的只有:它是一個極端值(Extreme Value)。 我們還不能直接宣告它是一筆錯誤資料(Bad Data)。如果看到 Max 很大就直接刪除,甚至自行建立人次 > 2000 = invalid 的規則,反而可能把真實世界確實發生過的事件刪掉。
換句話說:極端值 "不等於" 錯誤資料。
# 檢查人數為0比率
zero_count = df[df["人次"]==0].shape[0]
print(f"Number of rows with 人次 equal to 0: {zero_count}")
all=df.shape[0]
percentage=zero_count/all
print(f"Percentage of rows with 人次 equal to 0: {percentage:.2%}")
2026 年 6 月共有 3,075,867 筆 人次 = 0 的資料,約佔整體 37.99%。將近四成,第一眼可能又會想:「這麼多 0,是不是髒資料?要不要刪掉?」
上一篇已經提過一個非常重要的概念:0 ≠ NULL。NULL 表示這個值可能未知、缺失或根本沒有被記錄;但 人次 = 0 表示這個 OD-hour 有一筆紀錄,而且資料來源記錄的人次就是 0。兩者的資料語意完全不同,因此在沒有其他證據以前,這些 0 都應該保留。
到底什麼才算錯?
這次我們遇到了三種看起來很奇怪的資料。
看到資料不符合預期,只代表我們需要進一步調查。資料品質規則(Data Quality Rule) 應該建立在結構描述(Schema)、來源文件、資料契約(Data Contract)、明確的欄位語意,或足夠可靠的歷史模式(Pattern) 上,而不是單純因為:「這個數字看起來怪怪的。」
stage 1 完成:經過兩篇資料剖析,我們已經知道...
哪些規則有證據支持,哪些只是我們自己一開始的猜測。資料剖析真正要回答的是「什麼才算奇怪」。只有理解資料來源與資料語意之後,我們才有資格把觀察轉成資料品質規則。
下一個 stage,我們就要把這些已經確認過的資料帶進真正的資料處理流程。
下一篇:Day 6|Stage 2(1/5):用 Amazon S3 建立 原始資料層(Raw Data Layer)