Giao diện
Feature Engineering — Biến dữ liệu thô thành tín hiệu cho model
🎯 Mục tiêu
Sau bài này, bạn sẽ:
- Biết khi nào dùng One-Hot, Target Encoding, Binning — và khi nào không nên
- Hiểu data leakage trap trong target encoding và cách phòng tránh
- Nắm khái niệm Feature Store — tư duy production cho feature pipeline
- Phân biệt được encoding cho low-cardinality vs high-cardinality features
- Viết được một pipeline feature engineering hoàn chỉnh từ raw data
Features are Everything
"Coming up with features is difficult, time-consuming, requires expert knowledge. Applied machine learning is basically feature engineering." — Andrew Ng
Hãy nhớ pipeline cơ bản của mọi ML project:
Raw Data → Features → Model → Predictions
^^^^^^^^
Đây là nơi 80% công sức nằm ở đâyFeatures là giao diện giữa dữ liệu và thuật toán. Model không "nhìn" dữ liệu thô — nó chỉ thấy con số. Nhiệm vụ của bạn là dịch thế giới thực thành những con số có ý nghĩa.
Quy tắc vàng
- Feature tốt + model đơn giản → kết quả tuyệt vời
- Feature tệ + model phức tạp → rác vào, rác ra (garbage in, garbage out)
Một Logistic Regression với features được thiết kế tốt thường thắng một Deep Neural Network với raw features kém chất lượng.
Business Context: Dự đoán Customer Churn
Xuyên suốt bài này, ta sẽ dùng bài toán thực tế: dự đoán khách hàng rời bỏ (churn) cho một SaaS platform.
Dữ liệu thô có thể trông như:
python
import pandas as pd
df = pd.DataFrame({
'user_id': [101, 102, 103, 104, 105],
'city': ['Hà Nội', 'TP.HCM', 'Đà Nẵng', 'Hà Nội', 'TP.HCM'],
'plan': ['basic', 'premium', 'basic', 'enterprise', 'premium'],
'signup_date': ['2023-01-15', '2022-06-20', '2023-11-01', '2021-03-10', '2023-07-05'],
'monthly_usage_hours': [12.5, 45.2, 3.1, 78.9, 22.0],
'support_tickets': [0, 2, 5, 1, 3],
'churned': [0, 0, 1, 0, 1] # target variable
})Câu hỏi: Làm sao biến city, plan, signup_date thành con số mà model hiểu được?
Đó chính là Feature Engineering.
One-Hot Encoding (OHE)
Ý tưởng cốt lõi
One-Hot Encoding chuyển mỗi giá trị categorical thành một cột binary riêng biệt:
color → color_red color_blue color_green
─────────────────────────────────────────────
"red" → 1 0 0
"blue" → 0 1 0
"green" → 0 0 1Mỗi hàng chỉ có đúng một cột = 1, còn lại = 0. Vì vậy gọi là "one-hot" — chỉ một bit "nóng".
Khi nào dùng OHE?
| Điều kiện | OHE phù hợp? |
|---|---|
| Cardinality thấp (< 20 categories) | ✅ |
| Không có thứ tự (nominal data) | ✅ |
| Linear models (Logistic Regression, SVM) | ✅ |
| Cardinality cao (> 100 categories) | ❌ |
| Tree-based models (có thể split trực tiếp) | ⚠️ Không bắt buộc |
Cách thực hiện: pandas vs sklearn
Cách 1: pd.get_dummies() — nhanh, tiện cho EDA
python
# One-hot encoding cột 'plan'
df_encoded = pd.get_dummies(df, columns=['plan'], prefix='plan')
print(df_encoded[['user_id', 'plan_basic', 'plan_enterprise', 'plan_premium']]) user_id plan_basic plan_enterprise plan_premium
0 101 True False False
1 102 False False True
2 103 True False False
3 104 False True False
4 105 False False TrueCách 2: OneHotEncoder từ sklearn — chuẩn cho production pipeline
python
from sklearn.preprocessing import OneHotEncoder
import numpy as np
encoder = OneHotEncoder(sparse_output=False, handle_unknown='ignore')
# QUAN TRỌNG: fit trên train, transform trên cả train và test
plan_encoded = encoder.fit_transform(df[['plan']])
print(encoder.categories_)
# [array(['basic', 'enterprise', 'premium'], dtype=object)]
print(plan_encoded)
# [[1. 0. 0.]
# [0. 0. 1.]
# [1. 0. 0.]
# [0. 1. 0.]
# [0. 0. 1.]]pd.get_dummies() vs OneHotEncoder
pd.get_dummies(): Tiện cho EDA, nhưng không lưu lại mapping → không dùng được cho productionOneHotEncoder: Có.fit()/.transform()→ tái sử dụng được cho test data và production serving- Quy tắc: Notebook EDA →
get_dummies(). Pipeline production →OneHotEncoder
🚨 The Cardinality Trap
Đây là sai lầm phổ biến nhất với OHE:
python
# ĐỘC HẠI: OHE trên cột city có 1000 giá trị unique
df_bad = pd.get_dummies(df, columns=['city'])
# Kết quả: 1000 cột sparse → memory explodes, model không học được gìVấn đề khi OHE high-cardinality:
┌──────────────────────────────────────────────────┐
│ 1000 categories → 1000 cột mới │
│ Mỗi cột gần như toàn 0 (sparse) │
│ Model thiếu data để học pattern từ mỗi city │
│ RAM tăng 100x, training chậm 50x │
│ Overfitting gần như chắc chắn │
└──────────────────────────────────────────────────┘Quy tắc cứng
Không bao giờ OHE trên feature có > 50 unique values mà không cân nhắc kỹ. Với high-cardinality, hãy dùng Target Encoding, Frequency Encoding, hoặc Embedding.
Target Encoding (Mean Encoding)
Ý tưởng cốt lõi
Thay vì tạo nhiều cột binary, thay thế mỗi category bằng giá trị trung bình của target variable cho category đó:
python
# Ví dụ: encoding city theo trung bình revenue
# city="Hà Nội" → average revenue = 150,000
# city="TP.HCM" → average revenue = 220,000
# city="Đà Nẵng" → average revenue = 95,000
# Kết quả: cột city (string) → cột city_encoded (float)Khi nào dùng Target Encoding?
| Tình huống | Target Encoding? |
|---|---|
| High-cardinality categories (100+ values) | ✅ |
| Tree-based models (XGBoost, LightGBM) | ✅ |
| Linear models | ⚠️ Cẩn thận — có thể cần kết hợp thêm |
| Competition / Kaggle | ✅ (rất phổ biến) |
| Khi cần giữ 1 cột thay vì 100+ cột | ✅ |
⚠️ DANGER: Data Leakage!
Data Leakage — Sai lầm nghiêm trọng nhất
Nếu bạn tính mean target trên toàn bộ dataset rồi gán lại, bạn đang rò rỉ thông tin target vào features.
python
# ❌ SAI — Data Leakage
df['city_encoded'] = df.groupby('city')['churned'].transform('mean')
# Mỗi hàng "biết" target của chính nó thông qua mean
# Model sẽ "gian lận" — accuracy cao giả tạoHậu quả: Model có metric tuyệt vời trên validation nhưng thảm họa khi deploy.
Cách làm đúng: K-Fold Target Encoding
Ý tưởng: Với mỗi hàng, tính mean target chỉ từ các hàng khác (out-of-fold):
python
from sklearn.model_selection import KFold
import numpy as np
def kfold_target_encode(df, col, target, n_splits=5, smoothing=10):
"""
Target encoding với K-Fold để tránh data leakage.
Args:
df: DataFrame
col: tên cột categorical cần encode
target: tên cột target
n_splits: số fold
smoothing: hệ số smoothing (cao hơn = conservative hơn)
Returns:
Series với encoded values
"""
global_mean = df[target].mean()
encoded = pd.Series(index=df.index, dtype=float)
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
for train_idx, val_idx in kf.split(df):
# Tính mean CHỈ từ train fold
train_data = df.iloc[train_idx]
# Smoothing: kết hợp category mean với global mean
# Tránh overfitting khi category có ít samples
stats = train_data.groupby(col)[target].agg(['mean', 'count'])
smooth_mean = (
(stats['count'] * stats['mean'] + smoothing * global_mean)
/ (stats['count'] + smoothing)
)
# Gán cho validation fold
encoded.iloc[val_idx] = df.iloc[val_idx][col].map(smooth_mean)
# Fill NaN (categories không xuất hiện trong train fold)
encoded = encoded.fillna(global_mean)
return encoded
# Sử dụng
df['city_encoded'] = kfold_target_encode(df, 'city', 'churned')Smoothing — Tại sao cần thiết?
Không smoothing:
city="Bắc Kạn" (chỉ 2 samples, cả 2 churned) → mean = 1.0
→ Model nghĩ: ở Bắc Kạn = chắc chắn churn? SAI!
Có smoothing (Bayesian):
smooth_mean = (count × category_mean + m × global_mean) / (count + m)
city="Bắc Kạn": (2 × 1.0 + 10 × 0.3) / (2 + 10) = 0.417
→ Hợp lý hơn nhiều! Category nhỏ bị "kéo" về global meanBayesian Smoothing
m(smoothing factor) cao → tin tưởng global mean hơn → an toàn hơn cho rare categoriesmthấp → tin tưởng category mean hơn → nhạy hơn nhưng rủi ro overfitting- Thường chọn
m = 10-50tùy dataset size
Business Example: Encoding city → Revenue trung bình
python
# Scenario thực tế: SaaS platform với 500 thành phố
# Target: monthly_revenue
# Thay vì OHE tạo 500 cột, dùng target encoding:
# city="TP.HCM" → avg_revenue = 2,500,000 VNĐ
# city="Hà Nội" → avg_revenue = 2,100,000 VNĐ
# city="Đà Nẵng" → avg_revenue = 1,200,000 VNĐ
# city="Cần Thơ" → avg_revenue = 800,000 VNĐ
# Kết quả: 1 cột float thay vì 500 cột binary
# Model tree-based có thể split trên con số này rất hiệu quảBinning (Discretization)
Ý tưởng cốt lõi
Biến feature liên tục thành rời rạc bằng cách chia thành các nhóm (bins):
age: 22 25 31 45 67 19 28 52
bin: [0] [0] [1] [2] [3] [0] [1] [3]
┌────┬────┬────┬────┐
Bin: │ 0 │ 1 │ 2 │ 3 │
Range: │18-29│30-39│40-54│55+ │
Count: │ 4 │ 2 │ 1 │ 1 │
└────┴────┴────┴────┘Ba chiến lược Binning
1. Equal-Width Binning: pd.cut()
Chia khoảng giá trị thành các bin cùng độ rộng:
python
# Chia monthly_usage_hours thành 4 bin cùng độ rộng
df['usage_bin_equal'] = pd.cut(
df['monthly_usage_hours'],
bins=4,
labels=['low', 'medium', 'high', 'very_high']
)
# Bin edges được tính tự động:
# [3.1, 22.05) → low
# [22.05, 41.0) → medium
# [41.0, 59.95) → high
# [59.95, 78.9] → very_highƯu điểm: Đơn giản, dễ giải thích. Nhược điểm: Nếu data skewed, một số bin sẽ rất đông, một số gần trống.
2. Equal-Frequency Binning: pd.qcut()
Mỗi bin có cùng số lượng samples:
python
# Chia thành 4 bin, mỗi bin ~25% data
df['usage_bin_quantile'] = pd.qcut(
df['monthly_usage_hours'],
q=4,
labels=['Q1', 'Q2', 'Q3', 'Q4']
)
# Mỗi bin luôn chứa ~25% observations
# Tốt cho data phân bố lệch (skewed)Ưu điểm: Mỗi bin có đủ data để model học. Nhược điểm: Bin ranges không đều — khó giải thích cho stakeholder.
3. Domain-Driven Binning
Dùng kiến thức nghiệp vụ để định nghĩa bin:
python
# Chia tuổi theo nhóm có ý nghĩa business
age_bins = [0, 18, 25, 35, 50, 65, 100]
age_labels = ['<18', '18-25', '26-35', '36-50', '51-65', '65+']
df['age_group'] = pd.cut(
df['age'],
bins=age_bins,
labels=age_labels
)
# Hoặc: phân loại usage level theo SLA
usage_bins = [0, 5, 20, 50, float('inf')]
usage_labels = ['inactive', 'light', 'moderate', 'power_user']
df['usage_tier'] = pd.cut(
df['monthly_usage_hours'],
bins=usage_bins,
labels=usage_labels
)Ưu điểm: Có ý nghĩa business, dễ giải thích, stable qua thời gian. Nhược điểm: Cần domain expertise, có thể miss patterns mà data-driven binning phát hiện.
Chọn chiến lược nào?
| Tình huống | Chiến lược |
|---|---|
| Data phân bố đều | Equal-width (pd.cut) |
| Data phân bố lệch | Equal-frequency (pd.qcut) |
| Cần giải thích cho business | Domain-driven |
| Exploration / chưa biết data | Thử cả ba, so sánh kết quả |
⛔ Anti-pattern: Binning mọi thứ
Đừng bin tất cả features!
Binning vứt bỏ thông tin. Khi bạn biến age=25 và age=34 thành cùng bin [18-35], model mất khả năng phân biệt hai giá trị này.
Quy tắc:
- Tree-based models (XGBoost, Random Forest): Không cần binning — chúng tự tìm optimal split points
- Linear models: Binning giúp tạo non-linear boundaries → có ích
- Neural networks: Thường không cần binning — tự học non-linearity
python
# ❌ ANTI-PATTERN: bin age cho XGBoost
# XGBoost có thể split tại age=27.5 — chính xác hơn bin [18-35] nhiều
# ✅ CORRECT: bin age cho Logistic Regression
# LR không thể tạo non-linear decision boundary từ raw age
# Binning + OHE giúp LR xử lý non-linearityOther Encoding Techniques — Tổng quan
Ngoài OHE, Target Encoding, và Binning, còn nhiều kỹ thuật khác:
Label Encoding
Gán mỗi category một số nguyên. Chỉ dùng cho ordinal data (có thứ tự tự nhiên):
python
from sklearn.preprocessing import LabelEncoder
# ✅ Ordinal data — thứ tự có ý nghĩa
size_map = {'small': 0, 'medium': 1, 'large': 2, 'xlarge': 3}
df['size_encoded'] = df['size'].map(size_map)
# ❌ SAI: Label encoding cho nominal data
# city: "Hà Nội"=0, "TP.HCM"=1, "Đà Nẵng"=2
# Model nghĩ: "Đà Nẵng" > "TP.HCM" > "Hà Nội" → VÔ NGHĨA!Frequency Encoding
Thay category bằng tần suất xuất hiện trong dataset:
python
# Frequency encoding
freq = df['city'].value_counts(normalize=True)
df['city_freq'] = df['city'].map(freq)
# "Hà Nội" xuất hiện 40% → 0.40
# "TP.HCM" xuất hiện 35% → 0.35
# "Đà Nẵng" xuất hiện 25% → 0.25Ưu điểm: Không leak target, đơn giản, xử lý high-cardinality tốt. Nhược điểm: Hai category cùng frequency → cùng giá trị encoded → mất phân biệt.
Binary Encoding
Kết hợp Label Encoding + biểu diễn nhị phân. Tốt cho high-cardinality mà cần ít cột hơn OHE:
python
# 8 categories → Label: 0-7 → Binary: 3 cột (2³ = 8)
# Thay vì 8 cột OHE, chỉ cần 3 cột binary
# category label bin_2 bin_1 bin_0
# "A" 0 0 0 0
# "B" 1 0 0 1
# "C" 2 0 1 0
# "D" 3 0 1 1
# "E" 4 1 0 0
# ...
# 1000 categories → chỉ cần 10 cột (2¹⁰ = 1024)
# So sánh: OHE cần 1000 cột!Bảng so sánh tổng hợp
| Kỹ thuật | Cardinality | Loại data | Số cột output | Leak risk | Phù hợp nhất |
|---|---|---|---|---|---|
| One-Hot | Thấp (< 20) | Nominal | N cột | Không | Linear models |
| Label | Bất kỳ | Ordinal | 1 cột | Không | Tree models, ordinal |
| Target | Cao | Nominal | 1 cột | Cao | Tree models, Kaggle |
| Frequency | Cao | Nominal | 1 cột | Không | Mọi model |
| Binary | Trung bình-Cao | Nominal | log₂(N) cột | Không | Khi cần balance |
| Binning | Liên tục | Numerical | 1 cột (hoặc OHE bins) | Không | Linear models |
Feature Stores — Tư duy Production
Vấn đề: Training-Serving Skew
Trong notebook, bạn tính feature thoải mái:
python
# Notebook: tính feature từ full historical data
df['avg_purchase_30d'] = df.groupby('user_id')['amount'].transform(
lambda x: x.rolling(30).mean()
)Nhưng khi deploy model lên production API:
python
# Production: phải tính real-time khi user request đến
# Code KHÁC hoàn toàn so với notebook!
# → Kết quả KHÁC → Model hoạt động sai!Training-Serving Skew
Đây là nguyên nhân #1 khiến ML models "chết lặng" trong production:
- Feature tính khác cách giữa training và serving
- Model có accuracy 95% trong notebook nhưng 60% trong production
- Rất khó phát hiện vì không có lỗi rõ ràng — model vẫn trả về prediction
Feature Store là gì?
Feature Store là kho lưu trữ tập trung cho các computed features, đảm bảo training và serving dùng cùng logic tính feature:
┌──────────────────┐
│ Raw Data │
│ (databases, │
│ event streams) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Feature Pipeline │
│ (tính toán & │
│ transform) │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Feature Store │◄── Single source of truth
│ ┌────────────┐ │
│ │ Batch │ │ user_total_purchases_30d
│ │ Features │ │ user_avg_session_length
│ └────────────┘ │
│ ┌────────────┐ │
│ │ Real-time │ │ user_current_cart_value
│ │ Features │ │ user_last_action_seconds
│ └────────────┘ │
└───────┬──┬───────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Training │ │ Serving │
│ (cùng │ │ (cùng │
│ features!) │ │ features!) │
└──────────────┘ └──────────────┘Hai loại Features trong Feature Store
Batch Features — Tính trước theo lịch
python
# Chạy mỗi đêm (daily batch job)
# Kết quả lưu vào Feature Store
batch_features = {
'user_total_purchases_30d': 15, # Tổng mua 30 ngày
'user_avg_session_minutes_7d': 23.5, # TB thời gian session 7 ngày
'user_support_tickets_90d': 3, # Số ticket hỗ trợ 90 ngày
'user_login_count_14d': 8, # Số lần login 14 ngày
}
# Cập nhật: mỗi giờ hoặc mỗi ngày
# Độ trễ: chấp nhận được (phút đến giờ)Real-time Features — Tính tại thời điểm request
python
# Tính ngay khi có request prediction
# Phải trả về trong milliseconds
realtime_features = {
'user_current_cart_value': 450000, # Giá trị giỏ hàng hiện tại
'user_seconds_since_last_click': 12, # Giây từ click cuối
'user_pages_viewed_this_session': 7, # Số trang đã xem session này
}
# Cập nhật: real-time (event-driven)
# Độ trễ: phải < 100msCác Feature Store phổ biến
Công cụ Feature Store
| Tool | Đặc điểm | Open-source? |
|---|---|---|
| Feast | Lightweight, integrates with GCP/AWS | ✅ |
| Tecton | Enterprise-grade, real-time focus | ❌ (SaaS) |
| Hopsworks | Full ML platform, feature store built-in | ✅ (core) |
| Databricks Feature Store | Tích hợp Spark ecosystem | ❌ (SaaS) |
Ở giai đoạn học, bạn chưa cần dùng Feature Store. Nhưng hiểu tư duy này từ sớm sẽ giúp bạn thiết kế feature pipeline đúng cách ngay từ đầu.
🔥 GPU Paragraph
Feature engineering truyền thống là CPU work — pandas, SQL, numpy. Bạn không cần GPU để one-hot encode hay binning.
Nhưng trong ML hiện đại, có một loại feature đặc biệt: learned embeddings.
Classical features (CPU): Learned features (GPU):
├── OHE, Target Encoding ├── User embeddings
├── Binning, Aggregation ├── Item embeddings
├── Date extraction ├── Text embeddings (BERT)
└── Math transforms └── Image features (ResNet)Embedding-based features (như user embeddings trong recommendation systems) cần training trên GPU. Modern ML systems kết hợp:
- Classical feature engineering (CPU) → nhanh, interpretable, domain knowledge
- Learned features (GPU) → tự động phát hiện patterns phức tạp
Kỹ sư hoàn chỉnh cần thành thạo cả hai. Feature engineering cổ điển cho bạn nền tảng để hiểu khi nào learned features có ích — và khi nào chúng là overkill.
🧠 Common Beginner Misconception
"Nhiều features hơn = Model tốt hơn" — SAI!
Đây là Curse of Dimensionality — lời nguyền chiều:
Features tăng → Không gian tăng theo hàm mũ
→ Data trở nên "thưa" (sparse)
→ Model cần nhiều data hơn để học
→ Nhưng data có hạn
→ Kết quả: OVERFITTING
Accuracy
▲
│ ╱╲
│ ╱ ╲
│ ╱ ╲────── quá nhiều features
│ ╱
│ ╱
│╱
└──────────────► Số features
ít vừa nhiều
(tệ) (tốt!) (tệ)Tác hại của quá nhiều features:
- Noise tăng: Feature vô nghĩa thêm nhiễu, che lấp signal
- Training chậm: Thời gian tăng tuyến tính hoặc hơn
- Overfitting: Model "nhớ" noise thay vì học patterns
- Khó giải thích: 500 features → ai hiểu model quyết định thế nào?
Quy tắc: Feature SELECTION quan trọng ngang Feature CREATION.
python
# Workflow đúng:
# 1. Bắt đầu với 5-10 features mạnh nhất (dựa trên domain knowledge)
# 2. Train baseline model
# 3. Thêm features mới, đánh giá trên validation set
# 4. Nếu metric không cải thiện → bỏ feature đó
# 5. Dùng feature importance để loại bỏ features yếu⚡ Fast Exercise
⚡ Bài tập nhanh: E-commerce Feature Engineering
Đề bài: Cho DataFrame e-commerce:
python
ecom_df = pd.DataFrame({
'user_id': [1, 2, 3, 4, 5, 6, 7, 8],
'city': ['HN', 'HCM', 'DN', 'HN', 'HCM', 'HP', 'HN', 'HCM'],
'purchase_amount': [150, 2300, 500, 780, 1200, 90, 450, 3100],
'signup_date': pd.to_datetime([
'2023-01-15', '2022-06-20', '2023-11-01', '2021-03-10',
'2023-07-05', '2024-01-01', '2022-09-15', '2023-04-20'
]),
'churned': [0, 0, 1, 0, 0, 1, 0, 1]
})Yêu cầu: Tạo 3 features mới dùng 3 kỹ thuật encoding khác nhau.
Gợi ý đáp án:
python
import pandas as pd
import numpy as np
from datetime import datetime
# Feature 1: OHE cho city (low cardinality = 4 giá trị)
ecom_encoded = pd.get_dummies(ecom_df, columns=['city'], prefix='city')
# Feature 2: Binning cho purchase_amount
ecom_df['purchase_tier'] = pd.qcut(
ecom_df['purchase_amount'],
q=3,
labels=['low_spender', 'mid_spender', 'high_spender']
)
# Feature 3: Date feature engineering (không phải encoding, nhưng feature creation)
reference_date = pd.Timestamp('2024-06-01')
ecom_df['account_age_days'] = (reference_date - ecom_df['signup_date']).dt.days
# Bonus Feature 4: Frequency encoding cho city
city_freq = ecom_df['city'].value_counts(normalize=True)
ecom_df['city_frequency'] = ecom_df['city'].map(city_freq)
print(ecom_df[['user_id', 'purchase_tier', 'account_age_days', 'city_frequency']]) user_id purchase_tier account_age_days city_frequency
0 1 low_spender 503 0.375
1 2 high_spender 712 0.375
2 3 low_spender 213 0.125
3 4 mid_spender 1179 0.375
4 5 mid_spender 332 0.375
5 6 low_spender 152 0.125
6 7 mid_spender 625 0.375
7 8 high_spender 408 0.375🪤 Gotcha: OHE Vocabulary Leak
Fit trên train+test → Data Leakage!
python
# ❌ SAI: fit OneHotEncoder trên toàn bộ data
from sklearn.preprocessing import OneHotEncoder
all_data = pd.concat([train_df, test_df])
encoder = OneHotEncoder()
encoder.fit(all_data[['category']]) # ← Test data leak vào vocabulary!
# Nếu test có category "premium_plus" mà train không có,
# encoder vẫn tạo cột cho nó → model "biết" điều không nên biếtpython
# ✅ ĐÚNG: fit CHỈ trên train data
encoder = OneHotEncoder(handle_unknown='ignore')
encoder.fit(train_df[['category']]) # Chỉ train!
train_encoded = encoder.transform(train_df[['category']])
test_encoded = encoder.transform(test_df[['category']])
# Categories mới trong test → bị ignore → tất cả cột = 0
# handle_unknown='ignore' đảm bảo:
# - Không crash khi gặp category mới
# - Category lạ → vector toàn 0 (an toàn)Quy tắc nhớ đời:
.fit() → CHỈ trên train data
.transform() → trên cả train và test
.fit_transform() → CHỈ dùng cho train data📊 Performance Note: Sparse Matrices
Tiết kiệm 10-100x memory với sparse matrices
Khi OHE tạo nhiều cột, hầu hết giá trị = 0. Lưu dense matrix rất lãng phí:
python
# Dense matrix (mặc định):
# [[1, 0, 0, 0, 0, 0, ..., 0]] ← lưu cả 1000 số 0
# Memory: 1000 categories × 100,000 rows × 8 bytes = ~800MB
# Sparse matrix:
# Chỉ lưu vị trí các giá trị ≠ 0
# Memory: 100,000 rows × ~16 bytes = ~1.6MB (tiết kiệm 500x!)
from sklearn.preprocessing import OneHotEncoder
# Bật sparse output
encoder = OneHotEncoder(sparse_output=True) # sparse_output thay vì sparse (sklearn>=1.2)
X_sparse = encoder.fit_transform(df[['city']])
print(type(X_sparse)) # scipy.sparse._csr.csr_matrix
print(X_sparse.shape) # (100000, 1000)
print(f"Memory dense: {X_sparse.toarray().nbytes / 1e6:.1f} MB")
print(f"Memory sparse: {X_sparse.data.nbytes / 1e6:.1f} MB")
# Hầu hết sklearn models chấp nhận sparse input trực tiếp
# → Không cần convert về dense
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_sparse, y_train) # Chạy bình thường, nhanh hơn, ít RAM hơn🚫 Production Anti-pattern: Training-Serving Skew
Kẻ giết model thầm lặng
python
# ═══════════════════════════════════════════
# TRAINING (trong Jupyter Notebook)
# ═══════════════════════════════════════════
# Feature: "days since last purchase"
df['days_since_purchase'] = (pd.Timestamp('2024-01-01') - df['last_purchase']).dt.days
# ^^^^^^^^^^^^^^^^^^^^^^^^
# Hardcode ngày! Production sẽ khác!
# ═══════════════════════════════════════════
# PRODUCTION (trong API server)
# ═══════════════════════════════════════════
# Cùng feature nhưng code KHÁC:
days_since = (datetime.now() - user.last_purchase).days
# ^^^^^^^^^^^^^
# Dùng datetime.now() → giá trị khác hoàn toàn!Hậu quả:
- Training:
days_since_purchasetính từ 2024-01-01 (quá khứ) - Production:
days_since_purchasetính từ "hôm nay" (thay đổi mỗi ngày) - → Feature có distribution khác → model prediction sai
- → Không có error nào xuất hiện → silent failure
Cách phòng tránh:
- Feature Store: Cùng code tính feature cho cả training và serving
- Feature Pipeline Tests: Assert feature distributions match
- Monitoring: Track feature value distributions in production
- Version Control: Pin feature computation logic, không hardcode dates
python
# ✅ Feature function dùng chung
def compute_days_since_purchase(last_purchase_date, reference_date):
"""Dùng CÙNG function cho training và serving."""
return (reference_date - last_purchase_date).days
# Training:
df['days_since'] = df['last_purchase'].apply(
lambda x: compute_days_since_purchase(x, training_cutoff_date)
)
# Serving:
days_since = compute_days_since_purchase(
user.last_purchase, datetime.now()
)🛝 Playground: Complete Feature Engineering Pipeline
🛝 Playground — Pipeline Feature Engineering hoàn chỉnh
python
"""
Feature Engineering Pipeline hoàn chỉnh
Bài toán: Dự đoán customer churn cho SaaS platform
"""
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split, KFold
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from datetime import datetime
# ═══════════════════════════════════════════
# 1. SAMPLE DATA
# ═══════════════════════════════════════════
np.random.seed(42)
n_samples = 1000
df = pd.DataFrame({
'user_id': range(1, n_samples + 1),
'city': np.random.choice(
['Hà Nội', 'TP.HCM', 'Đà Nẵng', 'Hải Phòng', 'Cần Thơ',
'Biên Hòa', 'Huế', 'Nha Trang', 'Buôn Ma Thuột', 'Thái Nguyên'],
n_samples
),
'plan': np.random.choice(['basic', 'standard', 'premium', 'enterprise'], n_samples),
'signup_date': pd.date_range('2021-01-01', periods=n_samples, freq='D'),
'monthly_usage_hours': np.random.exponential(20, n_samples).clip(0, 200),
'support_tickets_30d': np.random.poisson(2, n_samples),
'login_count_7d': np.random.poisson(5, n_samples),
'churned': np.random.binomial(1, 0.25, n_samples) # 25% churn rate
})
print(f"Dataset: {df.shape[0]} rows, {df.shape[1]} columns")
print(f"Churn rate: {df['churned'].mean():.1%}")
# ═══════════════════════════════════════════
# 2. TRAIN/TEST SPLIT (TRƯỚC KHI ENGINEERING!)
# ═══════════════════════════════════════════
train_df, test_df = train_test_split(df, test_size=0.2, random_state=42, stratify=df['churned'])
print(f"\nTrain: {len(train_df)}, Test: {len(test_df)}")
# ═══════════════════════════════════════════
# 3. FEATURE ENGINEERING
# ═══════════════════════════════════════════
# --- 3a. OHE cho plan (low cardinality = 4) ---
plan_encoder = OneHotEncoder(sparse_output=False, handle_unknown='ignore')
plan_encoder.fit(train_df[['plan']]) # Fit CHỈ trên train!
train_plan_ohe = pd.DataFrame(
plan_encoder.transform(train_df[['plan']]),
columns=plan_encoder.get_feature_names_out(),
index=train_df.index
)
test_plan_ohe = pd.DataFrame(
plan_encoder.transform(test_df[['plan']]),
columns=plan_encoder.get_feature_names_out(),
index=test_df.index
)
# --- 3b. Frequency Encoding cho city (10 categories — vừa phải) ---
city_freq = train_df['city'].value_counts(normalize=True) # Tính từ TRAIN!
train_df['city_freq'] = train_df['city'].map(city_freq)
test_df['city_freq'] = test_df['city'].map(city_freq).fillna(0) # Unknown city → 0
# --- 3c. Target Encoding cho city (K-Fold, an toàn) ---
def kfold_target_encode(train, test, col, target, n_splits=5, smoothing=10):
global_mean = train[target].mean()
train_encoded = pd.Series(index=train.index, dtype=float)
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
for train_idx, val_idx in kf.split(train):
fold_train = train.iloc[train_idx]
stats = fold_train.groupby(col)[target].agg(['mean', 'count'])
smooth = (stats['count'] * stats['mean'] + smoothing * global_mean) / (stats['count'] + smoothing)
train_encoded.iloc[val_idx] = train.iloc[val_idx][col].map(smooth)
train_encoded = train_encoded.fillna(global_mean)
# Test encoding: dùng full train stats
stats_full = train.groupby(col)[target].agg(['mean', 'count'])
smooth_full = (stats_full['count'] * stats_full['mean'] + smoothing * global_mean) / (stats_full['count'] + smoothing)
test_encoded = test[col].map(smooth_full).fillna(global_mean)
return train_encoded, test_encoded
train_df['city_target'], test_df['city_target'] = kfold_target_encode(
train_df, test_df, 'city', 'churned'
)
# --- 3d. Binning cho monthly_usage_hours ---
usage_bins = [0, 5, 15, 40, float('inf')]
usage_labels = ['inactive', 'light', 'moderate', 'power_user']
train_df['usage_tier'] = pd.cut(train_df['monthly_usage_hours'], bins=usage_bins, labels=usage_labels)
test_df['usage_tier'] = pd.cut(test_df['monthly_usage_hours'], bins=usage_bins, labels=usage_labels)
# --- 3e. Date Features ---
reference_date = pd.Timestamp('2024-06-01')
train_df['account_age_days'] = (reference_date - train_df['signup_date']).dt.days
test_df['account_age_days'] = (reference_date - test_df['signup_date']).dt.days
train_df['signup_month'] = train_df['signup_date'].dt.month
test_df['signup_month'] = test_df['signup_date'].dt.month
# ═══════════════════════════════════════════
# 4. ASSEMBLE FINAL FEATURE MATRIX
# ═══════════════════════════════════════════
numeric_features = ['monthly_usage_hours', 'support_tickets_30d', 'login_count_7d',
'city_freq', 'city_target', 'account_age_days', 'signup_month']
X_train = pd.concat([
train_df[numeric_features].reset_index(drop=True),
train_plan_ohe.reset_index(drop=True)
], axis=1)
X_test = pd.concat([
test_df[numeric_features].reset_index(drop=True),
test_plan_ohe.reset_index(drop=True)
], axis=1)
y_train = train_df['churned'].reset_index(drop=True)
y_test = test_df['churned'].reset_index(drop=True)
print(f"\n{'='*50}")
print(f"Final feature matrix:")
print(f" X_train: {X_train.shape}")
print(f" X_test: {X_test.shape}")
print(f" Features: {list(X_train.columns)}")
print(f"{'='*50}")
# ═══════════════════════════════════════════
# 5. QUICK SANITY CHECK
# ═══════════════════════════════════════════
print("\nSanity checks:")
print(f" ✓ No NaN in train: {X_train.isna().sum().sum() == 0}")
print(f" ✓ No NaN in test: {X_test.isna().sum().sum() == 0}")
print(f" ✓ Same columns: {list(X_train.columns) == list(X_test.columns)}")
print(f" ✓ No data leakage: encoder fit only on train ✓")
print(f"\nPipeline hoàn tất! Sẵn sàng để train model.")🎯 Tổng kết
Feature Engineering Cheat Sheet
═══════════════════════════════════════════════════
Categorical (ít giá trị) → One-Hot Encoding
Categorical (nhiều giá trị) → Target / Frequency Encoding
Ordinal (có thứ tự) → Label Encoding
Continuous (giảm noise) → Binning (cut / qcut)
Date/Time → Extract components (year, month, day_of_week)
Text → TF-IDF, Embeddings (bài sau)
Golden Rules:
1. fit() trên train, transform() trên test
2. Target encoding → PHẢI dùng K-Fold
3. OHE high-cardinality → sparse matrix hoặc đổi kỹ thuật
4. Feature Store → cùng logic cho training & serving
5. Thêm feature → validate trên held-out setKey Takeaways
- Feature engineering quyết định 80% hiệu quả model — đầu tư thời gian ở đây
- Mỗi encoding có use case riêng — không có "best encoding", chỉ có "right encoding cho context"
- Data leakage là kẻ thù số 1 — luôn split trước khi engineer
- Feature Store không phải luxury — nó là cách duy nhất đảm bảo consistency giữa training và production
- Ít feature mạnh tốt hơn nhiều feature yếu — bắt đầu đơn giản, thêm dần, validate liên tục
🧭 Bước tiếp theo
Bạn đã biết cách biến raw data thành features. Nhưng nếu data bị mất cân bằng (99% negative, 1% positive) thì sao? Model sẽ predict toàn negative và vẫn có accuracy 99% — nhưng hoàn toàn vô dụng.
Bài tiếp theo:
- 👉 Imbalanced Data & Streams — Xử lý data mất cân bằng, SMOTE, class weights, và streaming data
- 📚 Feature Engineering cho ML — Deep dive vào feature selection, polynomial features, interaction terms
🧩 Quiz nhanh
Câu 1: Khi nào KHÔNG nên dùng One-Hot Encoding?
Đáp án: Khi feature có high cardinality (> 50 unique values). OHE sẽ tạo quá nhiều cột sparse, gây overfitting và tốn memory. Thay vào đó, dùng Target Encoding, Frequency Encoding, hoặc Binary Encoding.
Câu 2: Tại sao target encoding cần K-Fold?
Đáp án: Vì nếu tính mean target trên toàn bộ data, mỗi hàng leak thông tin target của chính nó vào feature. K-Fold đảm bảo mỗi hàng được encode bằng mean chỉ từ các hàng khác (out-of-fold), tránh data leakage.
Câu 3: Training-serving skew là gì và tại sao nguy hiểm?
Đáp án: Là hiện tượng features được tính khác cách trong training vs production. Nguy hiểm vì model không báo lỗi — vẫn trả về prediction bình thường, nhưng prediction sai do input features không khớp với distribution lúc training. Feature Store là giải pháp chính.
🪲 Spot the Bug
🪲 Tìm bug trong đoạn code sau
python
from sklearn.preprocessing import OneHotEncoder
# Chuẩn bị data
X_all = pd.concat([X_train, X_test])
# Encode
encoder = OneHotEncoder()
encoder.fit(X_all[['product_category']]) # Bug ở đây?
X_train_enc = encoder.transform(X_train[['product_category']])
X_test_enc = encoder.transform(X_test[['product_category']])Bug: encoder.fit(X_all) — fit trên toàn bộ data bao gồm test set!
Vấn đề:
- Test data leak vào encoder vocabulary
- Categories chỉ có trong test set được "biết trước"
- Validation metrics sẽ lạc quan hơn thực tế
Fix:
python
encoder = OneHotEncoder(handle_unknown='ignore')
encoder.fit(X_train[['product_category']]) # Chỉ fit trên TRAIN!
X_train_enc = encoder.transform(X_train[['product_category']])
X_test_enc = encoder.transform(X_test[['product_category']])
# Categories mới trong test → bị ignore an toàn🎬 Scenario: Bạn là ML Engineer tại một startup
🎬 Scenario — Feature Engineering cho hệ thống recommendation
Bối cảnh: Bạn là ML Engineer tại một startup e-commerce Việt Nam. Team cần build hệ thống gợi ý sản phẩm. Data có:
- 50,000 users
- 10,000 products
- 500,000 transactions
- Columns:
user_id,product_id,category(200 categories),price,timestamp,purchased(0/1)
Câu hỏi cho bạn suy nghĩ:
Encoding category (200 unique values): OHE tạo 200 cột — chấp nhận được không? → Tùy model. Với tree-based models → dùng Frequency hoặc Target Encoding hiệu quả hơn. Với linear models → OHE 200 cột vẫn OK nếu dùng sparse matrix.
Encoding product_id (10,000 values): Kỹ thuật nào? → OHE: Không khả thi (10,000 cột). Target Encoding: Khả thi cho tree models. Nhưng lý tưởng nhất: Product Embeddings (learned features) — sẽ học ở phase sau.
Feature Store: Cần không? → Với 500K transactions và real-time recommendations: CẦN. Batch features (user purchase history) + Real-time features (current session behavior).
Training-serving skew risk: Ở đâu? →
timestampfeatures! Training dùng historical timestamps, serving dùng "now". Phải abstract thành relative features:days_since_last_purchase,hours_since_last_login.
Takeaway: Feature engineering decisions phụ thuộc vào model choice, data scale, và deployment context. Không có giải pháp one-size-fits-all.