1. 核心觀念:從原始數據到模型就緒
在學術研究或業界實戰中,資料預處理佔據了 70-80% 的時間。其核心目標不僅是「清理」,更是為了滿足機器學習演算法的數學假設。
GIGO 原則 (Garbage In, Garbage Out)
無論模型多麼先進(即使是 Deep Learning),如果輸入數據包含雜訊、偏差或錯誤格式,輸出結果將毫無統計意義。
資料預處理大致可分為:
- 數據清理:無效數據、不合理數據等。
- 數據轉換:資料型態、編碼、新創欄位等。
- 數據減量:移除紀錄 (橫的列, row)、移除欄位 (直的欄, column)。
- 數據整合:結合外部數據。
2. 缺失值處理 (Handling Missing Values)
理論基礎:缺失機制識別
在動手填補前,必須先判斷缺失的成因,這決定了你的處理策略:
- MCAR (Missing Completely At Random): 缺失與任何變數無關(例如:感測器隨機斷電)。→ 可安全刪除或簡單填補。
- MAR (Missing At Random): 缺失機率與「已觀測到的數據」有關(例如:女性較不願透漏體重,但性別已知)。→ 需使用模型填補 (Imputation)。
- MNAR (Missing Not At Random): 缺失與「缺失值本身」有關(例如:高收入者故意不填寫收入)。→ 最棘手,需將「是否缺失」作為一個新特徵。
import pandas as pd
import numpy as np
from sklearn.impute import SimpleImputer, KNNImputer
# 模擬數據:Age 有缺失
df = pd.DataFrame({
'Age': [25, np.nan, 30, 45, np.nan],
'Salary': [50000, 60000, 55000, 100000, 72000]
})
# 方法 1: 簡單統計量填補 (Simple Imputation)
# 適用於 MCAR,但會減少數據的變異數 (Variance)
simple_imp = SimpleImputer(strategy='median')
df['Age_Simple'] = simple_imp.fit_transform(df[['Age']])
# 方法 2: KNN 填補 (Advanced)
# 利用特徵空間的距離,找最像的 K 個樣本平均
# 較能保留數據結構,適用於 MAR
knn_imp = KNNImputer(n_neighbors=2)
df_knn = knn_imp.fit_transform(df[['Age', 'Salary']])
3. 重複值處理 (Handling Duplicates)
重複數據會人為放大特定樣本的權重,導致模型對這些樣本過擬合 (Overfitting),並扭曲統計指標(如平均值、變異數)。
常見成因
- 資料庫合併 (Join/Merge) 操作不當。
- 系統錯誤導致重複寫入日誌。
- 人為重複輸入。
import pandas as pd
df = pd.DataFrame({
'User_ID': [101, 102, 101, 103],
'Transaction': ['A', 'B', 'A', 'C'],
'Amount': [100, 200, 100, 300]
})
# 1. 檢查重複 (全欄位完全相同)
print(f"重複筆數: {df.duplicated().sum()}")
# Output: 1
# 2. 刪除重複 (預設保留第一筆 keep='first')
df_clean = df.drop_duplicates()
# 3. 針對特定子集 (Subset) 處理
# 例如:假設同一 User_ID 只能有一筆紀錄,保留最新的一筆 (假設資料已按時間排序)
df_unique_users = df.drop_duplicates(subset=['User_ID'], keep='last')
4. 資料型別轉換 (Type Casting)
機器學習模型的核心是數學運算,因此輸入必須是數值 (Int/Float)。 Pandas 讀取 CSV 時,若某欄位包含非數字字符(如 '$', '%', ',')或文字,會將其識別為 `object` (String)。
為什麼這很重要?
如果在進入模型前保留 `object` 型別,Scikit-Learn 等套件會直接噴錯 (ValueError: could not convert string to float)。 我們必須將「看起來像數字但其實是字串」的資料轉正。
常見髒資料情境
- 貨幣符號:
"$1,200"(無法計算) - 百分比:
"55%"(模型視為文字) - 布林值文字:
"Yes"/"No","True"/"False" - 日期字串:
"2023/01/01"(需轉為 Datetime 或 TimeStamp)
處理策略
- String Clean: 使用
.str.replace()去除符號。 - Coerce: 使用
pd.to_numeric(..., errors='coerce')強制轉換,無法轉的變 NaN。 - Mapping: 使用
.map()將類別轉為 0/1。
import pandas as pd
# 模擬髒資料
df = pd.DataFrame({
'Price': ['$1,200', '$500', 'Not Available'], # 混雜字元
'Discount': ['10%', '5%', '0%'], # 百分比
'Is_Member': ['Yes', 'No', 'Yes'], # 文字布林
'Date': ['2023/01/01', '2023-02-15', 'Error']
})
# 1. 處理數值字串 (去除 $ 和 ,)
# errors='coerce' 會將 'Not Available' 轉為 NaN,方便後續補值
df['Price'] = pd.to_numeric(
df['Price'].str.replace('$', '').str.replace(',', ''),
errors='coerce'
)
# 2. 處理百分比 (去除 % 並除以 100)
df['Discount'] = df['Discount'].str.replace('%', '').astype(float) / 100
# 3. 處理二元類別 (Mapping)
df['Is_Member'] = df['Is_Member'].map({'Yes': 1, 'No': 0})
# 4. 處理日期 (轉為 Datetime 物件)
df['Date'] = pd.to_datetime(df['Date'], errors='coerce')
print(df.dtypes)
# Output:
# Price float64
# Discount float64
# Is_Member int64
# Date datetime64[ns]
5. 離群值與雜訊 (Outliers)
離群值可能是錯誤(如年齡 200 歲),也可能是極端但真實的數據(如 CEO 的薪水)。 在刪除之前,必須結合領域知識 (Domain Knowledge) 判斷。
Z-Score 方法
假設數據呈常態分佈。超過 ± 3σ 的值視為異常。
threshold = 3
IQR (四分位距) 方法
對非常態分佈較穩健 (Robust)。
Lower = Q1 - 1.5 * IQR
Upper = Q3 + 1.5 * IQR
# 離群值處理策略:Capping (蓋帽法 / Winsorization)
# 不直接刪除數據,而是將超過邊界的值替換為邊界值
# 這樣可以保留樣本數,但限制極端值的影響
def cap_outliers(series):
Q1 = series.quantile(0.25)
Q3 = series.quantile(0.75)
IQR = Q3 - Q1
lower_limit = Q1 - 1.5 * IQR
upper_limit = Q3 + 1.5 * IQR
# 將大於上界的設為上界,小於下界的設為下界
return series.clip(lower=lower_limit, upper=upper_limit)
# 應用
df['Age_Cleaned'] = cap_outliers(df['Age'])
6. 特徵選擇與工程 (Feature Selection & Engineering)
預處理不僅是清理髒數據,還包括「去蕪存菁」。過多的爛特徵會導致模型過擬合 (Overfitting) 並增加計算成本;而好的特徵工程往往能比複雜的模型帶來更大的效能提升。
刪除欄位 (Dropping)
- 唯一識別碼 (Unique IDs): 如 `User_ID`, `Ticket_No`。這些對預測無幫助,且會造成嚴重過擬合。
- 高缺失率 (High Missing Rate): 若某欄位缺失超過 50%-80%,且無法合理填補,建議刪除。
- 低變異數 (Low Variance): 若某欄位 99% 的值都相同 (例如全是 '0'),它不攜帶資訊量。
- 高共線性 (Multicollinearity): 若 A 與 B 相關係數 > 0.95,兩者提供重複資訊,應刪除其中之一。
新增特徵 (Creation)
- 領域知識衍生: 如 `BMI = Weight / Height²`,或 `房價每坪 = 總價 / 坪數`。
- 時間特徵分解: 將 `2023-11-01` 拆解為 `Year`, `Month`, `Weekday`, `Is_Weekend`。
- 交互作用項 (Interaction): 兩個變數相乘或相除 (例如 `長 * 寬` = `面積`)。
- 分箱 (Binning): 將連續變數轉為類別,如 `Age` -> `Age_Group` (少年, 青年, 老年)。
import pandas as pd
import numpy as np
df = pd.DataFrame({
'Transaction_ID': ['T01', 'T02', 'T03', 'T04'], # 應刪除
'Price': [100, 200, 150, 300],
'Quantity': [1, 2, 1, 5],
'Date': pd.to_datetime(['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-08']),
'Constant_Col': [1, 1, 1, 1] # 低變異數,應刪除
})
# 1. 刪除 (Dropping)
# 刪除 ID 欄位和 常數欄位 (axis=1 代表欄位)
cols_to_drop = ['Transaction_ID', 'Constant_Col']
df = df.drop(columns=cols_to_drop)
# 2. 新增特徵 (Creation)
# 2.1 數學交互作用:計算總金額
df['Total_Amount'] = df['Price'] * df['Quantity']
# 2.2 時間特徵提取
df['Month'] = df['Date'].dt.month
df['Weekday'] = df['Date'].dt.weekday
df['Is_Weekend'] = df['Weekday'].apply(lambda x: 1 if x >= 5 else 0)
print(df.columns)
# Output: Index(['Price', 'Quantity', 'Date', 'Total_Amount', 'Month', 'Weekday', 'Is_Weekend'], dtype='object')
7. 外部數據整合 (Integrating External Data)
很多時候,模型表現的瓶頸不在於演算法的選擇,而在於數據的豐富度。原始數據往往缺乏關鍵的上下文資訊 (Context),透過結合公開數據 (Open Data) 或第三方 API,能挖掘出隱藏的高價值特徵。
實戰案例:房價預測 (Housing Price Prediction)
情境: 原始數據包含「坪數」、「屋齡」、「房間數」與「地址/經緯度」。
問題: 決定房價的關鍵因素「交通便利性」缺失。原始表格中並沒有「距離最近捷運站」這個欄位。
解決方案: 下載政府公開的捷運站經緯度資料,計算房屋與最近捷運站的距離。
import pandas as pd
import numpy as np
# 1. 原始房屋數據 (只有經緯度)
df_houses = pd.DataFrame({
'House_ID': [1, 2],
'Lat': [25.0330, 25.0420], # 台北某處座標
'Lon': [121.5654, 121.5500]
})
# 2. 外部數據:捷運站清單 (從政府 Open Data 取得)
df_mrt = pd.DataFrame({
'Station_Name': ['Taipei 101', 'Sun Yat-Sen Memorial Hall'],
'Lat': [25.0328, 25.0415],
'Lon': [121.5641, 121.5578]
})
# 3. 定義 Haversine 公式 (計算地球表面兩點距離)
def haversine_np(lon1, lat1, lon2, lat2):
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
c = 2 * np.arcsin(np.sqrt(a))
km = 6367 * c
return km
# 4. 計算每間房子距離最近捷運站的距離
def get_nearest_mrt_dist(house_row):
# 計算該房子到"所有"捷運站的距離
dists = haversine_np(
house_row['Lon'], house_row['Lat'],
df_mrt['Lon'].values, df_mrt['Lat'].values
)
# 取最小值 (最近的站)
return dists.min()
df_houses['Dist_to_MRT_km'] = df_houses.apply(get_nearest_mrt_dist, axis=1)
print(df_houses)
# House 1 (Near 101): Distance very short
# House 2 (Near SYS Hall): Distance calculated
8. 特徵縮放 (Feature Scaling)
為什麼要縮放?
許多機器學習演算法是基於「距離」或「梯度下降」的。
- KNN / K-Means: 計算歐幾里得距離時,數值大的特徵(如薪水 50000)會完全主導數值小的特徵(如年齡 30)。
- Neural Networks / Logistic Regression: 梯度下降在特徵尺度一致時收斂最快(Loss Function 的等高線接近圓形)。
- Tree-based Models (Random Forest, XGBoost): 通常不需要縮放,因為它們是基於規則切割。
| 方法 | 公式 | 適用場景 |
|---|---|---|
| StandardScaler (Z-score) | $$z = \frac{x - \mu}{\sigma}$$ | 大多數演算法的預設選擇。使 Mean=0, Std=1。假設資料非常態但不顯著偏態。 |
| MinMaxScaler | $$x' = \frac{x - min}{max - min}$$ | 影像處理 (0-255轉0-1)、需要非負值時。受離群值影響大。 |
| RobustScaler | $$x' = \frac{x - Q2}{Q3 - Q1}$$ | 資料包含大量離群值時使用。利用中位數與 IQR 進行縮放。 |
from sklearn.preprocessing import StandardScaler, MinMaxScaler
data = [[-1, 2], [-0.5, 6], [0, 10], [1, 18]]
# StandardScaler (推薦首選)
scaler = StandardScaler()
print(scaler.fit_transform(data))
# MinMaxScaler
min_max = MinMaxScaler()
print(min_max.fit_transform(data))
9. 類別特徵編碼 (Encoding)
機器學習模型只能理解數字。如何將文字轉換為數字是關鍵,錯誤的方法會引入不存在的數學關係。
情況 A:有序類別 (Ordinal Data)
類別之間有大小關係。例如:Size (S, M, L), Rating (Low, Medium, High)。
解法:Label Encoding / Ordinal Encoding
S -> 0, M -> 1, L -> 2
情況 B:名目類別 (Nominal Data)
類別間無順序關係。例如:Color (Red, Blue, Green)。
⚠️ 陷阱 1:如果用 Label Encoding (Red=0, Blue=1, Green=2),模型會誤以為 Green > Blue > Red (2 > 1 > 0)。
解法:One-Hot Encoding
Red -> [1, 0, 0], Blue -> [0, 1, 0]
⚠️ 進階觀念:虛擬變數陷阱 (Dummy Variable Trap)
當你對有 $N$ 個類別的特徵進行 One-Hot Encoding 時,會產生 $N$ 個新欄位(例如:Is_Red, Is_Blue, Is_Green)。 如果將這 $N$ 個變數全部放入線性模型(如 Linear Regression, Logistic Regression),會導致嚴重的多重共線性 (Multicollinearity)。
問題:完全相關
$$Is\_Red + Is\_Blue + Is\_Green = 1$$
這些變數加總永遠等於 1(截距項)。這意味著其中一個變數可以完全由其他變數預測:
Is_Green = 1 - Is_Red - Is_Blue
後果:矩陣不可逆
線性模型試圖計算權重矩陣的反矩陣 (XT X)−1。如果存在完全共線性,行列式為 0,矩陣無法求逆,導致模型無法訓練或權重極度不穩定。
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
import pandas as pd
df = pd.DataFrame({
'Color': ['Red', 'Blue', 'Green', 'Blue'],
'Size': ['S', 'M', 'L', 'S']
})
# 1. 處理有序數據 (Size)
ord_enc = OrdinalEncoder(categories=[['S', 'M', 'L']])
df['Size_Encoded'] = ord_enc.fit_transform(df[['Size']])
# 2. 處理名目數據 (Color) - 關鍵在 drop='first'
# 如果我們有 3 個顏色,我們只需要 2 個欄位就能完全表示 (00=Color3)
one_hot = OneHotEncoder(sparse_output=False, drop='first')
color_encoded = one_hot.fit_transform(df[['Color']])
# Output columns: ['Blue', 'Green'] (Red 被丟棄作為基準項)
# Red: [0, 0], Blue: [1, 0], Green: [0, 1]
10. 資料洩漏 (Data Leakage) - 致命錯誤
這是學術論文被拒、模型上線後失效的最常見原因。 資料洩漏發生在你使用「測試集 (Test Set)」的資訊來訓練或預處理模型時。
❌ 錯誤的做法 (Common Mistake)
- 讀取所有資料 (Train + Test)。
- 對整個資料集進行標準化 (計算全體的 Mean/Std) 或填補缺失值。
- 分割 Train/Test。
- 訓練模型。
後果: 模型已經「偷看」了測試集的統計分佈,導致測試準確率虛高。
✅ 正確的做法 (Best Practice)
- 讀取資料。
- 立刻分割 Train/Test。
- 在 Train Set 上計算參數 (Fit)。例如:計算 Train 的 Mean。
- 將這些參數應用 (Transform) 到 Train Set 和 Test Set。
# 正確流程示範
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
scaler = StandardScaler()
# 1. 只在訓練集上 Fit
scaler.fit(X_train)
# 2. 轉換訓練集
X_train_scaled = scaler.transform(X_train)
# 3. 使用訓練集的參數轉換測試集 (不要重新 fit!)
X_test_scaled = scaler.transform(X_test)