Skip to content

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 ở đây

Features 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           1

Mỗ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ệnOHE 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          True

Cá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 production
  • OneHotEncoder: 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ốngTarget 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ạo

Hậ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 mean

Bayesian Smoothing

  • m (smoothing factor) cao → tin tưởng global mean hơn → an toàn hơn cho rare categories
  • m thấp → tin tưởng category mean hơn → nhạy hơn nhưng rủi ro overfitting
  • Thường chọn m = 10-50 tù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ốngChiến lược
Data phân bố đềuEqual-width (pd.cut)
Data phân bố lệchEqual-frequency (pd.qcut)
Cần giải thích cho businessDomain-driven
Exploration / chưa biết dataThử 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=25age=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-linearity

Other 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ậtCardinalityLoại dataSố cột outputLeak riskPhù hợp nhất
One-HotThấp (< 20)NominalN cộtKhôngLinear models
LabelBất kỳOrdinal1 cộtKhôngTree models, ordinal
TargetCaoNominal1 cộtCaoTree models, Kaggle
FrequencyCaoNominal1 cộtKhôngMọi model
BinaryTrung bình-CaoNominallog₂(N) cộtKhôngKhi cần balance
BinningLiên tụcNumerical1 cột (hoặc OHE bins)KhôngLinear 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 < 100ms

Các Feature Store phổ biến

Công cụ Feature Store

ToolĐặc điểmOpen-source?
FeastLightweight, integrates with GCP/AWS
TectonEnterprise-grade, real-time focus❌ (SaaS)
HopsworksFull ML platform, feature store built-in✅ (core)
Databricks Feature StoreTí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:

  1. Noise tăng: Feature vô nghĩa thêm nhiễu, che lấp signal
  2. Training chậm: Thời gian tăng tuyến tính hoặc hơn
  3. Overfitting: Model "nhớ" noise thay vì học patterns
  4. 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ết
python
# ✅ ĐÚ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_purchase tính từ 2024-01-01 (quá khứ)
  • Production: days_since_purchase tí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:

  1. Feature Store: Cùng code tính feature cho cả training và serving
  2. Feature Pipeline Tests: Assert feature distributions match
  3. Monitoring: Track feature value distributions in production
  4. 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 set

Key Takeaways

  1. Feature engineering quyết định 80% hiệu quả model — đầu tư thời gian ở đây
  2. Mỗi encoding có use case riêng — không có "best encoding", chỉ có "right encoding cho context"
  3. Data leakage là kẻ thù số 1 — luôn split trước khi engineer
  4. Feature Store không phải luxury — nó là cách duy nhất đảm bảo consistency giữa training và production
  5. Í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:


🧩 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ĩ:

  1. 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.

  2. 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.

  3. 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).

  4. Training-serving skew risk: Ở đâu? → timestamp features! 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.