終於,我們要正式碰資料。這個專案會使用政府資料開放平台提供的「臺北捷運每日分時各站 OD 流量統計資料」。這一階段先下載 2026 年 6 月 CSV,放進上一篇建立的 data/ 資料夾中,後續程式會從這個位置讀取。
在前面的文章裡,我們一直強調一件事,這個系列不是拿到工具就開始用,而是先知道自己到底遇到了什麼問題。資料也是一樣。如果連「一筆資料代表什麼」都不知道,我們其實也不知道什麼資料該刪、什麼資料該改,甚至不知道眼前看到的東西到底算不算異常。
在正式建立資料管線(Data Pipeline)前,我們先使用少量樣本進行資料剖析,了解資料結構、資料粒度、欄位分布與可能存在的品質問題。這些觀察也會影響後續 PostgreSQL 資料表與資料管線的設計。
不過,這個階段不會立刻修改看起來異常的資料,因為異常現象不一定代表資料錯誤,必須先確認欄位語意、來源規則與實際情境。完成初步資料剖析後,專案會先進入 Stage 2 資料擷取與載入(Extract & Load),建立從來源、S3 到 PostgreSQL 的資料流。
資料剖析得到的結論,會在後續 dbt 階段轉成資料驗證(Data Validation)規則;只有確認資料違反規則時,才決定是否進行資料清理(Data Cleaning)或隔離處理。因此,Day 4 的目的是先回答一個更基本的問題:這份資料實際上長什麼樣子?
import pandas as pd
# 讀取資料
file_path="data/臺北捷運每日分時各站OD流量統計資料_202606.csv"
df = pd.read_csv(file_path,encoding="utf-8")
# 確認基本資料
print(repr(df.columns.tolist())) # 欄位名稱
print(df.head(5)) # 前五列資料
df.info() # 各欄位型態與 non-null 數量
print(df.shape) # 資料有幾列、幾欄
這時候可以確認,我們拿到的是一份 8,096,760 × 5 的資料表。但是知道 rows 和 columns 還不夠。接下來的問題是這份資料的一筆 row,到底代表什麼?
在資料工程與資料建模裡,我們常會碰到一個詞:資料粒度(Grain)。你可以先把它理解成一筆資料究竟代表到多細的程度?以這份臺北捷運 OD 資料來說,一筆資料代表的是:某一天、某一個時段,從某一個進站站點到某一個出站站點的人次。因此這份資料的Grain可以寫成:
日期 × 時段 × 進站 × 出站
知道Grain之後,我們才有辦法開始思考哪些欄位組合應該可以唯一代表一筆資料?這份資料不像訂單資料可能直接有一個 order_id 可以辨識每筆紀錄,因此我們可以先提出一個候選鍵(candidate key):
日期 + 時段 + 進站 + 出站
注意,我現在說的是candidate key,不是直接宣布它一定是唯一鍵( unique key)。因為我們現在就是要透過資料剖析,驗證這個假設到底成不成立。
# 是否有重複
print(df.duplicated(subset=["日期","時段","進站", "出站",]).sum())
# terminal 結果: 2026 年 6 月的結果是:0
也就是至少在這份資料裡,沒有發現相同 日期 + 時段 + 進站 + 出站 重複出現的紀錄。這也是為什麼重複資料檢查(Duplicate Check) 不能只是隨便找兩欄來比。如果我們連Grain都不知道,就很可能把原本合法存在的資料誤判成重複資料。
接著檢查缺失值。
print(df.isnull().sum()) #是否有null 數值
這次五個欄位的 missing value 都是:0。但這裡有一個很容易混淆的地方,NULL 不等於 0。NULL 通常代表這個值缺失、未知,或根本沒有被記錄。但人次 = 0 可能代表這個 OD-hour 確實有被記錄,而且人次就是 0。所以未來看到0的時候,我們不能因為它看起來「沒有東西」,就直接當成缺失值刪掉。0到底合不合理,要看這個欄位本身的語意。這個問題下一篇還會再回來。
接著看日期。透過 df.info() 可以知道,原始 CSV 裡的日期目前不是 datetime 型態。方便我們進行比較運算,我都先換成datetime 型態。
#第一段
df["日期"] = pd.to_datetime(df["日期"], format="%Y-%m-%d") # 先轉換日期型態與格式
print(df["日期"].max()) # 最大日期
print(df["日期"].min()) # 最小日期
print(df["日期"].nunique()) # 多少不同天的日期(合理要顯示30,因為6月是30天)
# 第二段
actual_dates = pd.DatetimeIndex(df["日期"].unique()) # 資料中所有不一樣日期列成一個list
expected_dates = pd.date_range(start="2026-06-01", end="2026-06-30") # 6月日期列成一個list
missing_dates = expected_dates.difference(actual_dates) # 應該有但沒有
unexpected_dates = actual_dates.difference(expected_dates) # 不應該出現但實際出現
print(missing_dates)
print(unexpected_dates)
確認有 30 個不同日期,不代表一定剛好就是 6/1~6/30。資料可能少了 6/15,卻不小心多了一筆 7/1。這樣 nunique() 還是 30。所以我們真正要比較的是 期待日期(Expected) vs 實際日期(Actual)。請見第二段code。
2026 年 6 月的結果兩邊都是空的。也就是6/1~6/30 都有,而且沒有多出其他日期。但這個檢查真的有必要嗎?有。
因為我後來拿另一個月份交叉檢查時,真的發現一份「12 月資料」裡有 32 個不同日期值。缺少的預期日期(Missing Expected Date) 沒有問題,但 非預期日期(Unexpected Date) 卻多出了一個:1/1。這就證明:只看 nunique() 或單純確認「有沒有少日期」都不夠。我們還需要確認有沒有不該出現在這個範圍裡的日期。
接著我們來看時段。這個欄位已經是int64,所以目前不用轉成 datetime,直接觀察它的值域(domain)即可,也就是這個欄位裡可能出現哪些值。
# 段落一
# 確認時段
print(df["時段"].nunique())
print(df["時段"].min())
print(df["時段"].max())
# 段落二
actual_hours = set(df["時段"].unique())
expected_hours = set(range(24))
missing_hours = expected_hours.difference(actual_hours)
print(missing_hours)
# 段落三
# 讀取 2025 年 12 月資料後,確認凌晨 2~4 時出現在哪些日期
dec_df = pd.read_csv("data/臺北捷運每日分時各站OD流量統計資料_202512.csv", encoding="utf-8")
dec_df["日期"] = pd.to_datetime(dec_df["日期"], format="%Y-%m-%d")
dec_filtered = dec_df[dec_df["時段"].between(2, 4)]
print(dec_filtered["日期"].unique())
先看段落一的結果
Minimum = 0
Maximum = 23
Unique hours = 21
這時候可以想到為什麼一天24 小時,unique hours 只有21? 但是max、 min 又剛好是第一跟最後一個小時,所以透過第二段code 可以發現實際上缺失的是{2,3,4}。但他真的有問題嗎?
為了確認 {2, 3, 4} 到底是不是異常,再拿其他月份的資料進行 cross-check,5月確實如此。其中我又特別拿了跨年的12 月份來比較,因為跨年夜捷運通常可能有不同的營運狀況。結果確實找到{2, 3, 4} 的資料。並且透過段落三的code 可以發現是 1/1。這時候事情開始變得不一樣了。
如果我一開始沒有看到6月跟5月缺 {2, 3, 4} 就直接建立一條 validation:「每個月份一定要包含 0~23 共 24 個時段,否則資料管線 fail。」那這條規則(Rule) 很可能就是錯的。因為我們現在已經看到:時段 coverage 可能與實際營運時間有關。換句話說,沒有紀錄,不代表缺失紀錄。它也可能代表那個時間本來就沒有營運,因此沒有這種資料。
這就是為什麼資料剖析不只是跑幾個 Pandas function。真正重要的是看到一個現象之後,我們怎麼判斷它到底是不是資料品質問題(Data Quality Problem)?
我們已經知道:
我們目前使用的是 2026 年 6 月資料,總共有:
8,096,760 rows
5 columns
約 308.9 MB
欄位則包含:日期、時段、進站、出站、人次
這些資訊會影響 Stage 2 的資料表與載入流程設計,也會在後續 dbt 階段轉成具體的資料驗證規則。
下一篇,我們會繼續往更有趣的地方走。因為資料裡還有一些東西,看起來真的非常像「錯誤資料」。例如極端值、大量的 0,甚至連進站跟出站的捷運站名稱都對不起來。
但是……異常資料,真的就是錯誤資料嗎?
下一篇:Day 5|Stage 1(2/2):異常資料真的異常嗎?資料剖析最重要的一課