Danh sách bài viết

Bài 7: Feature scaling: Min-Max Normalization

Min-Max Normalization scale mọi feature về cùng một range — mặc định [0, 1]. Vì sao model gradient-based, distance-based, margin-based cần scaling; vì sao tree-based thì không. Công thức, sklearn MinMaxScaler, quy tắc fit chỉ trên train (tránh data leak), feature_range, inverse_transform, MaxAbsScaler cho sparse data, và pitfall lớn nhất: outlier kéo lệch min/max.

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

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

Sau bài học, bạn sẽ:

  • Giải thích được vì sao feature scaling là bước cần thiết trước khi train phần lớn model ML.
  • Phân biệt nhóm model cần scaling (gradient / distance / margin-based) và nhóm không cần (tree-based).
  • Nắm công thức Min-Max Normalization và biết kết quả nằm trong [0, 1].
  • Dùng MinMaxScaler của sklearn đúng API: fit_transform trên train, transform trên test.
  • Hiểu vì sao fit lại trên test là data leak và sai kết quả đánh giá.
  • Đổi range bằng feature_range; đảo ngược bằng inverse_transform.
  • Biết khi nào nên dùng MaxAbsScaler thay cho MinMaxScaler.
  • Nhận diện pitfall lớn nhất của Min-Max: nhạy cảm cực mạnh với outlier.
2

Vì sao cần feature scaling

Dataset thực tế hầu như không bao giờ có các feature cùng đơn vị, cùng độ lớn. Một bảng nhân sự điển hình:

age   (năm)   : 18 – 65        → range  47
salary (VND)  : 5_000_000 – 100_000_000  → range  95_000_000
years_exp     : 0 – 40         → range  40

Nếu đưa thẳng vào model, salary sẽ áp đảo 2 feature còn lại đơn giản vì giá trị tuyệt đối lớn hơn hàng triệu lần — không phải vì nó "quan trọng hơn". Hệ quả phụ thuộc loại model:

  • Gradient descent (Linear / Logistic Regression, Neural Network): hàm loss có dạng "thung lũng" rất dài theo trục feature lớn, rất hẹp theo trục feature nhỏ. Gradient descent zigzag, mất nhiều epoch để hội tụ; learning rate phải nhỏ kẻo overshoot.
  • Distance-based (KNN, K-Means): khoảng cách Euclid \( d(x, y) = \sqrt{\sum_i (x_i - y_i)^2} \) bị thống trị bởi feature có range lớn. Hai người chênh nhau 1 triệu VND lương sẽ "xa" hơn hai người chênh nhau 40 tuổi — vô lý.
  • Margin-based (SVM): khoảng cách từ điểm tới hyperplane phụ thuộc trực tiếp độ lớn feature. Không scale → margin bị méo.
  • Variance-based (PCA): PCA tìm hướng có variance lớn nhất. Feature có range lớn tự động có variance lớn, "ăn" hết các thành phần chính — kết quả PCA gần như chỉ phản ánh đơn vị đo.

Feature scaling đưa mọi feature về cùng một "sân chơi" — cùng range hoặc cùng phân phối — để model đánh giá tầm quan trọng dựa trên pattern thực, không phải đơn vị đo.

3

Model nào CẦN scaling

Các nhóm model dưới đây bắt buộc (hoặc gần như bắt buộc) scaling trước khi train:

  • Linear Regression / Logistic Regression (khi train bằng gradient descent hoặc dùng regularization L1/L2). Sklearn dùng solver dạng đóng (closed-form) cho Linear Regression nên không bắt buộc, nhưng khi có L1/L2 (Ridge, Lasso — Bài 21) thì bắt buộc: penalty cộng trên hệ số, hệ số của feature scale lớn sẽ bị phạt bất công.
  • SVM (Bài 31) — margin tính bằng khoảng cách, scale méo → margin méo, kernel RBF sẽ "chết" vì khoảng cách trong exponent quá lớn / quá nhỏ.
  • KNN (Bài 27) — toàn bộ thuật toán là khoảng cách giữa các điểm.
  • K-Means (Bài 32) — centroid và assignment đều dùng khoảng cách Euclid.
  • PCA (Bài 36) — phân tích phương sai; không scale thì PCA chỉ phản ánh đơn vị đo.
  • Neural Network (Series 3) — gradient descent + activation function (sigmoid / tanh) đều nhạy cảm độ lớn input. Mạng không scale thường không hội tụ hoặc hội tụ rất chậm.

Có 2 lựa chọn scaling chính dùng cho các model trên: Min-Max Normalization (bài này) và Standardization / Z-score (Bài 8). Bài 9 sẽ so sánh trực tiếp khi nào nên dùng cái nào.

4

Model nào KHÔNG cần scaling

Một nhóm model không thay đổi kết quả khi scale feature: các thuật toán dựa trên cây quyết định.

  • Decision Tree (Bài 28)
  • Random Forest (Bài 29)
  • Gradient Boosting — XGBoost, LightGBM, CatBoost (Bài 30)

Lý do: mỗi node chỉ hỏi câu hỏi dạng "feature_i < threshold ?". Threshold được chọn từ chính các giá trị thực của feature đó. Nếu scale feature về [0, 1], threshold cũng tự động scale theo — split point đổi nhưng tập hợp các sample đi về mỗi nhánh không đổi. Cây sau scaling tương đương cây trước scaling.

Hệ quả thực dụng: với Random Forest / XGBoost, có thể bỏ qua bước scaling — không sai, không thừa. Nhưng nếu cùng dataset cần dùng nhiều model (so sánh Logistic Regression với XGBoost), ta vẫn để scaling trong pipeline cho thống nhất; nó chỉ "vô hại" với cây, không gây hại. Bài 9 sẽ deep hơn về phần này.

5

Công thức Min-Max Normalization

Min-Max scale một feature về đoạn [0, 1] bằng biến đổi affine — trừ đi min, chia cho range:

\[ x' = \frac{x - x_{\min}}{x_{\max} - x_{\min}} \]

Trong đó \( x_{\min} \) và \( x_{\max} \) là min và max của feature đó trên tập train. Kết quả:

  • Giá trị nhỏ nhất (x = x_min) → x' = 0.
  • Giá trị lớn nhất (x = x_max) → x' = 1.
  • Mọi giá trị giữa → tỉ lệ tuyến tính trong [0, 1].

Tổng quát hơn — scale về một đoạn tuỳ ý [a, b]:

\[ x' = a + \frac{(x - x_{\min})(b - a)}{x_{\max} - x_{\min}} \]

Đây chính là công thức sklearn dùng khi đặt feature_range=(a, b) (xem mục 7).

Tính chất quan trọng:

  • Biến đổi tuyến tính — không thay đổi hình dạng phân phối, không thay đổi correlation giữa các feature.
  • Bảo toàn thứ tự — nếu \( x_1 < x_2 \) thì \( x_1' < x_2' \).
  • Đảo ngược được — biết \( x_{\min}, x_{\max} \) thì lấy lại \( x \) từ \( x' \) (mục 9).
6

sklearn MinMaxScaler — API cơ bản

Class MinMaxScaler nằm trong sklearn.preprocessing, theo đúng API fit / transform / fit_transform chung của sklearn.

from sklearn.preprocessing import MinMaxScaler
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split

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

scaler = MinMaxScaler()                      # mặc định feature_range=(0, 1)
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled  = scaler.transform(X_test)    # KHÔNG fit lại trên test!

print("Train min :", X_train_scaled.min(axis=0))   # [0. 0. 0. 0.]
print("Train max :", X_train_scaled.max(axis=0))   # [1. 1. 1. 1.]
print("Test  min :", X_test_scaled.min(axis=0))    # có thể < 0
print("Test  max :", X_test_scaled.max(axis=0))    # có thể > 1

Sau khi fit, scaler nhớ min_scale_ đã học:

print("data_min_  :", scaler.data_min_)   # min của từng feature trong train
print("data_max_  :", scaler.data_max_)
print("scale_     :", scaler.scale_)      # = 1 / (data_max_ - data_min_)
print("min_       :", scaler.min_)        # = -data_min_ * scale_

Công thức nội bộ của sklearn: X_scaled = X * scale_ + min_ — tương đương công thức ở mục 5 nhưng viết dưới dạng nhân + cộng để tận dụng vector hoá NumPy.

7

feature_range — đổi range mặc định

Tham số feature_range nhận một tuple (a, b) để chọn đoạn đích.

from sklearn.preprocessing import MinMaxScaler

scaler_pm1 = MinMaxScaler(feature_range=(-1, 1))
X_train_pm1 = scaler_pm1.fit_transform(X_train)

print(X_train_pm1.min(axis=0))   # [-1. -1. -1. -1.]
print(X_train_pm1.max(axis=0))   # [ 1.  1.  1.  1.]

Khi nào không dùng mặc định [0, 1]?

  • [-1, 1] — phù hợp với activation tanh (range output đúng [-1, 1]), hoặc khi muốn đối xứng quanh 0 mà không cần Standardization đầy đủ.
  • [0, 255] — gần như không bao giờ dùng cho input model, nhưng có thể tiện cho visualization ảnh.
  • [0, 1] mặc định — dùng cho 95% trường hợp; phù hợp với sigmoid và pixel ảnh.

Đa số bài thực tế chỉ dùng (0, 1) hoặc (-1, 1); các giá trị khác hiếm gặp.

8

Quy tắc vàng: fit trên train, transform cho cả 2

Quy tắc quan trọng nhất khi dùng bất kỳ scaler nào:

  • fit (hoặc fit_transform) chỉ chạy trên train.
  • transform chạy trên cả train và test (và val), dùng cùng bộ tham số đã học từ train.

Vì sao? Test set là "tương lai" mà model chưa thấy. Nếu fit trên toàn bộ data (train + test) thì minmax đã chứa thông tin từ test — model đã "biết" về test trước khi predict. Đây là data leak: kết quả đánh giá trên test sẽ tốt hơn thực tế, và khi deploy gặp dữ liệu mới, model sẽ tệ hơn dự kiến.

Sai (data leak)

# SAI — fit trên toàn bộ data trước khi split
scaler = MinMaxScaler()
X_all_scaled = scaler.fit_transform(X)         # leak!
X_train, X_test, y_train, y_test = train_test_split(X_all_scaled, y, test_size=0.2)

Sai (fit lại trên test)

# SAI — fit lại trên test → min/max khác giữa train và test
scaler = MinMaxScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled  = scaler.fit_transform(X_test)  # khác scaler!

Đúng

scaler = MinMaxScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled  = scaler.transform(X_test)      # dùng min/max của train

Lưu ý quan trọng: vì minmax lấy từ train, một giá trị trong test có thể lớn hơn x_max của train hoặc nhỏ hơn x_min → kết quả sau scaling sẽ vượt đoạn [0, 1]. Đây là hành vi đúng, không phải bug. Model vẫn predict được; nó chỉ phản ánh sự thật rằng test có sample "ngoài kinh nghiệm" của train.

Khi pipeline phức tạp hơn (nhiều bước preprocessing), nên dùng sklearn.pipeline.Pipeline (Bài 14) — pipeline tự đảm bảo mỗi fit chỉ chạy trên train, mỗi transform chạy đúng dữ liệu.

9

inverse_transform — đưa về scale gốc

Mọi scaler của sklearn đều có method inverse_transform — đảo ngược biến đổi để lấy lại scale gốc.

from sklearn.preprocessing import MinMaxScaler
import numpy as np

X = np.array([[1.0], [5.0], [10.0]])

scaler = MinMaxScaler()
X_scaled = scaler.fit_transform(X)
print(X_scaled.ravel())              # [0.    0.444 1.   ]

X_back = scaler.inverse_transform(X_scaled)
print(X_back.ravel())                # [ 1.  5. 10.]

Use case chính của inverse_transform:

  • Regression với scaled target. Khi train regression, đôi khi ta scale luôn y (về [0, 1]) để loss số học ổn định. Model output là số trong [0, 1] — cần inverse_transform để báo cáo kết quả theo đơn vị gốc (VND, mét, độ C…).
  • Hiển thị / debug. Sample scaled không trực quan; muốn in ra để con người đọc thì inverse_transform rồi log.
  • Reverse cho production. Khi deploy: input đến từ user ở scale gốc → scaler.transform → model → inverse_transform output (nếu target được scale) → trả về user.

Nếu chỉ scale X (không scale y) — trường hợp phổ biến nhất — thì hiếm khi cần inverse_transform.

10

MaxAbsScaler — variant cho sparse data

MaxAbsScaler là biến thể của Min-Max, scale theo giá trị tuyệt đối lớn nhất:

\[ x' = \frac{x}{|x_{\max}|} \]

Kết quả luôn nằm trong [-1, 1]. Khác biệt cốt lõi so với MinMaxScaler: không trừ đi giá trị nào, chỉ chia. Hệ quả: giá trị 0 trong input vẫn là 0 sau khi scale — giữ được sparsity.

from sklearn.preprocessing import MaxAbsScaler
import numpy as np

X = np.array([[ 0.0, 1.0],
              [ 0.0, 5.0],
              [ 0.0, -10.0]])

scaler = MaxAbsScaler()
X_scaled = scaler.fit_transform(X)
print(X_scaled)
# [[ 0.   0.1]
#  [ 0.   0.5]
#  [ 0.  -1. ]]
# Cột 0 toàn 0 → giữ nguyên 0 sau scaling

Khi nào dùng MaxAbsScaler?

  • Sparse matrix — TF-IDF, bag-of-words, one-hot encoded high-cardinality. MinMaxScaler sẽ phá sparsity (vì trừ min biến 0 thành số khác 0, khiến ma trận thành dense và tốn bộ nhớ).
  • Data đã centered quanh 0 (mean ≈ 0) — không cần dịch về 0 nữa.

Với data dense thông thường, dùng MinMaxScaler hoặc StandardScaler (Bài 8); chỉ chuyển sang MaxAbsScaler khi sparsity quan trọng.

11

Pitfall: outlier kéo lệch min / max

Điểm yếu lớn nhất của Min-Max: chỉ dùng 2 con số (minmax) để định nghĩa toàn bộ biến đổi. Một outlier duy nhất ở 2 đầu có thể phá toàn bộ scaling.

Ví dụ giá nhà trong một quận, 99% căn nằm trong khoảng 1–5 tỷ, nhưng có 1 biệt thự 100 tỷ:

import numpy as np
from sklearn.preprocessing import MinMaxScaler

prices = np.array([1, 2, 3, 4, 5, 100]).reshape(-1, 1)  # tỷ VND
scaler = MinMaxScaler()
scaled = scaler.fit_transform(prices)
print(scaled.ravel())
# [0.       0.0101   0.0202   0.0303   0.0404   1.     ]

99% data bị co dồn về khoảng [0, 0.04] — gần như không phân biệt được giữa căn 1 tỷ và căn 5 tỷ. Outlier 100 tỷ độc chiếm phần range còn lại. Với KNN hay K-Means, khoảng cách giữa các căn "thường" gần như bằng 0; model sẽ coi chúng đều như nhau.

Cách khắc phục:

  • Xử lý outlier trước — Bài 12 (IQR / Z-score) sẽ học. Cắt hoặc loại bỏ giá trị bất thường rồi mới scale.
  • Dùng Standardization (Z-score, Bài 8) — vẫn nhạy với outlier nhưng ít hơn vì dùng meanstd (phân tán đều) thay vì 2 đầu cực.
  • Dùng RobustScaler — dùng median và IQR thay vì min/max và mean/std. Sẽ đề cập ở Bài 12.
  • Log transform — với feature có phân phối lệch nặng (giá, thu nhập, dân số), log(x) nén đuôi dài về kích thước hợp lý trước khi scale.

Quy tắc thực dụng: Min-Max chỉ dùng khi feature đã sạch outlier hoặc có range biết trước (pixel 0–255, percentage 0–100, score 0–1).

12

Use case typical

Các tình huống Min-Max là lựa chọn tự nhiên (hợp lý hơn Standardization):

  • Image pixel — ảnh grayscale / RGB có range cố định [0, 255]. Chia cho 255 (đúng MinMaxScaler với data_min_ = 0, data_max_ = 255) đưa về [0, 1] — input chuẩn cho CNN.
  • Sigmoid / tanh activation — sigmoid output trong (0, 1), tanh trong (-1, 1). Input scale về cùng range giữ activation trong vùng nhạy (gradient không bị saturated).
  • Feature có range biết trước — percentage (0–100), age sinh học (0–120), score game (0–10).
  • Khi muốn giữ ý nghĩa "0 = nhỏ nhất, 1 = lớn nhất" — dễ giải thích cho người dùng, dễ visualize trên heatmap.

Ngược lại, khi feature có phân phối gần Gaussian, hoặc khi không biết range tối đa (ví dụ số lượt truy cập website / ngày), Standardization (Bài 8) thường an toàn hơn.

13

Code Python end-to-end

Scale một feature đơn giản

import numpy as np
from sklearn.preprocessing import MinMaxScaler

ages = np.array([18, 25, 30, 45, 65]).reshape(-1, 1)
scaler = MinMaxScaler()
ages_scaled = scaler.fit_transform(ages)

print(ages_scaled.ravel())
# [0.     0.149  0.255  0.574  1.    ]
# 18 → 0, 65 → 1, các giá trị giữa nội suy tuyến tính

Scale ma trận nhiều feature

import numpy as np
from sklearn.preprocessing import MinMaxScaler

X = np.array([
    [25, 50_000_000, 2],
    [30, 75_000_000, 5],
    [45, 30_000_000, 20],
    [60, 90_000_000, 35],
])  # age, salary, years_exp

scaler = MinMaxScaler()
X_scaled = scaler.fit_transform(X)
print(X_scaled.round(3))
# [[0.    0.333 0.   ]
#  [0.143 0.75  0.091]
#  [0.571 0.    0.545]
#  [1.    1.    1.   ]]

Demo inverse_transform

X_back = scaler.inverse_transform(X_scaled)
print(X_back.round(0))
# [[2.5e+01 5.0e+07 2.0e+00]
#  [3.0e+01 7.5e+07 5.0e+00]
#  [4.5e+01 3.0e+07 2.0e+01]
#  [6.0e+01 9.0e+07 3.5e+01]]
# Khớp với X ban đầu

Demo bug khi fit lại trên test

import numpy as np
from sklearn.preprocessing import MinMaxScaler

X_train = np.array([[1.0], [2.0], [3.0], [4.0], [5.0]])
X_test  = np.array([[2.0], [4.0], [6.0]])     # 6 vượt max train

# Đúng: dùng cùng scaler
scaler = MinMaxScaler()
scaler.fit(X_train)
print("train_min, train_max =", scaler.data_min_, scaler.data_max_)
print("test transform đúng :", scaler.transform(X_test).ravel())
# [0.25 0.75 1.25]   ← 1.25 vượt 1, nhưng đúng — phản ánh test có giá trị mới

# Sai: fit lại trên test
scaler_bad = MinMaxScaler()
print("test transform sai  :", scaler_bad.fit_transform(X_test).ravel())
# [0.  0.5 1. ]      ← bị "ép" về [0,1] với min/max khác → train/test không cùng scale

Hai output cuối khác nhau dù cùng input. Khi đưa vào model đã train trên train scaled, version "sai" cho kết quả vô nghĩa vì giá trị test đã bị mapping bằng một hàm khác hẳn.

14

Bài tập

  1. Load iris bằng load_iris(return_X_y=True). Áp MinMaxScaler() cho toàn bộ X. Verify X_scaled.min(axis=0) đều bằng 0 và X_scaled.max(axis=0) đều bằng 1.
  2. Lặp lại bài 1 nhưng dùng MinMaxScaler(feature_range=(-1, 1)). Verify min/max của mỗi cột.
  3. Chia iris thành train/test với test_size=0.2, random_state=42. Fit scaler trên train, transform cả 2. In X_test_scaled.min(axis=0)X_test_scaled.max(axis=0) — quan sát có cột nào âm hoặc > 1 không, giải thích.
  4. Cho data = np.array([1, 2, 3, 4, 1000]).reshape(-1, 1). Scale bằng MinMaxScaler. Quan sát giá trị sau scaling của 4 số đầu — chúng có còn phân biệt được không? Liên hệ pitfall mục 11.
  5. Scale lại data ở bài 4 sau khi loại bỏ outlier 1000 (dùng data[data < 100]). So sánh khoảng cách giữa 1 và 4 trong 2 trường hợp.
Đáp án ngắn
  1. X_scaled.min(axis=0)[0. 0. 0. 0.], X_scaled.max(axis=0)[1. 1. 1. 1.].
  2. Min → [-1. -1. -1. -1.], max → [1. 1. 1. 1.].
  3. Một số cột test có min < 0 hoặc max > 1 vì sample test chứa giá trị nhỏ hơn min hoặc lớn hơn max của train. Đây là hành vi đúng — phản ánh test có giá trị "ngoài kinh nghiệm" của train.
  4. Sau scaling: [0.000, 0.001, 0.002, 0.003, 1.000]. 4 giá trị đầu gần như dính vào nhau, mất khả năng phân biệt — đúng pitfall outlier.
  5. Loại 1000, scale [1, 2, 3, 4][0, 0.333, 0.667, 1.0]. Khoảng cách giữa 1 và 4 là 1.0; ở bài 4 chỉ là 0.003. Sau khi loại outlier, model phân biệt được giữa các sample "thường".
15

Tóm tắt

  • Feature scaling cần thiết khi các feature có range chênh lệch lớn — feature lớn sẽ áp đảo feature nhỏ trong gradient, khoảng cách, margin, variance.
  • Cần scaling: Linear / Logistic Regression (với L1/L2), SVM, KNN, K-Means, PCA, Neural Network.
  • Không cần scaling: Decision Tree, Random Forest, Gradient Boosting (XGBoost, LightGBM, CatBoost) — split dựa trên threshold, không nhạy với scale.
  • Công thức Min-Max: \( x' = (x - x_{\min}) / (x_{\max} - x_{\min}) \). Kết quả \( x' \in [0, 1] \). Tổng quát về \([a, b]\): \( x' = a + (x - x_{\min})(b - a) / (x_{\max} - x_{\min}) \).
  • MinMaxScaler trong sklearn.preprocessing: fit_transform trên train, transform trên test. feature_range đổi đoạn đích.
  • Quy tắc vàng: fit chỉ chạy trên train. Fit trên toàn bộ data hoặc fit lại trên test → data leak. Test có thể vượt [0, 1] sau scaling — bình thường.
  • inverse_transform đảo ngược về scale gốc — hữu ích khi scale luôn target trong regression.
  • MaxAbsScaler chia cho |x_max|, range [-1, 1], giữ sparsity (không trừ gì) — dùng cho TF-IDF, bag-of-words, ma trận thưa.
  • Pitfall lớn nhất: outlier kéo lệch min/max → 99% data co dồn về một góc nhỏ. Min-Max chỉ an toàn khi feature đã sạch outlier hoặc có range biết trước.
  • Use case typical: image pixel (0–255 → 0–1), sigmoid/tanh activation, feature có range cố định. Khi không chắc chắn, cân nhắc Standardization (Bài 8).