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): 缺失與「缺失值本身」有關(例如:高收入者故意不填寫收入)。→ 最棘手,需將「是否缺失」作為一個新特徵。
Python Example: Imputation Strategies Scikit-Learn
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) 操作不當。
  • 系統錯誤導致重複寫入日誌。
  • 人為重複輸入。
Python Example: Identifying & Removing Duplicates
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。
Python Example: Cleaning & Type Conversion
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σ 的值視為異常。

z = (x - mean) / std
threshold = 3

IQR (四分位距) 方法

對非常態分佈較穩健 (Robust)。

IQR = Q3 - Q1
Lower = Q1 - 1.5 * IQR
Upper = Q3 + 1.5 * IQR
Python Example: Handling Outliers (Winsorization)
# 離群值處理策略: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` (少年, 青年, 老年)。
Python Example: Feature Engineering
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)

情境: 原始數據包含「坪數」、「屋齡」、「房間數」與「地址/經緯度」。
問題: 決定房價的關鍵因素「交通便利性」缺失。原始表格中並沒有「距離最近捷運站」這個欄位。
解決方案: 下載政府公開的捷運站經緯度資料,計算房屋與最近捷運站的距離。

Python Example: Geo-spatial Feature Engineering
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,矩陣無法求逆,導致模型無法訓練或權重極度不穩定。

✅ 解決方案:Drop First (刪除第一個欄位)。對於 $N$ 個類別,只保留 $N-1$ 個虛擬變數。
Python Example: Handling Dummy Variable Trap
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)

  1. 讀取所有資料 (Train + Test)。
  2. 整個資料集進行標準化 (計算全體的 Mean/Std) 或填補缺失值。
  3. 分割 Train/Test。
  4. 訓練模型。

後果: 模型已經「偷看」了測試集的統計分佈,導致測試準確率虛高。

✅ 正確的做法 (Best Practice)

  1. 讀取資料。
  2. 立刻分割 Train/Test。
  3. Train Set 上計算參數 (Fit)。例如:計算 Train 的 Mean。
  4. 將這些參數應用 (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)