Bài 6

Feature Engineering

Feature engineering là nghệ thuật biến dữ liệu thô thành đặc trưng (feature) mà mô hình học được tốt. Đây thường là bước tạo khác biệt lớn nhất về hiệu năng — hơn cả việc chọn thuật toán. Bài này bao quát encoding biến phân loại, scaling, biến đổi số, feature thời gian & text, feature selection, và cách dùng sklearn Pipeline/ColumnTransformer để tránh rò rỉ dữ liệu.

Feature engineering là gì
Vì sao nó quan trọng nhất

Feature engineering là quá trình dùng hiểu biết về bài toán để tạo, biến đổi và chọn lọc các đặc trưng đầu vào cho mô hình. Một câu nói kinh điển trong ngành: "Applied machine learning is basically feature engineering" (Andrew Ng).

Vì sao quan trọng nhất:

Nguyên tắc vàng: mọi phép biến đổi học tham số từ dữ liệu (mean để scale, danh mục để encode, thống kê target...) phải học chỉ trên tập train và áp lại cho test — nếu không sẽ rò rỉ (leakage). Toàn bài này sẽ nhấn mạnh điểm đó và kết bằng sklearn Pipeline.
Encoding biến phân loại
Đưa category về dạng số

Hầu hết thuật toán chỉ nhận số, nên biến category (text) phải được encode. Chọn cách nào phụ thuộc vào biến có thứ tự không và cardinality (số giá trị duy nhất):

Kỹ thuậtCách làmKhi dùng
Label encodingGán mỗi hạng một số nguyên (0,1,2...).Cho target, hoặc mô hình cây. Không dùng cho model tuyến tính (áp đặt thứ tự giả).
Ordinal encodingGán số theo thứ tự có nghĩa (low<med<high → 0,1,2).Biến thực sự có thứ tự.
One-hotMỗi hạng thành một cột 0/1.Không thứ tự, cardinality thấp (< ~15). Chuẩn cho model tuyến tính.
Target / meanThay hạng bằng trung bình target của hạng đó.Cardinality cao. Mạnh nhưng dễ leakage & overfit.
Frequency / countThay hạng bằng tần suất xuất hiện.Cardinality cao, khi tần suất mang thông tin.
HashingBăm hạng vào số cột cố định.Cardinality rất cao / streaming, chấp nhận va chạm.
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder

# One-hot: an toàn cho hạng lạ ở test nhờ handle_unknown
ohe = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
X_city = ohe.fit_transform(train[["city"]])   # fit CHỈ trên train
X_city_test = ohe.transform(test[["city"]])

# pandas one-hot nhanh (khi không cần fit/transform tách biệt)
train = pd.get_dummies(train, columns=["city"], drop_first=True)

# Ordinal có thứ tự chỉ định rõ
oe = OrdinalEncoder(categories=[["low", "medium", "high"]])
train["level_enc"] = oe.fit_transform(train[["level"]])

# Frequency encoding (map tần suất học từ train)
freq = train["city"].value_counts(normalize=True)
train["city_freq"] = train["city"].map(freq)
test["city_freq"] = test["city"].map(freq).fillna(0)
One-hot & cardinality cao: nếu cột có hàng nghìn giá trị (vd zip code), one-hot tạo hàng nghìn cột thưa (sparse), làm chậm và dễ overfit. Khi đó dùng target/frequency/hashing encoding, hoặc gộp các hạng hiếm vào nhóm "Other".
Scaling / Normalization
Đưa các biến số về cùng thang

Các cột số thường có thang khác nhau (tuổi 0–100, thu nhập 0–1 tỉ). Nhiều thuật toán bị lệch vì biến giá trị lớn lấn át:

ScalerCông thứcĐặc điểm
StandardScaler(x − mean) / stdMean 0, std 1. Mặc định phổ biến; nhạy với outlier.
MinMaxScaler(x − min) / (max − min)Đưa về [0,1]. Rất nhạy outlier (min/max bị kéo).
RobustScaler(x − median) / IQRDùng median & IQR → bền với outlier.

Khi nào CẦN scaling: các mô hình dựa trên khoảng cách (KNN, K-Means, SVM) và gradient descent (Linear/Logistic Regression, mạng neural), cùng PCA/regularization — vì chúng nhạy với độ lớn.

Khi nào KHÔNG cần: các mô hình cây (Decision Tree, Random Forest, XGBoost, LightGBM) chia theo ngưỡng từng biến độc lập, nên bất biến với scale — scale không hại nhưng vô ích.

from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)   # học mean/std TỪ train
X_test_scaled = scaler.transform(X_test)         # áp cùng mean/std lên test
Leakage kinh điển: tuyệt đối không fit scaler trên toàn bộ dữ liệu rồi mới split. Mean/std/min/max phải tính riêng trên train — nếu không, thống kê của test rò vào train.
Biến đổi biến số
Binning, log/Box-Cox, tương tác & đa thức

Binning / Discretization — nhóm biến liên tục thành khoảng, giúp bắt quan hệ phi tuyến và giảm nhiễu:

import pandas as pd
# Chia theo ngưỡng cố định (business rule)
train["age_group"] = pd.cut(train["age"],
    bins=[0, 18, 35, 60, 120],
    labels=["teen", "young", "adult", "senior"])

# Chia theo phân vị (mỗi bin số lượng bằng nhau)
train["income_q"] = pd.qcut(train["income"], q=4,
    labels=["Q1", "Q2", "Q3", "Q4"])

Biến đổi phân phối — log / Box-Cox / Yeo-Johnson làm phân phối lệch gần chuẩn hơn (xem Bài 5):

import numpy as np
from sklearn.preprocessing import PowerTransformer

train["income_log"] = np.log1p(train["income"])   # không có tham số học

pt = PowerTransformer(method="yeo-johnson")      # CÓ tham số -> fit trên train
train[["income_yj"]] = pt.fit_transform(train[["income"]])
test[["income_yj"]] = pt.transform(test[["income"]])

Biến tương tác & đa thức — tạo tích/luỹ thừa để model tuyến tính bắt được quan hệ phi tuyến:

from sklearn.preprocessing import PolynomialFeatures

# Tay: tỉ số & tích mang ý nghĩa nghiệp vụ
train["price_per_m2"] = train["price"] / train["area"]
train["rooms_x_area"] = train["rooms"] * train["area"]

# Tự động: bậc 2 + tương tác (x1, x2, x1^2, x1*x2, x2^2)
poly = PolynomialFeatures(degree=2, include_bias=False)
X_poly = poly.fit_transform(train[["area", "rooms"]])
Cẩn trọng: PolynomialFeatures bậc cao làm số cột bùng nổ và dễ overfit. Feature tạo tay theo hiểu biết nghiệp vụ (tỉ số, hiệu, tích có nghĩa) thường hiệu quả và dễ giải thích hơn feature đa thức máy móc.
Feature thời gian
Tách thành phần, cyclical, lag & rolling

Một cột datetime chứa rất nhiều tín hiệu. Tách thành phần là bước đầu tiên:

import numpy as np
dt = pd.to_datetime(train["order_date"])
train["year"]     = dt.dt.year
train["month"]    = dt.dt.month
train["day"]      = dt.dt.day
train["dow"]      = dt.dt.dayofweek        # 0=T2 ... 6=CN
train["hour"]     = dt.dt.hour
train["is_weekend"] = (dt.dt.dayofweek >= 5).astype(int)

Cyclical encoding (sin/cos) — với biến tuần hoàn (giờ, tháng, thứ), giá trị 23h và 0h thực ra gần nhau, nhưng số 23 và 0 lại rất xa. Mã hoá sin/cos giữ được tính "vòng tròn":

# hour có chu kỳ 24
train["hour_sin"] = np.sin(2 * np.pi * train["hour"] / 24)
train["hour_cos"] = np.cos(2 * np.pi * train["hour"] / 24)
# month có chu kỳ 12
train["month_sin"] = np.sin(2 * np.pi * train["month"] / 12)
train["month_cos"] = np.cos(2 * np.pi * train["month"] / 12)

Lag & rolling features — cho time series / dữ liệu chuỗi, dùng quá khứ để dự báo:

# Sắp theo thời gian trước đã!
train = train.sort_values("order_date")

# Lag: giá trị của 1 và 7 kỳ trước
train["sales_lag1"] = train["sales"].shift(1)
train["sales_lag7"] = train["sales"].shift(7)

# Rolling: trung bình/độ lệch trượt 7 kỳ (shift(1) để không dùng giá trị hiện tại)
train["sales_ma7"] = train["sales"].shift(1).rolling(7).mean()
train["sales_std7"] = train["sales"].shift(1).rolling(7).std()
Leakage thời gian: với time series, KHÔNG dùng giá trị tương lai để tạo feature cho hiện tại, và phải split theo thời gian (train = quá khứ, test = tương lai) — không shuffle ngẫu nhiên. Chú ý shift(1) trong rolling để không lỡ tay đưa chính giá trị đang dự báo vào feature.
Feature từ văn bản (cơ bản)
Bag-of-Words & TF-IDF

Văn bản cần chuyển thành vector số. Hai cách kinh điển:

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer

# Bag-of-Words
bow = CountVectorizer(max_features=5000, stop_words="english")
X_bow = bow.fit_transform(train["review"])   # fit vocab TỪ train

# TF-IDF (thường tốt hơn cho phân loại văn bản)
tfidf = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_tfidf = tfidf.fit_transform(train["review"])
X_tfidf_test = tfidf.transform(test["review"])  # dùng lại vocab của train
Ngoài phạm vi bài: word embeddings (Word2Vec, GloVe) và biểu diễn từ mô hình ngôn ngữ (BERT, sentence-transformers) cho vector dày, nắm ngữ nghĩa tốt hơn nhiều — sẽ gặp ở phần NLP nâng cao.
Feature selection
Filter · Wrapper · Embedded

Bớt feature thừa/nhiễu giúp giảm overfit, tăng tốc, dễ giải thích. Ba nhóm phương pháp:

NhómÝ tưởngVí dụ
FilterChấm điểm từng feature bằng thống kê, độc lập với model. Nhanh.Correlation, chi-square, mutual information, variance threshold.
WrapperThử các tập con feature, đánh giá bằng chính model. Chính xác nhưng tốn kém.RFE (Recursive Feature Elimination), forward/backward selection.
EmbeddedChọn feature ngay trong lúc train model.L1/Lasso (đưa hệ số về 0), feature_importances_ của cây.
from sklearn.feature_selection import (
    SelectKBest, chi2, mutual_info_classif, RFE)
from sklearn.linear_model import LogisticRegression, Lasso
from sklearn.ensemble import RandomForestClassifier

# FILTER: chọn K feature tốt nhất theo mutual information
selector = SelectKBest(score_func=mutual_info_classif, k=20)
X_sel = selector.fit_transform(X_train, y_train)

# WRAPPER: RFE loại dần feature yếu nhất
rfe = RFE(LogisticRegression(max_iter=1000), n_features_to_select=10)
rfe.fit(X_train, y_train)
print(X_train.columns[rfe.support_])

# EMBEDDED: độ quan trọng từ Random Forest
rf = RandomForestClassifier(n_estimators=300, random_state=42)
rf.fit(X_train, y_train)
importances = pd.Series(rf.feature_importances_, index=X_train.columns)
print(importances.sort_values(ascending=False).head(15))

# EMBEDDED: L1/Lasso ép hệ số các feature vô dụng về 0
lasso = Lasso(alpha=0.01).fit(X_train, y_train)
Data leakage & sklearn Pipeline
Fit trên train, transform test — một cách an toàn

Data leakage là khi thông tin không có ở thời điểm dự báo (đặc biệt từ tập test hoặc từ target) lọt vào quá trình huấn luyện, làm điểm đánh giá cao ảo nhưng sập khi lên production. Nguồn leakage thường gặp: impute/scale/encode trước khi split, target encoding không cross-validate, dùng dữ liệu tương lai trong time series.

Giải pháp gọn gàng: đóng gói mọi biến đổi vào Pipeline + ColumnTransformer. Khi gọi fit, mọi bước chỉ học từ train; khi predict/transform, chúng áp đúng tham số đã học lên dữ liệu mới. Không bao giờ rò rỉ, và tái lập được.

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split, cross_val_score

num_cols = ["age", "income", "score"]
cat_cols = ["city", "gender"]

# Nhánh xử lý riêng cho cột số & cột category
num_pipe = Pipeline([
    ("impute", SimpleImputer(strategy="median")),
    ("scale", StandardScaler()),
])
cat_pipe = Pipeline([
    ("impute", SimpleImputer(strategy="most_frequent")),
    ("ohe", OneHotEncoder(handle_unknown="ignore")),
])

pre = ColumnTransformer([
    ("num", num_pipe, num_cols),
    ("cat", cat_pipe, cat_cols),
])

# Gắn preprocessing + model vào MỘT pipeline
model = Pipeline([
    ("pre", pre),
    ("clf", RandomForestClassifier(n_estimators=300, random_state=42)),
])

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y)

# fit: impute/scale/encode học CHỈ từ X_train
model.fit(X_train, y_train)
print("Test acc:", model.score(X_test, y_test))

# cross_val_score cũng an toàn: preprocessing fit lại trong MỖI fold
scores = cross_val_score(model, X_train, y_train, cv=5)
print("CV mean:", scores.mean())
Target encoding & leakage: target encoding thay hạng bằng trung bình target của hạng — nếu tính trên toàn bộ dữ liệu, giá trị target của mỗi dòng "tự tiết lộ" chính nó, gây overfit nặng. Phải tính bằng out-of-fold (cross-validation) hoặc smoothing, và luôn học thống kê chỉ trên train. Dùng category_encoders.TargetEncoder đặt trong Pipeline để an toàn.
← Quay lại
Bài 5: EDA