Danh sách bài viết

Bài 10: One-Hot Encoding cho biến phân loại

One-Hot Encoding cho nominal feature: vì sao không gán số trực tiếp, dùng sklearn OneHotEncoder vs pandas get_dummies, drop='first' chống multicollinearity, handle_unknown='ignore' cho production, và xử lý high cardinality.

24/05/2026
13 phút đọc
0 lượt xem
1

Mục tiêu bài học

Sau bài này, bạn sẽ:

  • Phân biệt được nominal và ordinal categorical feature.
  • Hiểu vì sao gán số nguyên cho nominal làm sai semantic của data.
  • Dùng được OneHotEncoder của sklearn và pd.get_dummies đúng tình huống.
  • Biết khi nào cần drop="first" và khi nào không.
  • Cấu hình handle_unknown để pipeline không vỡ ở inference.
  • Nhận diện high cardinality và biết các alternative thay one-hot.
2

Categorical data — nominal vs ordinal

Categorical feature là feature có giá trị thuộc một tập hữu hạn các nhãn, không phải số đo. Hai dạng cần phân biệt:

  • Nominal — không có thứ tự tự nhiên giữa các giá trị. Vd: city ∈ {HN, HCM, DN}, color ∈ {red, green, blue}, blood_type ∈ {A, B, AB, O}.
  • Ordinal — có thứ tự rõ ràng. Vd: size ∈ {S, M, L, XL}, education ∈ {primary, secondary, bachelor, master, phd}, rating ∈ {1, 2, 3, 4, 5}.

Hầu hết model ML cổ điển (linear regression, logistic regression, SVM, KNN, neural network) chỉ nhận input dạng số. Categorical raw (string) phải qua bước encoding trước khi đưa vào .fit().

Cách encode đúng phụ thuộc vào loại feature: nominal → one-hot, ordinal → label/ordinal encoding với thứ tự cụ thể. Bài 11 sẽ deep về label/ordinal; bài này tập trung one-hot.

3

Vì sao không gán số trực tiếp cho nominal

Giả sử có feature city ∈ {HN, HCM, DN}. Cách "đơn giản nhất" là map: HN=0, HCM=1, DN=2.

Với model dựa trên khoảng cách hoặc tổ hợp tuyến tính (linear, logistic, KNN, SVM, neural network), việc gán này tạo ra ba giả định sai:

  • Thứ tự: model nghĩ HN < HCM < DN — nhưng không có ý nghĩa thực tế nào nói HN nhỏ hơn DN.
  • Khoảng cách: |DN - HN| = 2 nhưng |HCM - HN| = 1. Model nghĩ HN gần HCM hơn DN — vô căn cứ.
  • Trung bình: (HN + DN)/2 = HCM — toán hợp lệ nhưng semantic vô nghĩa.

Kết quả: model học theo một thứ tự giả mà bạn vô tình đưa vào. Đây là dạng data leakage / bias do encoding, khó phát hiện vì model vẫn chạy và trả về số.

Cách gán số trực tiếp này gọi là Label Encoding. Nó OK cho ordinal (vì khoảng cách 1 unit thật sự phản ánh thứ tự), nhưng sai cho nominal. Tree-based model (Decision Tree, Random Forest, XGBoost) ít nhạy hơn với label encoding nhờ split rời rạc, nhưng vẫn nên dùng one-hot cho linear/NN.

4

One-Hot Encoding — ý tưởng

One-Hot Encoding (OHE): mỗi giá trị unique của category trở thành 1 cột binary mới. Mỗi sample có đúng một cột bằng 1, còn lại 0 — đó là chỗ "one-hot" trong tên gọi.

Với city ∈ {HN, HCM, DN}:

samplecity (raw)is_HNis_HCMis_DN
1HN100
2HCM010
3DN001
4HN100

Ba cột là độc lập, không tạo ra thứ tự giả. Distance giữa HN và HCM bằng distance giữa HN và DN (đều là sqrt(2) trong Euclidean). Model linear xem mỗi cột là một feature riêng với coefficient riêng.

Cái giá phải trả: nếu category có k giá trị unique, OHE tạo ra k cột mới (hoặc k-1 nếu drop một cột). Với k lớn, số chiều phình to — sẽ xử lý ở mục high cardinality.

5

sklearn OneHotEncoder cơ bản

API trong sklearn.preprocessing:

import numpy as np
from sklearn.preprocessing import OneHotEncoder

X = np.array([["HN"], ["HCM"], ["DN"], ["HN"], ["HCM"]])

encoder = OneHotEncoder(sparse_output=False)
X_encoded = encoder.fit_transform(X)

print(X_encoded)
# [[0. 1. 0.]   <- HN
#  [1. 0. 0.]   <- HCM  (vì categories_ sort alphabetical: DN, HCM, HN)
#  [0. 0. 1.]   <- DN  ... đợi đã, để inspect chính xác xem mục 6
#  [0. 1. 0.]
#  [1. 0. 0.]]

Vài điểm cần nhớ:

  • Input X phải 2 chiều — shape (n_samples, n_features). Một cột vẫn cần reshape (-1, 1).
  • sparse_output=False trả về np.ndarray dense. Bỏ qua tham số này thì sklearn 1.2+ mặc định True, trả về scipy.sparse.csr_matrix — tiết kiệm RAM khi nhiều category.
  • fit_transform trên train; transform (không fit lại) trên test. Luôn áp dụng quy tắc này để tránh leak.

Với input là DataFrame, có thể đưa nguyên cột mà không cần convert:

import pandas as pd

df = pd.DataFrame({"city": ["HN", "HCM", "DN", "HN"]})
encoder = OneHotEncoder(sparse_output=False)
X_encoded = encoder.fit_transform(df[["city"]])  # double brackets giữ shape 2D
6

Inspect encoder — categories_ và get_feature_names_out

Sau khi fit, encoder lưu metadata để bạn biết cột nào ứng với category nào:

encoder = OneHotEncoder(sparse_output=False)
encoder.fit(df[["city"]])

print(encoder.categories_)
# [array(['DN', 'HCM', 'HN'], dtype=object)]
# Mỗi phần tử list ứng với 1 cột input. Thứ tự sort alphabetical.

print(encoder.get_feature_names_out())
# ['city_DN' 'city_HCM' 'city_HN']

categories_list, mỗi phần tử là array các giá trị unique của một cột input. Sklearn sort alphabetical (hoặc theo thứ tự custom nếu bạn truyền tham số categories=[...]).

get_feature_names_out() trả về tên cột mới — rất tiện để đính lại vào DataFrame:

X_encoded = encoder.transform(df[["city"]])
df_out = pd.DataFrame(X_encoded, columns=encoder.get_feature_names_out())
print(df_out)
#    city_DN  city_HCM  city_HN
# 0      0.0       0.0      1.0
# 1      0.0       1.0      0.0
# 2      1.0       0.0      0.0
# 3      0.0       0.0      1.0
7

pandas get_dummies — khi nào dùng

Pandas có shortcut pd.get_dummies làm điều tương tự, syntax gọn hơn:

df = pd.DataFrame({"city": ["HN", "HCM", "DN", "HN"], "age": [25, 30, 22, 28]})
df_encoded = pd.get_dummies(df, columns=["city"])
print(df_encoded)
#    age  city_DN  city_HCM  city_HN
# 0   25    False     False     True
# 1   30    False      True    False
# 2   22     True     False    False
# 3   28    False     False     True

Tham số tương đương:

  • drop_first=True — giống drop="first" của sklearn.
  • dtype=int — đổi bool mặc định (pandas 2.x) thành int.
  • prefix="city", prefix_sep="_" — tuỳ biến tên cột.

Nhược điểm quan trọng: get_dummies không phải estimator — nó không "nhớ" được tập category đã thấy. Nếu train có {HN, HCM, DN} nhưng test thiếu DN hoặc có thêm CT, kết quả encode trên test sẽ có số cột khác với train, làm vỡ pipeline xuống dưới.

Quy tắc thực dụng:

  • EDA, notebook một lần: dùng get_dummies cho nhanh.
  • Production / model deploy / cross-validation: dùng OneHotEncoder — fit trên train, transform trên test/inference, giữ đúng số cột và đúng thứ tự.
8

drop="first" và dummy variable trap

Với k category, OHE mặc định tạo k cột. Nhưng k cột này có ràng buộc tuyến tính: tổng tất cả các cột luôn bằng 1 với mọi sample (vì mỗi sample chính xác có 1 cột = 1). Đây gọi là dummy variable trap hoặc multicollinearity hoàn hảo.

Hệ quả với Linear Regression / OLS:

  • Ma trận thiết kế X rank-deficient → X.T @ X không khả nghịch → solver phân tích (closed-form) sẽ lỗi hoặc cho coefficient không xác định.
  • Với solver gradient (Logistic Regression, SGD), model vẫn chạy nhưng coefficient không identifiable: nhiều bộ (w_HN, w_HCM, w_DN) khác nhau cho cùng dự đoán.
  • Diễn giải coefficient bị mơ hồ trong statistics / inference.

Giải pháp: bỏ một cột làm baseline. Sklearn dùng drop="first":

encoder = OneHotEncoder(sparse_output=False, drop="first")
encoder.fit(df[["city"]])
print(encoder.get_feature_names_out())
# ['city_HCM' 'city_HN']   <- bỏ city_DN (category đầu sau khi sort)

X_encoded = encoder.transform(df[["city"]])
# city = DN  -> [0, 0]   (baseline ngầm)
# city = HCM -> [1, 0]
# city = HN  -> [0, 1]

Khi nào cần drop="first":

  • Linear Regression / OLS, đặc biệt khi cần đọc coefficient.
  • Statistical inference (test giả thuyết, confidence interval).

Khi nào KHÔNG cần:

  • Logistic Regression với regularization (L2 / L1) — regularization tự giải quyết multicollinearity, drop=first đôi khi làm performance kém hơn một chút.
  • Tree-based (Decision Tree, Random Forest, XGBoost) — split rời rạc, không quan tâm tuyến tính phụ thuộc.
  • Neural Network — học weight cho từng feature, không bị closed-form vỡ.

Mặc định an toàn cho người mới: không drop, chỉ bật drop="first" khi gặp linear regression không regularize hoặc cần diễn giải coefficient.

9

handle_unknown="ignore" cho production

Mặc định, nếu test set chứa một category mà train không có, transform sẽ raise ValueError:

encoder = OneHotEncoder(sparse_output=False)
encoder.fit(np.array([["HN"], ["HCM"], ["DN"]]))

# Test có category mới "CT"
try:
    encoder.transform(np.array([["CT"]]))
except ValueError as e:
    print("Lỗi:", e)
# Lỗi: Found unknown categories ['CT'] in column 0 during transform

Trong production, một category mới ở inference (vd thêm tỉnh mới, user nhập typo) sẽ làm crash service. Bật handle_unknown="ignore" để encode thành vector toàn 0:

encoder = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
encoder.fit(np.array([["HN"], ["HCM"], ["DN"]]))

print(encoder.transform(np.array([["CT"]])))
# [[0. 0. 0.]]   <- toàn 0, không match category nào

Vector toàn 0 báo cho model "không thuộc category nào đã thấy". Model linear sẽ trả về intercept; tree sẽ rơi vào leaf default. Đây không phải prediction tối ưu, nhưng pipeline không vỡ — đủ tốt cho phần lớn use case.

Lưu ý: handle_unknown="ignore" không tương thích với drop="first" trong một số version cũ của sklearn (vì baseline = unknown sẽ ambiguous). Từ sklearn 1.0 trở đi có thêm option handle_unknown="infrequent_if_exist" để gom các category hiếm — sẽ đề cập ở phần high cardinality.

10

High cardinality và các alternative

High cardinality = category có quá nhiều giá trị unique. Ví dụ: zip_code (10.000 unique ở Mỹ), user_id (hàng triệu), product_sku (hàng chục nghìn).

Áp dụng one-hot lên feature có 10.000 unique → 10.000 cột thêm vào dataset → curse of dimensionality: ma trận khổng lồ, hầu hết 0, model train chậm, dễ overfit, RAM cạn.

Các alternative phổ biến:

  • Target Encoding (Mean Encoding): thay mỗi category bằng mean của target y theo category đó trên train. Ví dụ: zip_HN → mean(price | zip=HN). Hiệu quả, ít chiều, nhưng dễ leak — phải dùng smoothing và cross-validation để tránh overfit. Thư viện category_encodersTargetEncoder; sklearn 1.3+ cũng đã thêm sklearn.preprocessing.TargetEncoder.
  • Frequency Encoding: thay category bằng tần suất xuất hiện (count hoặc proportion). Vd: zip_HN xuất hiện 500/10.000 sample → encode thành 0.05. Giữ một phần thông tin "phổ biến hay hiếm".
  • Embedding: học một vector d chiều cho mỗi category, training cùng model (thường NN). PyTorch có nn.Embedding. Linh hoạt nhất nhưng cần NN và đủ data để học vector. Sẽ học chi tiết ở Series 3.
  • Gộp rare category: các category xuất hiện ít hơn ngưỡng (vd < 1% sample) gom thành "Other" trước khi one-hot. Giảm chiều mạnh, giữ được signal của top category. Sklearn 1.1+ hỗ trợ qua min_frequencymax_categories:
encoder = OneHotEncoder(
    sparse_output=False,
    min_frequency=10,      # category < 10 lần gom vào "infrequent"
    max_categories=20,     # tối đa 20 cột output (gồm cả "infrequent")
    handle_unknown="infrequent_if_exist",
)
encoder.fit(X_train)
11

Sparse output cho tiết kiệm RAM

Một ma trận one-hot điển hình có 99%+ phần tử bằng 0. Lưu dense (numpy array) phí RAM: 1 triệu sample × 10.000 cột × 8 bytes (float64) ≈ 80 GB.

Sklearn mặc định trả về sparse matrix (scipy.sparse.csr_matrix) — chỉ lưu các vị trí khác 0:

encoder = OneHotEncoder()  # sparse_output=True mặc định
X_sparse = encoder.fit_transform(X)
print(type(X_sparse))
# <class 'scipy.sparse._csr.csr_matrix'>
print(X_sparse.shape, X_sparse.nnz)
# (1000000, 10000) 1000000   <- chỉ lưu 1 triệu phần tử thay vì 10 tỷ

Nhiều estimator của sklearn nhận sparse input native: LogisticRegression, LinearSVC, SGDClassifier, MultinomialNB. Một số khác không (đa số tree-based, KNN) — cần convert .toarray(), lúc đó cân nhắc gộp category hoặc giảm chiều trước.

Quy tắc: high cardinality + linear model → giữ sparse. Cardinality vừa phải (< 50 cột) + tree model → dense cho tiện debug.

12

Kết hợp với ColumnTransformer

Dataset thực tế gồm cả cột số và cột categorical. Bạn không muốn scale categorical (vô nghĩa) cũng không muốn OHE cột số. ColumnTransformer apply transformer khác nhau lên các nhóm cột khác nhau:

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

df = pd.DataFrame({
    "age": [25, 30, 22, 28, 35],
    "income": [1000, 2000, 1500, 1800, 2500],
    "city": ["HN", "HCM", "DN", "HN", "HCM"],
    "gender": ["M", "F", "F", "M", "F"],
})

numerical_cols = ["age", "income"]
categorical_cols = ["city", "gender"]

preprocessor = ColumnTransformer([
    ("num", StandardScaler(), numerical_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), categorical_cols),
])

X_transformed = preprocessor.fit_transform(df)
print(X_transformed.shape)  # (5, 2 + 3 + 2) = (5, 7)
print(preprocessor.get_feature_names_out())
# ['num__age' 'num__income' 'cat__city_DN' 'cat__city_HCM' 'cat__city_HN'
#  'cat__gender_F' 'cat__gender_M']

Mỗi tuple trong ColumnTransformer có dạng (name, transformer, columns). Tên ("num", "cat") trở thành prefix trong get_feature_names_out().

Tham số remainder="drop" (mặc định) bỏ cột không khai báo; đổi sang remainder="passthrough" để giữ nguyên. Cả pipeline này có thể nhét vào Pipeline chung với model — sẽ học ở Bài 14.

13

Khi nào KHÔNG cần OHE

  • LightGBM, CatBoost: hỗ trợ categorical feature native. Chỉ cần khai báo cột nào là categorical, model tự xử lý (CatBoost còn có target-statistics encoding built-in). Không nên one-hot trước — sẽ làm chậm và đôi khi tệ hơn.
  • XGBoost ≥ 1.5: có tham số enable_categorical=True + đặt dtype category ở pandas; cho dataset cardinality không quá lớn. Với XGBoost cũ hoặc bài toán thực dụng, label encoding (cast về int) thường đủ tốt vì tree split không cần thứ tự tuyến tính có nghĩa.
  • sklearn HistGradientBoostingClassifier/Regressor: hỗ trợ trực tiếp categorical qua tham số categorical_features (sklearn 1.0+).
  • Embedding trong NN: với high cardinality và đủ data, dùng nn.Embedding thay one-hot cho hiệu quả hơn nhiều về cả RAM lẫn performance.

One-hot vẫn là default an toàn cho linear model, SVM, KNN, neural network khi cardinality vừa phải và bạn không có thông tin target để target-encode.

14

Pitfall thường gặp

  • Fit lại trên test: fit_transform(X_test) sẽ học lại tập category từ test → leak và sai số cột. Luôn fit trên train, transform trên test.
  • Quên handle_unknown="ignore": production crash khi gặp category mới. Bắt buộc bật cho mọi pipeline serving.
  • OHE high cardinality dense: 100k cột × float64 → giết RAM. Hoặc giữ sparse, hoặc đổi alternative (target/frequency encoding, gộp rare).
  • OHE rồi scale: cột 0/1 không cần StandardScaler. Có scale cũng không hỏng kết quả nhưng làm khó diễn giải; nên scale chỉ cột số.
  • Trộn get_dummies giữa train và test riêng lẻ: test thiếu category → ít cột hơn → model expect input shape khác → lỗi runtime. Hoặc dùng reindex sau khi get_dummies, hoặc chuyển sang OneHotEncoder.
  • Quên drop="first" với Linear Regression không regularize: model vẫn fit nhưng coefficient không identifiable, không diễn giải được.
15

Bài tập thực hành

Bài 1. Cho X = np.array([["red"], ["green"], ["blue"], ["red"], ["blue"]]). One-hot encode với OneHotEncoder(sparse_output=False). In ra:

  • Ma trận encode.
  • encoder.categories_.
  • encoder.get_feature_names_out().

Bài 2. Dùng encoder ở bài 1, transform X_test = np.array([["yellow"]]). Quan sát lỗi raise mặc định. Sau đó fit lại encoder mới với handle_unknown="ignore" và verify transform(X_test) trả về vector toàn 0.

Bài 3. Cho DataFrame có 3 cột: age (số), city (nominal, 3 giá trị), education (ordinal, 4 giá trị). Viết ColumnTransformer apply StandardScaler cho cột số, OneHotEncoder cho city, và pass-through cho education (chưa encode ở bài này — sẽ làm ở Bài 11). In get_feature_names_out().

Bài 4. Sinh dataset giả: 1000 sample, cột zip_code có 1000 giá trị unique (cardinality = số sample), target y là số bất kỳ. Trả lời:

  • Nếu one-hot trực tiếp, output có bao nhiêu cột? Vấn đề gì?
  • Đề xuất 2 strategy thay thế và viết 1-2 câu giải thích lý do chọn.

Gợi ý đáp án bài 4: one-hot → 1000 cột, hầu như 0, mỗi cột chỉ giúp phân biệt 1 sample (gần như memorize). Strategy thay thế: (a) Target Encoding dùng mean(y | zip) với smoothing — giữ 1 cột, encode được mối quan hệ với target. (b) Gộp rare zip (vd top 50 zip phổ biến nhất + "Other") rồi mới one-hot — kiểm soát chiều, giữ signal phần lớn data. (c) Frequency encoding nếu phổ biến/hiếm có ý nghĩa.

16

Bài tiếp theo

Bài 11: Label Encoding và Ordinal Encoding — hai cách encode còn lại: LabelEncoder chỉ dùng cho target y, OrdinalEncoder dùng cho feature ordinal với thứ tự custom; và vì sao không lẫn lộn hai class này.