Back to Blog

[ML101] Chương 2: Dự án Machine Learning đầu tiên

Hướng dẫn từng bước xây dựng dự án Machine Learning đầu tiên với Python

Bài viết có tham khảo, sử dụng và sửa đổi tài nguyên từ kho lưu trữ handson-mlp, tuân thủ giấy phép Apache‑2.0. Chúng tôi chân thành cảm ơn tác giả Aurélien Géron (@aureliengeron) vì sự chia sẻ kiến thức tuyệt vời và những đóng góp quý giá cho cộng đồng.

Chào mừng bạn đến với chương thực hành đầu tiên. Trong chương này, chúng ta sẽ đi qua toàn bộ quy trình của một dự án Machine Learning thực tế, từ việc thu thập dữ liệu, xử lý dữ liệu, chọn mô hình, tinh chỉnh tham số cho đến khi triển khai.

Mục tiêu của chúng ta là xây dựng một mô hình dự đoán giá nhà trung bình tại các quận (districts) thuộc bang California, dựa trên các đặc trưng dữ liệu từ cuộc điều tra dân số.

Bạn có thể chạy trực tiếp các đoạn mã code tại: Google Colab.

1. Thiết lập Môi trường

Trước khi bắt đầu, chúng ta nên đảm bảo môi trường Python đáp ứng các yêu cầu về phiên bản của ngôn ngữ và các thư viện cần thiết như Scikit-Learn.

Đầu tiên, hãy gửi lời chào đến thế giới Machine Learning!

print("Welcome to Machine Learning!")
Welcome to Machine Learning!

Dự án này yêu cầu Python phiên bản 3.10 trở lên để đảm bảo tính tương thích của các cú pháp mới.

import sys

# Kiểm tra phiên bản Python hiện tại.
# Nếu version < 3.10, chương trình sẽ báo lỗi AssertionError.
assert sys.version_info >= (3, 10)

Chúng ta cũng cần thư viện Scikit-Learn phiên bản 1.6.1 trở lên. Scikit-Learn là thư viện quan trọng nhất cho các thuật toán Machine Learning cổ điển trong Python.

from packaging.version import Version
import sklearn

# Kiểm tra phiên bản sklearn.
assert Version(sklearn.__version__) >= Version("1.6.1")

2. Thu thập Dữ liệu

Nhiệm vụ của bạn ở ví dụ này là dự đoán giá nhà trung vị (median house value) tại các quận của California, dựa trên các đặc trưng như dân số, thu nhập trung vị, v.v.

2.1. Tải dữ liệu

Trong thực tế, dữ liệu thường được lưu trữ trong các cơ sở dữ liệu quan hệ (relational databases) hoặc các kho dữ liệu. Tuy nhiên, để đơn giản hóa việc học tập, chúng ta sẽ tải một file nén (tarball) chứa dữ liệu CSV từ GitHub.

Đoạn mã dưới đây định nghĩa hàm load_housing_data để tự động hóa việc tải xuống và giải nén dữ liệu nếu nó chưa tồn tại trên máy của bạn.

from pathlib import Path
import pandas as pd
import tarfile
import urllib.request

def load_housing_data():
    # Định nghĩa đường dẫn file nén
    tarball_path = Path("datasets/housing.tgz")

    # Nếu file chưa tồn tại, tiến hành tải về
    if not tarball_path.is_file():
        # Tạo thư mục datasets nếu chưa có
        Path("datasets").mkdir(parents=True, exist_ok=True)
        url = "https://github.com/ageron/data/raw/main/housing.tgz"
        # Tải file từ URL về đường dẫn local
        urllib.request.urlretrieve(url, tarball_path)

        # Giải nén file
        with tarfile.open(tarball_path) as housing_tarball:
            housing_tarball.extractall(path="datasets", filter="data")

    # Đọc file CSV vào Pandas DataFrame
    return pd.read_csv(Path("datasets/housing/housing.csv"))

# Gọi hàm để lấy dữ liệu
housing_full = load_housing_data()

2.2. Khám phá cấu trúc dữ liệu

Sau khi tải dữ liệu, bước đầu tiên quan trọng là hiểu cấu trúc của nó. Chúng ta sử dụng phương thức head() để xem 5 dòng đầu tiên.

housing_full.head()
   longitude  latitude  housing_median_age  total_rooms  total_bedrooms  \
0    -122.23     37.88                41.0        880.0           129.0   
1    -122.22     37.86                21.0       7099.0          1106.0   
2    -122.24     37.85                52.0       1467.0           190.0   
3    -122.25     37.85                52.0       1274.0           235.0   
4    -122.25     37.85                52.0       1627.0           280.0   

   population  households  median_income  median_house_value ocean_proximity  
0       322.0       126.0         8.3252            452600.0        NEAR BAY  
1      2401.0      1138.0         8.3014            358500.0        NEAR BAY  
2       496.0       177.0         7.2574            352100.0        NEAR BAY  
3       558.0       219.0         5.6431            341300.0        NEAR BAY  
4       565.0       259.0         3.8462            342200.0        NEAR BAY  

Tiếp theo, phương thức info() cung cấp thông tin tổng quan về dữ liệu, bao gồm tổng số dòng, kiểu dữ liệu của từng cột và số lượng giá trị khác rỗng (non-null). Điều này giúp ta phát hiện nhanh các dữ liệu bị thiếu.

housing_full.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20640 entries, 0 to 20639
Data columns (total 10 columns):
 #   Column              Non-Null Count  Dtype  
---  ------              --------------  -----  
 0   longitude           20640 non-null  float64
 1   latitude            20640 non-null  float64
 2   housing_median_age  20640 non-null  float64
 3   total_rooms         20640 non-null  float64
 4   total_bedrooms      20433 non-null  float64
 5   population          20640 non-null  float64
 6   households          20640 non-null  float64
 7   median_income       20640 non-null  float64
 8   median_house_value  20640 non-null  float64
 9   ocean_proximity     20640 non-null  object 
dtypes: float64(9), object(1)
memory usage: 1.6+ MB

Quan sát kết quả info(), ta thấy cột ocean_proximity có kiểu dữ liệu là object, trong khi các cột khác là số (float64). Vì đây là file CSV, object thường ám chỉ dữ liệu dạng văn bản (text). Ta có thể kiểm tra xem có bao nhiêu danh mục (categories) trong cột này bằng phương thức value_counts().

housing_full["ocean_proximity"].value_counts()
count
ocean_proximity
<1H OCEAN 9136
INLAND 6551
NEAR OCEAN 2658
NEAR BAY 2290
ISLAND 5

Để hiểu rõ hơn về các thuộc tính số (numerical attributes), phương thức describe() sẽ tính toán các thống kê mô tả như trung bình (mean), độ lệch chuẩn (std), min, max và các phân vị (percentiles).

housing_full.describe()
          longitude      latitude  housing_median_age   total_rooms  \
count  20640.000000  20640.000000        20640.000000  20640.000000   
mean    -119.569704     35.631861           28.639486   2635.763081   
std        2.003532      2.135952           12.585558   2181.615252   
min     -124.350000     32.540000            1.000000      2.000000   
25%     -121.800000     33.930000           18.000000   1447.750000   
50%     -118.490000     34.260000           29.000000   2127.000000   
75%     -118.010000     37.710000           37.000000   3148.000000   
max     -114.310000     41.950000           52.000000  39320.000000   

       total_bedrooms    population    households  median_income  \
count    20433.000000  20640.000000  20640.000000   20640.000000   
mean       537.870553   1425.476744    499.539680       3.870671   
std        421.385070   1132.462122    382.329753       1.899822   
min          1.000000      3.000000      1.000000       0.499900   
25%        296.000000    787.000000    280.000000       2.563400   
50%        435.000000   1166.000000    409.000000       3.534800   
75%        647.000000   1725.000000    605.000000       4.743250   
max       6445.000000  35682.000000   6082.000000      15.000100   

       median_house_value  
count        20640.000000  
mean        206855.816909  
std         115395.615874  
min          14999.000000  
25%         119600.000000  
50%         179700.000000  
75%         264725.000000  
max         500001.000000  

Một cách khác để cảm nhận dữ liệu là vẽ biểu đồ histogram cho từng thuộc tính số. Histogram hiển thị số lượng các mẫu dữ liệu (trục tung) nằm trong một khoảng giá trị nhất định (trục hoành).

import matplotlib.pyplot as plt

# extra code – các dòng sau thiết lập kích thước font chữ mặc định cho biểu đồ
plt.rc('font', size=14)
plt.rc('axes', labelsize=14, titlesize=14)
plt.rc('legend', fontsize=14)
plt.rc('xtick', labelsize=10)
plt.rc('ytick', labelsize=10)

# Vẽ histogram cho toàn bộ các cột số
housing_full.hist(bins=50, figsize=(12, 8))
plt.show()

png

3. Tạo tập kiểm tra (Test Set)

Một nguyên tắc vàng trong Machine Learning là: Không bao giờ được nhìn vào tập kiểm tra (Test Set) cho đến khi mô hình đã hoàn thiện. Nếu bạn khám phá tập kiểm tra quá sớm, não bộ của bạn có thể vô tình phát hiện ra các mẫu (patterns) trong đó và lựa chọn mô hình thiên vị theo các mẫu này. Hiện tượng này gọi là Thiên kiến dòm ngó dữ liệu (Data Snooping Bias).

3.1. Lấy mẫu ngẫu nhiên (Random Sampling)

Cách đơn giản nhất là chọn ngẫu nhiên 20% dữ liệu làm tập kiểm tra.

import numpy as np

def shuffle_and_split_data(data, test_ratio, rng):
    # Hoán vị ngẫu nhiên các chỉ số (index)
    shuffled_indices = rng.permutation(len(data))
    # Tính kích thước tập test
    test_set_size = int(len(data) * test_ratio)
    # Tách chỉ số cho test và train
    test_indices = shuffled_indices[:test_set_size]
    train_indices = shuffled_indices[test_set_size:]
    # Trả về 2 DataFrame tương ứng
    return data.iloc[train_indices], data.iloc[test_indices]

Để đảm bảo kết quả có thể tái lập (reproducible), chúng ta cần thiết lập một hạt giống ngẫu nhiên (random seed).

# Tạo bộ sinh số ngẫu nhiên với seed cố định là 42
rng = np.random.default_rng(seed=42)
train_set, test_set = shuffle_and_split_data(housing_full, 0.2, rng)

# Kiểm tra kích thước tập train
len(train_set)
16512
# Kiểm tra kích thước tập test
len(test_set)
4128

Lưu ý về tính nhất quán: Ngay cả khi đặt seed, nếu dữ liệu được cập nhật (thêm dòng mới), việc xáo trộn lại có thể khiến các mẫu trong tập test cũ lọt vào tập train mới. Để giải quyết, ta có thể dùng định danh (identifier) của mỗi mẫu để quyết định nó thuộc tập nào (ví dụ: tính hash của ID).

from zlib import crc32

def is_id_in_test_set(identifier, test_ratio):
    # Kiểm tra xem mã hash của ID có nhỏ hơn ngưỡng tỉ lệ hay không
    return crc32(np.int64(identifier)) < test_ratio * 2**32

def split_data_with_id_hash(data, test_ratio, id_column):
    ids = data[id_column]
    # Áp dụng hàm kiểm tra cho từng ID
    in_test_set = ids.apply(lambda id_: is_id_in_test_set(id_, test_ratio))
    return data.loc[~in_test_set], data.loc[in_test_set]

Dữ liệu nhà ở không có cột ID, ta có thể dùng chỉ số dòng (row index) làm ID:

housing_with_id = housing_full.reset_index()  # thêm cột `index`
train_set, test_set = split_data_with_id_hash(housing_with_id, 0.2, "index")

Tuy nhiên, nếu thứ tự dòng thay đổi, cách trên sẽ sai. Giải pháp tốt hơn là tạo một ID ổn định từ kinh độ và vĩ độ:

# Tạo ID từ kinh độ và vĩ độ
housing_with_id["id"] = (housing_full["longitude"] * 1000
                         + housing_full["latitude"])
train_set, test_set = split_data_with_id_hash(housing_with_id, 0.2, "id")

Trong Scikit-Learn, hàm train_test_split thực hiện việc chia ngẫu nhiên rất tiện lợi:

from sklearn.model_selection import train_test_split

train_set, test_set = train_test_split(housing_full, test_size=0.2,
                                       random_state=42)

Kiểm tra dữ liệu bị thiếu trong tập test:

test_set["total_bedrooms"].isnull().sum()
np.int64(44)

3.2. Lấy mẫu phân tầng (Stratified Sampling)

Lấy mẫu ngẫu nhiên hoạt động tốt nếu tập dữ liệu đủ lớn. Nếu không, ta có nguy cơ gặp lỗi lấy mẫu (sampling bias). Ví dụ, nếu thu nhập trung vị (median income) là một yếu tố quan trọng để dự đoán giá nhà, ta cần đảm bảo tập kiểm tra có phân phối thu nhập tương tự như tổng thể. Đây gọi là lấy mẫu phân tầng.

Chúng ta sẽ tạo một cột phân loại thu nhập income_cat:

# Đoạn mã mô phỏng xác suất (lý thuyết xác suất thống kê)
# extra code – tính xác suất mẫu xấu (10.7%)
from scipy.stats import binom

sample_size = 1000
ratio_female = 0.516
proba_too_small = binom(sample_size, ratio_female).cdf(490 - 1)
proba_too_large = 1 - binom(sample_size, ratio_female).cdf(540)
print(proba_too_small + proba_too_large)
0.10727422667455615
# extra code – một cách khác để ước lượng xác suất bằng mô phỏng
rng = np.random.default_rng(seed=42)
samples = (rng.random((100_000, sample_size)) < ratio_female).sum(axis=1)
((samples < 490) | (samples > 540)).mean()
np.float64(0.1077)

Tạo các nhóm thu nhập (income categories) để thực hiện phân tầng:

# Chia median_income thành 5 nhóm (bins)
housing_full["income_cat"] = pd.cut(housing_full["median_income"],
                                    bins=[0., 1.5, 3.0, 4.5, 6., np.inf],
                                    labels=[1, 2, 3, 4, 5])

Vẽ biểu đồ phân phối các nhóm thu nhập:

cat_counts = housing_full["income_cat"].value_counts().sort_index()
cat_counts.plot.bar(rot=0, grid=True)
plt.xlabel("Income category")
plt.ylabel("Number of districts")
plt.show()

png

Bây giờ ta dùng StratifiedShuffleSplit hoặc train_test_split với tham số stratify:

from sklearn.model_selection import StratifiedShuffleSplit

splitter = StratifiedShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
strat_splits = []
for train_index, test_index in splitter.split(housing_full,
                                              housing_full["income_cat"]):
    strat_train_set_n = housing_full.iloc[train_index]
    strat_test_set_n = housing_full.iloc[test_index]
    strat_splits.append([strat_train_set_n, strat_test_set_n])

strat_train_set, strat_test_set = strat_splits[0]

Cách viết ngắn gọn hơn:

strat_train_set, strat_test_set = train_test_split(
    housing_full, test_size=0.2, stratify=housing_full["income_cat"],
    random_state=42)

Kiểm tra tỷ lệ các nhóm trong tập test phân tầng:

strat_test_set["income_cat"].value_counts() / len(strat_test_set)
count
income_cat
3 0.350533
2 0.318798
4 0.176357
5 0.114341
1 0.039971

So sánh tỷ lệ sai số giữa lấy mẫu ngẫu nhiên và lấy mẫu phân tầng so với tổng thể:

# extra code – tính toán dữ liệu cho Hình 2–10
def income_cat_proportions(data):
    return data["income_cat"].value_counts() / len(data)

train_set, test_set = train_test_split(housing_full, test_size=0.2,
                                       random_state=42)

compare_props = pd.DataFrame({
    "Overall %": income_cat_proportions(housing_full),
    "Stratified %": income_cat_proportions(strat_test_set),
    "Random %": income_cat_proportions(test_set),
}).sort_index()
compare_props.index.name = "Income Category"
compare_props["Strat. Error %"] = (compare_props["Stratified %"] /
                                   compare_props["Overall %"] - 1)
compare_props["Rand. Error %"] = (compare_props["Random %"] /
                                  compare_props["Overall %"] - 1)
(compare_props * 100).round(2)
                 Overall %  Stratified %  Random %  Strat. Error %  \
Income Category                                                      
1                     3.98          4.00      4.24            0.36   
2                    31.88         31.88     30.74           -0.02   
3                    35.06         35.05     34.52           -0.01   
4                    17.63         17.64     18.41            0.03   
5                    11.44         11.43     12.09           -0.08   

                 Rand. Error %  
Income Category                 
1                         6.45  
2                        -3.59  
3                        -1.53  
4                         4.42  
5                         5.63  

Sau khi chia xong, ta cần xóa cột income_cat để trả dữ liệu về trạng thái ban đầu:

for set_ in (strat_train_set, strat_test_set):
    set_.drop("income_cat", axis=1, inplace=True)

4. Khám phá và Trực quan hóa Dữ liệu (Discover and Visualize the Data)

Bây giờ chúng ta sẽ chỉ làm việc trên tập huấn luyện (strat_train_set) để tránh làm rò rỉ thông tin từ tập test.

housing = strat_train_set.copy()

4.1. Trực quan hóa Dữ liệu Địa lý

Vì dữ liệu có kinh độ và vĩ độ, biểu đồ phân tán (scatter plot) là lựa chọn tốt nhất để hình dung vị trí địa lý.

# Biểu đồ cơ bản
housing.plot(kind="scatter", x="longitude", y="latitude", grid=True)
plt.show()

png

Để thấy rõ mật độ các điểm dữ liệu, ta giảm độ mờ (alpha).

# Biểu đồ hiển thị mật độ
housing.plot(kind="scatter", x="longitude", y="latitude", grid=True, alpha=0.2)
plt.show()

png

Bây giờ ta thêm thông tin về dân số (kích thước hình tròn - s) và giá nhà (màu sắc - c). Bản đồ nhiệt (heatmap) cmap="jet" sẽ giúp phân biệt giá trị từ thấp (xanh) đến cao (đỏ).

housing.plot(kind="scatter", x="longitude", y="latitude", grid=True,
            s=housing["population"] / 100, label="population",
            c="median_house_value", cmap="jet", colorbar=True,
            legend=True, sharex=False, figsize=(10, 7))
plt.show()

png

Dưới đây là phiên bản đẹp hơn của biểu đồ trên, được chồng lên bản đồ bang California (đoạn mã này dùng để tạo hình ảnh minh họa trong sách).

# extra code – tạo hình ảnh đầu tiên trong chương
# Tải hình ảnh bản đồ California
filename = "california.png"
filepath = Path(f"my_{filename}")
if not filepath.is_file():
    homlp_root = "https://github.com/ageron/handson-mlp/raw/main/"
    url = homlp_root + "images/end_to_end_project/" + filename
    print("Downloading", filename)
    urllib.request.urlretrieve(url, filepath)

housing_renamed = housing.rename(columns={
    "latitude": "Latitude", "longitude": "Longitude",
    "population": "Population",
    "median_house_value": "Median house value (ᴜsᴅ)"})

housing_renamed.plot(
             kind="scatter", x="Longitude", y="Latitude",
             s=housing_renamed["Population"] / 100, label="Population",
             c="Median house value (ᴜsᴅ)", cmap="jet", colorbar=True,
             legend=True, sharex=False, figsize=(10, 7))

california_img = plt.imread(filepath)
axis = -124.55, -113.95, 32.45, 42.05
plt.axis(axis)
plt.imshow(california_img, extent=axis)
plt.show()
Downloading california.png

png

4.2. Tìm kiếm Tương quan (Looking for Correlations)

Hệ số tương quan Pearson () đo lường mối quan hệ tuyến tính giữa hai biến, giá trị từ -1 đến 1.

# Tính ma trận tương quan
corr_matrix = housing.corr(numeric_only=True)

Xem sự tương quan của các biến với mục tiêu median_house_value:

corr_matrix["median_house_value"].sort_values(ascending=False)
median_house_value
median_house_value 1.000000
median_income 0.688380
total_rooms 0.137455
housing_median_age 0.102175
households 0.071426
total_bedrooms 0.054635
population -0.020153
longitude -0.050859
latitude -0.139584

Chúng ta cũng có thể dùng scatter_matrix của Pandas để vẽ biểu đồ tương quan giữa các cặp thuộc tính quan trọng.

from pandas.plotting import scatter_matrix

attributes = ["median_house_value", "median_income", "total_rooms",
              "housing_median_age"]
scatter_matrix(housing[attributes], figsize=(12, 8))
plt.show()

png

Mối tương quan mạnh nhất là giữa thu nhập trung vị và giá nhà. Hãy phóng to biểu đồ này:

housing.plot(kind="scatter", x="median_income", y="median_house_value",
             alpha=0.1, grid=True)
plt.show()

png

4.3. Thử nghiệm kết hợp thuộc tính (Attribute Combinations)

Đôi khi các thuộc tính gốc không hữu ích bằng các thuộc tính phái sinh. Ví dụ: tổng số phòng (total_rooms) không có ý nghĩa nhiều nếu ta không biết có bao nhiêu hộ gia đình (households). Ta sẽ tạo ra các thuộc tính mới:

housing["rooms_per_house"] = housing["total_rooms"] / housing["households"]
housing["bedrooms_ratio"] = housing["total_bedrooms"] / housing["total_rooms"]
housing["people_per_house"] = housing["population"] / housing["households"]

Kiểm tra lại ma trận tương quan:

corr_matrix = housing.corr(numeric_only=True)
corr_matrix["median_house_value"].sort_values(ascending=False)
median_house_value
median_house_value 1.000000
median_income 0.688380
rooms_per_house 0.143663
total_rooms 0.137455
housing_median_age 0.102175
households 0.071426
total_bedrooms 0.054635
population -0.020153
people_per_house -0.038224
longitude -0.050859
latitude -0.139584
bedrooms_ratio -0.256397

5. Chuẩn bị Dữ liệu cho Thuật toán ML (Prepare the Data)

Đây là bước quan trọng nhất. Thay vì làm thủ công, chúng ta nên viết các hàm để có thể tái sử dụng và áp dụng cho dữ liệu mới.

Đầu tiên, tách biến mục tiêu (label) ra khỏi biến đầu vào (features).

housing = strat_train_set.drop("median_house_value", axis=1)
housing_labels = strat_train_set["median_house_value"].copy()

5.1. Làm sạch Dữ liệu (Data Cleaning)

Dữ liệu thực tế thường bị thiếu (missing values). Cột total_bedrooms có một số giá trị bị thiếu. Chúng ta có 3 lựa chọn cơ bản:

  1. Bỏ các dòng bị thiếu.
  2. Bỏ toàn bộ cột đó.
  3. Điền giá trị bị thiếu (imputation) bằng 0, trung bình, hoặc trung vị.
# Mô phỏng 3 phương án (không áp dụng trực tiếp lên biến `housing` gốc ngay)
null_rows_idx = housing.isnull().any(axis=1)
housing.loc[null_rows_idx].head()

# Option 1: Xóa dòng
housing_option1 = housing.copy()
housing_option1.dropna(subset=["total_bedrooms"], inplace=True)
housing_option1.loc[null_rows_idx].head()

# Option 2: Xóa cột
housing_option2 = housing.copy()
housing_option2.drop("total_bedrooms", axis=1, inplace=True)
housing_option2.loc[null_rows_idx].head()

# Option 3: Điền giá trị trung vị (Median)
housing_option3 = housing.copy()
median = housing["total_bedrooms"].median()
housing_option3["total_bedrooms"] = housing_option3["total_bedrooms"].fillna(median)
housing_option3.loc[null_rows_idx].head()
       longitude  latitude  housing_median_age  total_rooms  total_bedrooms  \
14452    -120.67     40.50                15.0       5343.0           434.0   
18217    -117.96     34.03                35.0       2093.0           434.0   
11889    -118.05     34.04                33.0       1348.0           434.0   
20325    -118.88     34.17                15.0       4260.0           434.0   
14360    -117.87     33.62                 8.0       1266.0           434.0   

       population  households  median_income ocean_proximity  
14452      2503.0       902.0         3.5962          INLAND  
18217      1755.0       403.0         3.4115       <1H OCEAN  
11889      1098.0       257.0         4.2917       <1H OCEAN  
20325      1701.0       669.0         5.1033       <1H OCEAN  
14360       375.0       183.0         9.8020       <1H OCEAN  

Scikit-Learn cung cấp lớp SimpleImputer để xử lý việc điền dữ liệu thiếu một cách chuyên nghiệp.

from sklearn.impute import SimpleImputer

# Khởi tạo Imputer với chiến lược là điền số trung vị
imputer = SimpleImputer(strategy="median")

Vì trung vị chỉ tính được trên dữ liệu số, ta cần tạo một bản sao dữ liệu chỉ chứa các cột số:

housing_num = housing.select_dtypes(include=[np.number])

Huấn luyện (fit) imputer trên dữ liệu huấn luyện:

imputer.fit(housing_num)
SimpleImputer(strategy='median')

Xem các giá trị trung vị đã tính được:

imputer.statistics_
array([-118.51  ,   34.26  ,   29.    , 2125.    ,  434.    , 1167.    ,
        408.    ,    3.5385])

So sánh với việc tính thủ công:

housing_num.median().values
array([-118.51  ,   34.26  ,   29.    , 2125.    ,  434.    , 1167.    ,
        408.    ,    3.5385])

Sử dụng imputer để biến đổi (transform) dữ liệu, thay thế các giá trị NaN bằng median:

X = imputer.transform(housing_num)

Kiểm tra lại các tên đặc trưng và chuyển đổi kết quả từ NumPy array về DataFrame:

imputer.feature_names_in_

housing_tr = pd.DataFrame(X, columns=housing_num.columns,
                          index=housing_num.index)

# Kiểm tra lại các dòng từng bị thiếu
housing_tr.loc[null_rows_idx].head()
       longitude  latitude  housing_median_age  total_rooms  total_bedrooms  \
14452    -120.67     40.50                15.0       5343.0           434.0   
18217    -117.96     34.03                35.0       2093.0           434.0   
11889    -118.05     34.04                33.0       1348.0           434.0   
20325    -118.88     34.17                15.0       4260.0           434.0   
14360    -117.87     33.62                 8.0       1266.0           434.0   

       population  households  median_income  
14452      2503.0       902.0         3.5962  
18217      1755.0       403.0         3.4115  
11889      1098.0       257.0         4.2917  
20325      1701.0       669.0         5.1033  
14360       375.0       183.0         9.8020  
imputer.strategy
'median'
# Lặp lại để đảm bảo biến housing_tr có sẵn
housing_tr = pd.DataFrame(X, columns=housing_num.columns,
                          index=housing_num.index)
housing_tr.loc[null_rows_idx].head()
       longitude  latitude  housing_median_age  total_rooms  total_bedrooms  \
14452    -120.67     40.50                15.0       5343.0           434.0   
18217    -117.96     34.03                35.0       2093.0           434.0   
11889    -118.05     34.04                33.0       1348.0           434.0   
20325    -118.88     34.17                15.0       4260.0           434.0   
14360    -117.87     33.62                 8.0       1266.0           434.0   

       population  households  median_income  
14452      2503.0       902.0         3.5962  
18217      1755.0       403.0         3.4115  
11889      1098.0       257.0         4.2917  
20325      1701.0       669.0         5.1033  
14360       375.0       183.0         9.8020  
# from sklearn import set_config
# set_config(transform_output="pandas")  # scikit-learn >= 1.2 có thể xuất ra pandas trực tiếp

5.2. Xử lý Ngoại lai (Outliers)

Mặc dù trong chương này chúng ta chưa tập trung sâu, nhưng IsolationForest là một công cụ mạnh mẽ để phát hiện ngoại lai.

from sklearn.ensemble import IsolationForest

isolation_forest = IsolationForest(random_state=42)
outlier_pred = isolation_forest.fit_predict(X)
outlier_pred
array([-1,  1,  1, ...,  1,  1,  1])

Đoạn mã dưới đây (đã được comment) cho thấy cách lọc bỏ ngoại lai nếu muốn:

# housing = housing.iloc[outlier_pred == 1]
# housing_labels = housing_labels.iloc[outlier_pred == 1]

5.3. Xử lý Đặc trưng Văn bản và Phân loại (Categorical Attributes)

Cột ocean_proximity là dữ liệu dạng văn bản. Máy tính cần các con số.

housing_cat = housing[["ocean_proximity"]]
housing_cat.head(8)
      ocean_proximity
13096        NEAR BAY
14973       <1H OCEAN
3785           INLAND
14689          INLAND
20507      NEAR OCEAN
1286           INLAND
18078       <1H OCEAN
4396         NEAR BAY

Ordinal Encoding: Gán mỗi loại một số nguyên (0, 1, 2…). Nhược điểm là mô hình có thể hiểu nhầm thứ tự (ví dụ: loại 0 gần loại 1 hơn loại 4, nhưng thực tế có thể không phải vậy).

from sklearn.preprocessing import OrdinalEncoder

ordinal_encoder = OrdinalEncoder()
housing_cat_encoded = ordinal_encoder.fit_transform(housing_cat)
housing_cat_encoded[:8]
array([[3.],
       [0.],
       [1.],
       [1.],
       [4.],
       [1.],
       [0.],
       [3.]])
ordinal_encoder.categories_
[array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'],
       dtype=object)]

One-Hot Encoding: Tạo ra các cột nhị phân cho từng loại (1 nếu thuộc loại đó, 0 nếu không). Đây là phương pháp phổ biến cho các biến định danh không có thứ tự.

from sklearn.preprocessing import OneHotEncoder

cat_encoder = OneHotEncoder()
housing_cat_1hot = cat_encoder.fit_transform(housing_cat)
housing_cat_1hot
<Compressed Sparse Row sparse matrix of dtype 'float64'
	with 16512 stored elements and shape (16512, 5)>

Kết quả trả về là một ma trận thưa (sparse matrix) để tiết kiệm bộ nhớ. Ta có thể chuyển về dạng mảng đầy đủ (dense array):

housing_cat_1hot.toarray()
array([[0., 0., 0., 1., 0.],
       [1., 0., 0., 0., 0.],
       [0., 1., 0., 0., 0.],
       ...,
       [0., 0., 0., 0., 1.],
       [1., 0., 0., 0., 0.],
       [0., 0., 0., 0., 1.]])

Hoặc thiết lập sparse_output=False ngay từ đầu:

cat_encoder = OneHotEncoder(sparse_output=False)
housing_cat_1hot = cat_encoder.fit_transform(housing_cat)
housing_cat_1hot
array([[0., 0., 0., 1., 0.],
       [1., 0., 0., 0., 0.],
       [0., 1., 0., 0., 0.],
       ...,
       [0., 0., 0., 0., 1.],
       [1., 0., 0., 0., 0.],
       [0., 0., 0., 0., 1.]])
cat_encoder.categories_
[array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'],
       dtype=object)]

Cách xử lý các biến loại mới không có trong tập huấn luyện (Unknown categories):

df_test = pd.DataFrame({"ocean_proximity": ["INLAND", "NEAR BAY"]})
pd.get_dummies(df_test)
   ocean_proximity_INLAND  ocean_proximity_NEAR BAY
0                    True                     False
1                   False                      True
cat_encoder.transform(df_test)
array([[0., 1., 0., 0., 0.],
       [0., 0., 0., 1., 0.]])
df_test_unknown = pd.DataFrame({"ocean_proximity": ["<2H OCEAN", "ISLAND"]})
pd.get_dummies(df_test_unknown)
   ocean_proximity_<2H OCEAN  ocean_proximity_ISLAND
0                       True                   False
1                      False                    True

Nếu gặp loại lạ, ta có thể yêu cầu Encoder bỏ qua (ignore) thay vì báo lỗi:

cat_encoder.handle_unknown = "ignore"
cat_encoder.transform(df_test_unknown)
array([[0., 0., 0., 0., 0.],
       [0., 0., 1., 0., 0.]])
cat_encoder.feature_names_in_
array(['ocean_proximity'], dtype=object)
cat_encoder.get_feature_names_out()
array(['ocean_proximity_<1H OCEAN', 'ocean_proximity_INLAND',
       'ocean_proximity_ISLAND', 'ocean_proximity_NEAR BAY',
       'ocean_proximity_NEAR OCEAN'], dtype=object)
df_output = pd.DataFrame(cat_encoder.transform(df_test_unknown),
                         columns=cat_encoder.get_feature_names_out(),
                         index=df_test_unknown.index)
df_output
   ocean_proximity_<1H OCEAN  ocean_proximity_INLAND  ocean_proximity_ISLAND  \
0                        0.0                     0.0                     0.0   
1                        0.0                     0.0                     1.0   

   ocean_proximity_NEAR BAY  ocean_proximity_NEAR OCEAN  
0                       0.0                         0.0  
1                       0.0                         0.0  

5.4. Co giãn Đặc trưng (Feature Scaling)

Các thuật toán ML thường hoạt động kém nếu các đặc trưng có tỷ lệ khác nhau quá lớn (ví dụ: tuổi từ 0-100, thu nhập từ 0-100.000).

  • Min-Max Scaling (Normalization): Đưa dữ liệu về khoảng [0, 1] hoặc [-1, 1].
  • Standardization: Trừ đi trung bình và chia cho độ lệch chuẩn. Kết quả có trung bình 0 và phương sai 1. Cách này ít bị ảnh hưởng bởi ngoại lai hơn.
from sklearn.preprocessing import MinMaxScaler

min_max_scaler = MinMaxScaler(feature_range=(-1, 1))
housing_num_min_max_scaled = min_max_scaler.fit_transform(housing_num)
from sklearn.preprocessing import StandardScaler

std_scaler = StandardScaler()
housing_num_std_scaled = std_scaler.fit_transform(housing_num)

Khi dữ liệu bị lệch (như population), việc lấy logarit có thể giúp phân phối trở nên chuẩn hơn (hình chuông):

# extra code – tạo hình Figure 2–17
fig, axs = plt.subplots(1, 2, figsize=(8, 3), sharey=True)
housing["population"].hist(ax=axs[0], bins=50)
housing["population"].apply(np.log).hist(ax=axs[1], bins=50)
axs[0].set_xlabel("Population")
axs[1].set_xlabel("Log of population")
axs[0].set_ylabel("Number of districts")
plt.show()

png

Một kỹ thuật khác là thay thế giá trị bằng phân vị (percentile) của nó:

# extra code – minh họa phân phối đồng nhất (uniform) sau khi đổi sang phân vị
percentiles = [np.percentile(housing["median_income"], p)
               for p in range(1, 100)]
flattened_median_income = pd.cut(housing["median_income"],
                                 bins=[-np.inf] + percentiles + [np.inf],
                                 labels=range(1, 100 + 1))
flattened_median_income.hist(bins=50)
plt.xlabel("Median income percentile")
plt.ylabel("Number of districts")
plt.show()

png

Sử dụng hàm Gaussian RBF (Radial Basis Function) để đo độ tương đồng:

from sklearn.metrics.pairwise import rbf_kernel

age_simil_35 = rbf_kernel(housing[["housing_median_age"]], [[35]], gamma=0.1)

Đoạn mã dưới đây minh họa tác động của việc dùng RBF kernel:

# extra code – tạo Hình 2–18
ages = np.linspace(housing["housing_median_age"].min(),
                   housing["housing_median_age"].max(),
                   500).reshape(-1, 1)
gamma1 = 0.1
gamma2 = 0.03
rbf1 = rbf_kernel(ages, [[35]], gamma=gamma1)
rbf2 = rbf_kernel(ages, [[35]], gamma=gamma2)

fig, ax1 = plt.subplots()
ax1.set_xlabel("Housing median age")
ax1.set_ylabel("Number of districts")
ax1.hist(housing["housing_median_age"], bins=50)

ax2 = ax1.twinx()  # tạo trục y thứ hai dùng chung trục x
color = "blue"
ax2.plot(ages, rbf1, color=color, label="gamma = 0.10")
ax2.plot(ages, rbf2, color=color, label="gamma = 0.03", linestyle="--")
ax2.tick_params(axis='y', labelcolor=color)
ax2.set_ylabel("Age similarity", color=color)

plt.legend(loc="upper left")
plt.show()

png

Đôi khi ta cần co giãn cả biến mục tiêu (target values), ví dụ dùng StandardScaler cho biến y, sau đó dùng TransformedTargetRegressor để tự động biến đổi ngược lại khi dự đoán.

from sklearn.linear_model import LinearRegression

target_scaler = StandardScaler()
scaled_labels = target_scaler.fit_transform(housing_labels.to_frame())

model = LinearRegression()
model.fit(housing[["median_income"]], scaled_labels)
some_new_data = housing[["median_income"]].iloc[:5]
scaled_predictions = model.predict(some_new_data)
predictions = target_scaler.inverse_transform(scaled_predictions)
predictions
array([[131997.15275877],
       [299359.35844434],
       [146023.37185694],
       [138840.33653057],
       [192016.61557639]])
from sklearn.compose import TransformedTargetRegressor

model = TransformedTargetRegressor(LinearRegression(),
                                   transformer=StandardScaler())
model.fit(housing[["median_income"]], housing_labels)
predictions = model.predict(some_new_data)
predictions
array([131997.15275877, 299359.35844434, 146023.37185694, 138840.33653057,
       192016.61557639])

5.5. Bộ biến đổi tùy chỉnh (Custom Transformers)

Để tích hợp các bước xử lý riêng vào Pipeline của Scikit-Learn, ta có thể viết các Transformer tùy chỉnh.

Sử dụng FunctionTransformer cho các hàm đơn giản (như log):

from sklearn.preprocessing import FunctionTransformer

log_transformer = FunctionTransformer(np.log, inverse_func=np.exp)
log_pop = log_transformer.transform(housing[["population"]])
rbf_transformer = FunctionTransformer(rbf_kernel,
                                      kw_args=dict(Y=[[35.]], gamma=0.1))
age_simil_35 = rbf_transformer.transform(housing[["housing_median_age"]])
age_simil_35
array([[2.81118530e-13],
       [8.20849986e-02],
       [6.70320046e-01],
       ...,
       [9.55316054e-22],
       [6.70320046e-01],
       [3.03539138e-04]])
sf_coords = 37.7749, -122.41
sf_transformer = FunctionTransformer(rbf_kernel,
                                     kw_args=dict(Y=[sf_coords], gamma=0.1))
sf_simil = sf_transformer.transform(housing[["latitude", "longitude"]])
sf_simil
array([[0.999927  ],
       [0.05258419],
       [0.94864161],
       ...,
       [0.00388525],
       [0.05038518],
       [0.99868067]])
ratio_transformer = FunctionTransformer(lambda X: X[:, [0]] / X[:, [1]])
ratio_transformer.transform(np.array([[1., 2.], [3., 4.]]))
array([[0.5 ],
       [0.75]])

Nếu cần các Transformer phức tạp có khả năng “học” (có hàm fit), ta kế thừa từ BaseEstimatorTransformerMixin. Ví dụ dưới đây tái tạo lại StandardScaler:

from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted

class StandardScalerClone(BaseEstimator, TransformerMixin):
    def __init__(self, with_mean=True):  # không dùng *args hay **kwargs!
        self.with_mean = with_mean

    def fit(self, X, y=None):  # y là bắt buộc dù không dùng
        X = check_array(X)  # kiểm tra X là mảng hợp lệ
        self.mean_ = X.mean(axis=0)
        self.scale_ = X.std(axis=0)
        self.n_features_in_ = X.shape[1]
        return self  # luôn trả về self!

    def transform(self, X):
        check_is_fitted(self)  # kiểm tra xem đã fit chưa
        X = check_array(X)
        assert self.n_features_in_ == X.shape[1]
        if self.with_mean:
            X = X - self.mean_
        return X / self.scale_

Một ví dụ khác: ClusterSimilarity dùng K-Means để tạo đặc trưng mới dựa trên khoảng cách đến các tâm cụm (cluster centers).

from sklearn.cluster import KMeans

class ClusterSimilarity(BaseEstimator, TransformerMixin):
    def __init__(self, n_clusters=10, gamma=1.0, random_state=None):
        self.n_clusters = n_clusters
        self.gamma = gamma
        self.random_state = random_state

    def fit(self, X, y=None, sample_weight=None):
        self.kmeans_ = KMeans(self.n_clusters, random_state=self.random_state)
        self.kmeans_.fit(X, sample_weight=sample_weight)
        return self

    def transform(self, X):
        return rbf_kernel(X, self.kmeans_.cluster_centers_, gamma=self.gamma)

    def get_feature_names_out(self, names=None):
        return [f"Cluster {i} similarity" for i in range(self.n_clusters)]

Áp dụng ClusterSimilarity cho tọa độ địa lý:

cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42)
similarities = cluster_simil.fit_transform(housing[["latitude", "longitude"]])
similarities[:3].round(2)
array([[0.46, 0.  , 0.08, 0.  , 0.  , 0.  , 0.  , 0.98, 0.  , 0.  ],
       [0.  , 0.96, 0.  , 0.03, 0.04, 0.  , 0.  , 0.  , 0.11, 0.35],
       [0.34, 0.  , 0.45, 0.  , 0.  , 0.  , 0.01, 0.73, 0.  , 0.  ]])

Trực quan hóa các cụm tìm được trên bản đồ:

# extra code – tạo Hình 2–19
housing_renamed = housing.rename(columns={
    "latitude": "Latitude", "longitude": "Longitude",
    "population": "Population",
    "median_house_value": "Median house value (ᴜsᴅ)"})
housing_renamed["Max cluster similarity"] = similarities.max(axis=1)

housing_renamed.plot(kind="scatter", x="Longitude", y="Latitude", grid=True,
                     s=housing_renamed["Population"] / 100, label="Population",
                     c="Max cluster similarity",
                     cmap="jet", colorbar=True,
                     legend=True, sharex=False, figsize=(10, 7))
plt.plot(cluster_simil.kmeans_.cluster_centers_[:, 1],
         cluster_simil.kmeans_.cluster_centers_[:, 0],
         linestyle="", color="black", marker="X", markersize=20,
         label="Cluster centers")
plt.legend(loc="upper right")
plt.show()

png

5.6. Pipeline Chuyển đổi (Transformation Pipelines)

Pipeline giúp xâu chuỗi các bước xử lý dữ liệu để đảm bảo quy trình nhất quán và tránh rò rỉ dữ liệu.

from sklearn.pipeline import Pipeline

num_pipeline = Pipeline([
    ("impute", SimpleImputer(strategy="median")),
    ("standardize", StandardScaler()),
])

Dùng make_pipeline thì không cần đặt tên thủ công cho các bước:

from sklearn.pipeline import make_pipeline

num_pipeline = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())

Hiển thị sơ đồ Pipeline:

from sklearn import set_config

set_config(display='diagram')
num_pipeline
Pipeline(steps=[('simpleimputer', SimpleImputer(strategy='median')),
                ('standardscaler', StandardScaler())])

Thử nghiệm pipeline trên dữ liệu số:

housing_num_prepared = num_pipeline.fit_transform(housing_num)
housing_num_prepared[:2].round(2)
array([[-1.42,  1.01,  1.86,  0.31,  1.37,  0.14,  1.39, -0.94],
       [ 0.6 , -0.7 ,  0.91, -0.31, -0.44, -0.69, -0.37,  1.17]])

Lấy lại DataFrame từ kết quả:

df_housing_num_prepared = pd.DataFrame(
    housing_num_prepared, columns=num_pipeline.get_feature_names_out(),
    index=housing_num.index)
df_housing_num_prepared.head(2)
       longitude  latitude  housing_median_age  total_rooms  total_bedrooms  \
13096  -1.423037  1.013606            1.861119     0.311912        1.368167   
14973   0.596394 -0.702103            0.907630    -0.308620       -0.435925   

       population  households  median_income  
13096    0.137460    1.394812      -0.936491  
14973   -0.693771   -0.373485       1.171942  

Truy cập các bước trong Pipeline:

num_pipeline.steps
num_pipeline[1]
num_pipeline[:-1]
num_pipeline.named_steps["simpleimputer"]
num_pipeline.set_params(simpleimputer__strategy="median")
Pipeline(steps=[('simpleimputer', SimpleImputer(strategy='median')),
                ('standardscaler', StandardScaler())])

ColumnTransformer cho phép áp dụng các pipeline khác nhau cho các cột khác nhau (số vs phân loại).

from sklearn.compose import ColumnTransformer

num_attribs = ["longitude", "latitude", "housing_median_age", "total_rooms",
               "total_bedrooms", "population", "households", "median_income"]
cat_attribs = ["ocean_proximity"]

cat_pipeline = make_pipeline(
    SimpleImputer(strategy="most_frequent"),
    OneHotEncoder(handle_unknown="ignore"))

preprocessing = ColumnTransformer([
    ("num", num_pipeline, num_attribs),
    ("cat", cat_pipeline, cat_attribs),
])

Dùng make_column_selector để tự động chọn cột theo kiểu dữ liệu:

from sklearn.compose import make_column_selector, make_column_transformer

preprocessing = make_column_transformer(
    (num_pipeline, make_column_selector(dtype_include=np.number)),
    (cat_pipeline, make_column_selector(dtype_include=object)),
)

Áp dụng toàn bộ quy trình tiền xử lý lên dữ liệu:

housing_prepared = preprocessing.fit_transform(housing)

# extra code – chuyển về DataFrame
housing_prepared_fr = pd.DataFrame(
    housing_prepared,
    columns=preprocessing.get_feature_names_out(),
    index=housing.index)
housing_prepared_fr.head(2)
       pipeline-1__longitude  pipeline-1__latitude  \
13096              -1.423037              1.013606   
14973               0.596394             -0.702103   

       pipeline-1__housing_median_age  pipeline-1__total_rooms  \
13096                        1.861119                 0.311912   
14973                        0.907630                -0.308620   

       pipeline-1__total_bedrooms  pipeline-1__population  \
13096                    1.368167                0.137460   
14973                   -0.435925               -0.693771   

       pipeline-1__households  pipeline-1__median_income  \
13096                1.394812                  -0.936491   
14973               -0.373485                   1.171942   

       pipeline-2__ocean_proximity_<1H OCEAN  \
13096                                    0.0   
14973                                    1.0   

       pipeline-2__ocean_proximity_INLAND  pipeline-2__ocean_proximity_ISLAND  \
13096                                 0.0                                 0.0   
14973                                 0.0                                 0.0   

       pipeline-2__ocean_proximity_NEAR BAY  \
13096                                   1.0   
14973                                   0.0   

       pipeline-2__ocean_proximity_NEAR OCEAN  
13096                                     0.0  
14973                                     0.0  

Bây giờ chúng ta sẽ lắp ráp một ColumnTransformer hoàn chỉnh bao gồm cả việc tạo đặc trưng mới (tỷ lệ phòng, log biến đổi, cụm địa lý…).

def column_ratio(X):
    return X[:, [0]] / X[:, [1]]

def ratio_name(function_transformer, feature_names_in):
    return ["ratio"]

def ratio_pipeline():
    return make_pipeline(
        SimpleImputer(strategy="median"),
        FunctionTransformer(column_ratio, feature_names_out=ratio_name),
        StandardScaler())

log_pipeline = make_pipeline(
    SimpleImputer(strategy="median"),
    FunctionTransformer(np.log, feature_names_out="one-to-one"),
    StandardScaler())

cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42)
default_num_pipeline = make_pipeline(SimpleImputer(strategy="median"),
                                     StandardScaler())

preprocessing = ColumnTransformer([
        ("bedrooms", ratio_pipeline(), ["total_bedrooms", "total_rooms"]),
        ("rooms_per_house", ratio_pipeline(), ["total_rooms", "households"]),
        ("people_per_house", ratio_pipeline(), ["population", "households"]),
        ("log", log_pipeline, ["total_bedrooms", "total_rooms", "population",
                               "households", "median_income"]),
        ("geo", cluster_simil, ["latitude", "longitude"]),
        ("cat", cat_pipeline, make_column_selector(dtype_include=object)),
    ],
    remainder=default_num_pipeline)  # cột còn lại: housing_median_age

housing_prepared = preprocessing.fit_transform(housing)
housing_prepared.shape
(16512, 24)
preprocessing.get_feature_names_out()
array(['bedrooms__ratio', 'rooms_per_house__ratio',
       'people_per_house__ratio', 'log__total_bedrooms',
       'log__total_rooms', 'log__population', 'log__households',
       'log__median_income', 'geo__Cluster 0 similarity',
       'geo__Cluster 1 similarity', 'geo__Cluster 2 similarity',
       'geo__Cluster 3 similarity', 'geo__Cluster 4 similarity',
       'geo__Cluster 5 similarity', 'geo__Cluster 6 similarity',
       'geo__Cluster 7 similarity', 'geo__Cluster 8 similarity',
       'geo__Cluster 9 similarity', 'cat__ocean_proximity_<1H OCEAN',
       'cat__ocean_proximity_INLAND', 'cat__ocean_proximity_ISLAND',
       'cat__ocean_proximity_NEAR BAY', 'cat__ocean_proximity_NEAR OCEAN',
       'remainder__housing_median_age'], dtype=object)

6. Lựa chọn và Huấn luyện Mô hình (Select and Train a Model)

6.1. Huấn luyện và Đánh giá trên Tập Huấn luyện

Bắt đầu với mô hình đơn giản nhất: Hồi quy Tuyến tính (Linear Regression).

from sklearn.linear_model import LinearRegression

lin_reg = make_pipeline(preprocessing, LinearRegression())
lin_reg.fit(housing, housing_labels)
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                              SimpleImputer(strategy='median')),
                                                             ('standardscaler',
                                                              StandardScaler())]),
                                   transformers=[('bedrooms',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('functiontransformer',
                                                                   FunctionTransformer(feature_names_out=<function ratio_name at 0x7e2...
                                                   'median_income']),
                                                 ('geo',
                                                  ClusterSimilarity(random_state=42),
                                                  ['latitude', 'longitude']),
                                                 ('cat',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='most_frequent')),
                                                                  ('onehotencoder',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b87d49940>)])),
                ('linearregression', LinearRegression())])

Thử dự đoán trên một vài mẫu:

housing_predictions = lin_reg.predict(housing)
housing_predictions[:5].round(-2)
array([246000., 372700., 135700.,  91400., 330900.])

So sánh với nhãn thực tế:

housing_labels.iloc[:5].values
array([458300., 483800., 101700.,  96100., 361800.])

Tính tỷ lệ lỗi:

# extra code – tính tỷ lệ lỗi
error_ratios = housing_predictions[:5].round(-2) / housing_labels.iloc[:5].values - 1
print(", ".join([f"{100 * ratio:.1f}%" for ratio in error_ratios]))
-46.3%, -23.0%, 33.4%, -4.9%, -8.5%

Đo lường sai số bằng RMSE (Root Mean Squared Error):

from sklearn.metrics import root_mean_squared_error

lin_rmse = root_mean_squared_error(housing_labels, housing_predictions)
lin_rmse
68972.88910758484

Kết quả RMSE khá lớn (68972 USD). Có vẻ mô hình đang bị Underfitting (kém khớp). Thử một mô hình phức tạp hơn: Cây quyết định (Decision Tree).

from sklearn.tree import DecisionTreeRegressor

tree_reg = make_pipeline(preprocessing, DecisionTreeRegressor(random_state=42))
tree_reg.fit(housing, housing_labels)
Pipeline(steps=[('columntransformer',
                 ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                              SimpleImputer(strategy='median')),
                                                             ('standardscaler',
                                                              StandardScaler())]),
                                   transformers=[('bedrooms',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('functiontransformer',
                                                                   FunctionTransformer(feature_names_out=<function ratio_name at 0x7e2...
                                                  ClusterSimilarity(random_state=42),
                                                  ['latitude', 'longitude']),
                                                 ('cat',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='most_frequent')),
                                                                  ('onehotencoder',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b87d49940>)])),
                ('decisiontreeregressor',
                 DecisionTreeRegressor(random_state=42))])
housing_predictions = tree_reg.predict(housing)
tree_rmse = root_mean_squared_error(housing_labels, housing_predictions)
tree_rmse
0.0

RMSE = 0.0? Điều này cực kỳ khả nghi. Có thể mô hình đã bị Overfitting (quá khớp) dữ liệu huấn luyện.

6.2. Đánh giá tốt hơn bằng Cross-Validation

Thay vì dùng tập test (điều cấm kỵ), ta dùng K-fold Cross-Validation: chia tập train thành 10 phần, huấn luyện trên 9 phần và kiểm tra trên 1 phần, lặp lại 10 lần.

from sklearn.model_selection import cross_val_score

tree_rmses = -cross_val_score(tree_reg, housing, housing_labels,
                              scoring="neg_root_mean_squared_error", cv=10)
pd.Series(tree_rmses).describe()
0
count 10.000000
mean 66573.734600
std 1103.402323
min 64607.896046
25% 66204.731788
50% 66388.272499
75% 66826.257468
max 68532.210664

Với Cross-Validation, Decision Tree có RMSE trung bình khoảng 66,573 USD, không tốt hơn Linear Regression là bao.

Kiểm tra Cross-Validation cho Linear Regression:

# extra code
lin_rmses = -cross_val_score(lin_reg, housing, housing_labels,
                              scoring="neg_root_mean_squared_error", cv=10)
pd.Series(lin_rmses).describe()
0
count 10.000000
mean 70003.404818
std 4182.188328
min 65504.765753
25% 68172.065831
50% 68743.995249
75% 70344.943988
max 81037.863741

Thử nghiệm với Random Forest Regressor (Rừng ngẫu nhiên), một mô hình mạnh mẽ hơn hoạt động bằng cách kết hợp nhiều cây quyết định.

Cảnh báo: Đoạn mã sau có thể chạy mất nhiều phút.

from sklearn.ensemble import RandomForestRegressor

forest_reg = make_pipeline(preprocessing,
                           RandomForestRegressor(random_state=42))
forest_rmses = -cross_val_score(forest_reg, housing, housing_labels,
                                scoring="neg_root_mean_squared_error", cv=10)
pd.Series(forest_rmses).describe()
0
count 10.000000
mean 47038.092799
std 1021.491757
min 45495.976649
25% 46510.418013
50% 47118.719249
75% 47480.519175
max 49140.832210

Random Forest cho kết quả tốt hơn hẳn (RMSE ~47,038). Tuy nhiên, hãy so sánh với lỗi trên tập huấn luyện:

forest_reg.fit(housing, housing_labels)
housing_predictions = forest_reg.predict(housing)
forest_rmse = root_mean_squared_error(housing_labels, housing_predictions)
forest_rmse
17551.2122500877

Lỗi trên tập train (17,551) thấp hơn nhiều so với lỗi validation (47,038), chứng tỏ mô hình vẫn đang bị Overfitting.

7. Tinh chỉnh Mô hình (Fine-Tune Your Model)

Thay vì thử thủ công từng tham số, ta dùng GridSearchCV để Scikit-Learn tự động thử tất cả các tổ hợp tham số mà ta định nghĩa.

Cảnh báo: Đoạn mã sau có thể chạy mất nhiều phút.

from sklearn.model_selection import GridSearchCV

full_pipeline = Pipeline([
    ("preprocessing", preprocessing),
    ("random_forest", RandomForestRegressor(random_state=42)),
])

param_grid = [
    {'preprocessing__geo__n_clusters': [5, 8, 10],
     'random_forest__max_features': [4, 6, 8]},
    {'preprocessing__geo__n_clusters': [10, 15],
     'random_forest__max_features': [6, 8, 10]},
]

grid_search = GridSearchCV(full_pipeline, param_grid, cv=3,
                           scoring='neg_root_mean_squared_error')
grid_search.fit(housing, housing_labels)
GridSearchCV(cv=3,
             estimator=Pipeline(steps=[('preprocessing',
                                        ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                                                     SimpleImputer(strategy='median')),
                                                                                    ('standardscaler',
                                                                                     StandardScaler())]),
                                                          transformers=[('bedrooms',
                                                                         Pipeline(steps=[('simpleimputer',
                                                                                          SimpleImputer(strategy='median')),
                                                                                         ('functiontransformer',
                                                                                          FunctionTransformer(feature_names_out=<f...
                                                                         <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b87d49940>)])),
                                       ('random_forest',
                                        RandomForestRegressor(random_state=42))]),
             param_grid=[{'preprocessing__geo__n_clusters': [5, 8, 10],
                          'random_forest__max_features': [4, 6, 8]},
                         {'preprocessing__geo__n_clusters': [10, 15],
                          'random_forest__max_features': [6, 8, 10]}],
             scoring='neg_root_mean_squared_error')

Bạn có thể xem tên các tham số có thể tinh chỉnh bằng lệnh sau:

# extra code – hiển thị một phần output của get_params().keys()
print(str(full_pipeline.get_params().keys())[:1000] + "...")
dict_keys(['memory', 'steps', 'transform_input', 'verbose', 'preprocessing', 'random_forest', 'preprocessing__force_int_remainder_cols', 'preprocessing__n_jobs', 'preprocessing__remainder__memory', 'preprocessing__remainder__steps', 'preprocessing__remainder__transform_input', 'preprocessing__remainder__verbose', 'preprocessing__remainder__simpleimputer', 'preprocessing__remainder__standardscaler', 'preprocessing__remainder__simpleimputer__add_indicator', 'preprocessing__remainder__simpleimputer__copy', 'preprocessing__remainder__simpleimputer__fill_value', 'preprocessing__remainder__simpleimputer__keep_empty_features', 'preprocessing__remainder__simpleimputer__missing_values', 'preprocessing__remainder__simpleimputer__strategy', 'preprocessing__remainder__standardscaler__copy', 'preprocessing__remainder__standardscaler__with_mean', 'preprocessing__remainder__standardscaler__with_std', 'preprocessing__remainder', 'preprocessing__sparse_threshold', 'preprocessing__transformer_weights', ...

Các tham số tốt nhất tìm được:

grid_search.best_params_
{'preprocessing__geo__n_clusters': 15, 'random_forest__max_features': 6}

Mô hình tốt nhất:

grid_search.best_estimator_
Pipeline(steps=[('preprocessing',
                 ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                              SimpleImputer(strategy='median')),
                                                             ('standardscaler',
                                                              StandardScaler())]),
                                   transformers=[('bedrooms',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='median')),
                                                                  ('functiontransformer',
                                                                   FunctionTransformer(feature_names_out=<function ratio_name at 0x7e2b87d...
                                                  ClusterSimilarity(n_clusters=15,
                                                                    random_state=42),
                                                  ['latitude', 'longitude']),
                                                 ('cat',
                                                  Pipeline(steps=[('simpleimputer',
                                                                   SimpleImputer(strategy='most_frequent')),
                                                                  ('onehotencoder',
                                                                   OneHotEncoder(handle_unknown='ignore'))]),
                                                  <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b8a422270>)])),
                ('random_forest',
                 RandomForestRegressor(max_features=6, random_state=42))])

Xem kết quả chi tiết của từng lần thử:

cv_res = pd.DataFrame(grid_search.cv_results_)
cv_res.sort_values(by="mean_test_score", ascending=False, inplace=True)

# extra code – làm đẹp bảng kết quả
cv_res = cv_res[["param_preprocessing__geo__n_clusters",
                 "param_random_forest__max_features", "split0_test_score",
                 "split1_test_score", "split2_test_score", "mean_test_score"]]
score_cols = ["split0", "split1", "split2", "mean_test_rmse"]
cv_res.columns = ["n_clusters", "max_features"] + score_cols
cv_res[score_cols] = -cv_res[score_cols].round().astype(np.int64)
cv_res.head()
    n_clusters  max_features  split0  split1  split2  mean_test_rmse
12          15             6   42725   43708   44335           43590
13          15             8   43486   43820   44900           44069
6           10             4   43798   44036   44961           44265
9           10             6   43710   44163   44967           44280
7           10             6   43710   44163   44967           44280

Khi không gian tham số quá lớn, GridSearchCV tốn quá nhiều thời gian. RandomizedSearchCV chọn ngẫu nhiên các tổ hợp tham số, giúp khám phá không gian rộng hơn hiệu quả hơn.

from sklearn.experimental import enable_halving_search_cv
from sklearn.model_selection import HalvingRandomSearchCV

Cảnh báo: Đoạn mã sau có thể chạy mất nhiều phút.

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import randint

param_distribs = {'preprocessing__geo__n_clusters': randint(low=3, high=50),
                  'random_forest__max_features': randint(low=2, high=20)}

rnd_search = RandomizedSearchCV(
    full_pipeline, param_distributions=param_distribs, n_iter=10, cv=3,
    scoring='neg_root_mean_squared_error', random_state=42)

rnd_search.fit(housing, housing_labels)
RandomizedSearchCV(cv=3,
                   estimator=Pipeline(steps=[('preprocessing',
                                              ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                                                           SimpleImputer(strategy='median')),
                                                                                          ('standardscaler',
                                                                                           StandardScaler())]),
                                                                transformers=[('bedrooms',
                                                                               Pipeline(steps=[('simpleimputer',
                                                                                                SimpleImputer(strategy='median')),
                                                                                               ('functiontransformer',
                                                                                                FunctionTransformer(feature_names_...
                                             ('random_forest',
                                              RandomForestRegressor(random_state=42))]),
                   param_distributions={'preprocessing__geo__n_clusters': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x7e2b87d4ac60>,
                                        'random_forest__max_features': <scipy.stats._distn_infrastructure.rv_discrete_frozen object at 0x7e2b8b770ce0>},
                   random_state=42, scoring='neg_root_mean_squared_error')

Xem kết quả tìm kiếm ngẫu nhiên:

# extra code – hiển thị kết quả random search
cv_res = pd.DataFrame(rnd_search.cv_results_)
cv_res.sort_values(by="mean_test_score", ascending=False, inplace=True)
cv_res = cv_res[["param_preprocessing__geo__n_clusters",
                 "param_random_forest__max_features", "split0_test_score",
                 "split1_test_score", "split2_test_score", "mean_test_score"]]
cv_res.columns = ["n_clusters", "max_features"] + score_cols
cv_res[score_cols] = -cv_res[score_cols].round().astype(np.int64)
cv_res.head()
   n_clusters  max_features  split0  split1  split2  mean_test_rmse
1          45             9   41342   42242   43057           42214
8          32             7   41825   42275   43241           42447
0          41            16   42238   42938   43354           42843
5          42             4   41869   43362   43664           42965
2          23             8   42490   42928   43718           43046

Mẹo chọn phân phối xác suất cho tham số: Sử dụng các hàm từ scipy.stats để định nghĩa cách chọn tham số (ví dụ: loguniform cho các tham số thay đổi theo cấp số nhân).

# extra code – vẽ biểu đồ minh họa các phân phối xác suất
from scipy.stats import randint, uniform, geom, expon

xs1 = np.arange(0, 7 + 1)
randint_distrib = randint(0, 7 + 1).pmf(xs1)

xs2 = np.linspace(0, 7, 500)
uniform_distrib = uniform(0, 7).pdf(xs2)

xs3 = np.arange(0, 7 + 1)
geom_distrib = geom(0.5).pmf(xs3)

xs4 = np.linspace(0, 7, 500)
expon_distrib = expon(scale=1).pdf(xs4)

plt.figure(figsize=(12, 7))
plt.subplot(2, 2, 1)
plt.bar(xs1, randint_distrib, label="scipy.randint(0, 7 + 1)")
plt.ylabel("Probability")
plt.legend()
plt.axis([-1, 8, 0, 0.2])

plt.subplot(2, 2, 2)
plt.fill_between(xs2, uniform_distrib, label="scipy.uniform(0, 7)")
plt.ylabel("PDF")
plt.legend()
plt.axis([-1, 8, 0, 0.2])

plt.subplot(2, 2, 3)
plt.bar(xs3, geom_distrib, label="scipy.geom(0.5)")
plt.xlabel("Hyperparameter value")
plt.ylabel("Probability")
plt.legend()
plt.axis([0, 7, 0, 1])

plt.subplot(2, 2, 4)
plt.fill_between(xs4, expon_distrib, label="scipy.expon(scale=1)")
plt.xlabel("Hyperparameter value")
plt.ylabel("PDF")
plt.legend()
plt.axis([0, 7, 0, 1])

plt.show()

png

Sự khác biệt giữa exponloguniform:

# extra code – so sánh expon và loguniform
from scipy.stats import loguniform

xs1 = np.linspace(0, 7, 500)
expon_distrib = expon(scale=1).pdf(xs1)

log_xs2 = np.linspace(-5, 3, 500)
log_expon_distrib = np.exp(log_xs2 - np.exp(log_xs2))

xs3 = np.linspace(0.001, 1000, 500)
loguniform_distrib = loguniform(0.001, 1000).pdf(xs3)

log_xs4 = np.linspace(np.log(0.001), np.log(1000), 500)
log_loguniform_distrib = uniform(np.log(0.001), np.log(1000)).pdf(log_xs4)

plt.figure(figsize=(12, 7))
plt.subplot(2, 2, 1)
plt.fill_between(xs1, expon_distrib,
                 label="scipy.expon(scale=1)")
plt.ylabel("PDF")
plt.legend()
plt.axis([0, 7, 0, 1])

plt.subplot(2, 2, 2)
plt.fill_between(log_xs2, log_expon_distrib,
                 label="log(X) with X ~ expon")
plt.legend()
plt.axis([-5, 3, 0, 1])

plt.subplot(2, 2, 3)
plt.fill_between(xs3, loguniform_distrib,
                 label="scipy.loguniform(0.001, 1000)")
plt.xlabel("Hyperparameter value")
plt.ylabel("PDF")
plt.legend()
plt.axis([0.001, 1000, 0, 0.005])

plt.subplot(2, 2, 4)
plt.fill_between(log_xs4, log_loguniform_distrib,
                 label="log(X) with X ~ loguniform")
plt.xlabel("Log of hyperparameter value")
plt.legend()
plt.axis([-8, 1, 0, 0.2])
plt.show()

png

7.3. Phân tích Mô hình Tốt nhất

Chúng ta có thể xem xét tầm quan trọng của các đặc trưng (feature importance) để hiểu mô hình đang dựa vào đâu để dự đoán.

final_model = rnd_search.best_estimator_  # bao gồm cả preprocessing
feature_importances = final_model["random_forest"].feature_importances_
feature_importances.round(2)
array([0.07, 0.05, 0.05, 0.01, 0.01, 0.01, 0.01, 0.19, 0.01, 0.02, 0.01,
       0.01, 0.01, 0.  , 0.01, 0.02, 0.01, 0.02, 0.01, 0.  , 0.01, 0.02,
       0.01, 0.01, 0.01, 0.  , 0.02, 0.01, 0.01, 0.  , 0.01, 0.01, 0.01,
       0.03, 0.01, 0.01, 0.01, 0.01, 0.04, 0.01, 0.02, 0.01, 0.02, 0.01,
       0.02, 0.02, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.  , 0.07,
       0.  , 0.  , 0.  , 0.01])

Sắp xếp độ quan trọng giảm dần kèm tên đặc trưng:

sorted(zip(feature_importances,
           final_model["preprocessing"].get_feature_names_out()),
       reverse=True)
[(np.float64(0.18599734460509476), 'log__median_income'),
 (np.float64(0.07338850855844489), 'cat__ocean_proximity_INLAND'),
 (np.float64(0.06556941990883976), 'bedrooms__ratio'),
 (np.float64(0.053648710076725316), 'rooms_per_house__ratio'),
 (np.float64(0.04598870861894749), 'people_per_house__ratio'),
 (np.float64(0.04175269214442519), 'geo__Cluster 30 similarity'),
 (np.float64(0.025976797232869678), 'geo__Cluster 25 similarity'),
 (np.float64(0.023595895886342255), 'geo__Cluster 36 similarity'),
 (np.float64(0.02021056221732893), 'geo__Cluster 9 similarity'),
 (np.float64(0.01860691707666145), 'geo__Cluster 34 similarity'),
 (np.float64(0.018137988374628867), 'geo__Cluster 37 similarity'),
 (np.float64(0.01740435316632675), 'geo__Cluster 18 similarity'),
 (np.float64(0.016778386143844894), 'geo__Cluster 1 similarity'),
 (np.float64(0.015459009666188978), 'geo__Cluster 7 similarity'),
 (np.float64(0.015325731028175924), 'geo__Cluster 32 similarity'),
 (np.float64(0.015073772015038348), 'geo__Cluster 13 similarity'),
 (np.float64(0.014272160962173805), 'geo__Cluster 35 similarity'),
 (np.float64(0.014180636461860479), 'geo__Cluster 0 similarity'),
 (np.float64(0.013746364498238989), 'geo__Cluster 3 similarity'),
 (np.float64(0.01357230570846952), 'geo__Cluster 28 similarity'),
 (np.float64(0.01294034969422872), 'geo__Cluster 26 similarity'),
 (np.float64(0.012738123746761944), 'geo__Cluster 31 similarity'),
 (np.float64(0.011654237215152624), 'geo__Cluster 19 similarity'),
 (np.float64(0.011628003598059723), 'geo__Cluster 6 similarity'),
 (np.float64(0.011134113333125398), 'geo__Cluster 24 similarity'),
 (np.float64(0.011042979326385049), 'remainder__housing_median_age'),
 (np.float64(0.010907388443940418), 'geo__Cluster 43 similarity'),
 (np.float64(0.010847192663592166), 'geo__Cluster 44 similarity'),
 (np.float64(0.010592244492858267), 'geo__Cluster 10 similarity'),
 (np.float64(0.010512467290844922), 'geo__Cluster 23 similarity'),
 (np.float64(0.01045866561538645), 'geo__Cluster 41 similarity'),
 (np.float64(0.010261910692851673), 'geo__Cluster 40 similarity'),
 (np.float64(0.009757306983097491), 'geo__Cluster 2 similarity'),
 (np.float64(0.00965993322211448), 'geo__Cluster 12 similarity'),
 (np.float64(0.009574969190852869), 'geo__Cluster 14 similarity'),
 (np.float64(0.008199144719918425), 'geo__Cluster 20 similarity'),
 (np.float64(0.008141941480860806), 'geo__Cluster 33 similarity'),
 (np.float64(0.007596761219964691), 'geo__Cluster 8 similarity'),
 (np.float64(0.0075762980128490295), 'geo__Cluster 22 similarity'),
 (np.float64(0.007346290789504319), 'geo__Cluster 39 similarity'),
 (np.float64(0.006898774333063982), 'geo__Cluster 4 similarity'),
 (np.float64(0.0067947318450798395), 'log__total_rooms'),
 (np.float64(0.006514889773323568), 'log__population'),
 (np.float64(0.006350528211987125), 'geo__Cluster 27 similarity'),
 (np.float64(0.006337558749902337), 'geo__Cluster 16 similarity'),
 (np.float64(0.006231053672395539), 'geo__Cluster 38 similarity'),
 (np.float64(0.0061213483458714855), 'log__households'),
 (np.float64(0.005849842001582111), 'log__total_bedrooms'),
 (np.float64(0.0056783104666850125), 'geo__Cluster 15 similarity'),
 (np.float64(0.005479729990673467), 'geo__Cluster 29 similarity'),
 (np.float64(0.005348325088535128), 'geo__Cluster 42 similarity'),
 (np.float64(0.004866251452445486), 'geo__Cluster 17 similarity'),
 (np.float64(0.004495340541933027), 'geo__Cluster 11 similarity'),
 (np.float64(0.004418821635620684), 'geo__Cluster 5 similarity'),
 (np.float64(0.0035344732505291285), 'geo__Cluster 21 similarity'),
 (np.float64(0.001832424657341851), 'cat__ocean_proximity_<1H OCEAN'),
 (np.float64(0.0015282226447271795), 'cat__ocean_proximity_NEAR OCEAN'),
 (np.float64(0.0004325970342247361), 'cat__ocean_proximity_NEAR BAY'),
 (np.float64(3.0190221102670295e-05), 'cat__ocean_proximity_ISLAND')]

Kết quả cho thấy log__median_incomecat__ocean_proximity_INLAND là những yếu tố quan trọng nhất.

7.4. Đánh giá trên Tập Kiểm tra (Evaluate on Test Set)

Cuối cùng, ta đánh giá mô hình trên tập test. Nhớ chỉ dùng transform, không dùng fit trên tập test.

X_test = strat_test_set.drop("median_house_value", axis=1)
y_test = strat_test_set["median_house_value"].copy()

final_predictions = final_model.predict(X_test)

final_rmse = root_mean_squared_error(y_test, final_predictions)
print(final_rmse)
41445.533268606625

Tính khoảng tin cậy 95% cho RMSE:

from scipy.stats import bootstrap

def rmse(squared_errors):
    return np.sqrt(np.mean(squared_errors))

confidence = 0.95
squared_errors = (final_predictions - y_test) ** 2
boot_result = bootstrap([squared_errors], rmse, confidence_level=confidence,
                        random_state=42)
rmse_lower, rmse_upper = boot_result.confidence_interval
print(f"95% CI for RMSE: ({rmse_lower:.4f}, {rmse_upper:.4f})")
95% CI for RMSE: (39520.9572, 43701.7681)

8. Lưu và Triển khai Mô hình

Sử dụng joblib để lưu mô hình ra file, giúp tái sử dụng sau này mà không cần huấn luyện lại.

import joblib

joblib.dump(final_model, "my_california_housing_model.pkl")
['my_california_housing_model.pkl']

Mô phỏng việc tải mô hình và dự đoán dữ liệu mới trong môi trường production:

import joblib

# extra code – định nghĩa lại các thành phần phụ thuộc nếu tải ở script khác
from sklearn.cluster import KMeans
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.metrics.pairwise import rbf_kernel

def column_ratio(X):
    return X[:, [0]] / X[:, [1]]

# class ClusterSimilarity(BaseEstimator, TransformerMixin):
#    [...]

final_model_reloaded = joblib.load("my_california_housing_model.pkl")
new_data = housing.iloc[:5]  # giả sử đây là dữ liệu mới
predictions = final_model_reloaded.predict(new_data)
predictions
array([441046.12, 454713.09, 104832.  , 101316.  , 336181.05])

9. Bài tập Thực hành

Bài tập 1: Support Vector Machine (SVM)

Thử nghiệm với SVR (Support Vector Regressor) với các kernel khác nhau. Lưu ý SVM không mở rộng tốt với dữ liệu lớn, nên ta chỉ dùng 5,000 mẫu đầu tiên.

from sklearn.model_selection import GridSearchCV
from sklearn.svm import SVR

param_grid = [
        {'svr__kernel': ['linear'], 'svr__C': [10., 30., 100., 300., 1000.,
                                               3000., 10000., 30000.0]},
        {'svr__kernel': ['rbf'], 'svr__C': [1.0, 3.0, 10., 30., 100., 300.,
                                            1000.0],
         'svr__gamma': [0.01, 0.03, 0.1, 0.3, 1.0, 3.0]},
    ]

svr_pipeline = Pipeline([("preprocessing", preprocessing), ("svr", SVR())])

grid_search = GridSearchCV(svr_pipeline, param_grid, cv=3,
                           scoring='neg_root_mean_squared_error')
grid_search.fit(housing.iloc[:5000], housing_labels.iloc[:5000])
GridSearchCV(cv=3,
             estimator=Pipeline(steps=[('preprocessing',
                                        ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                                                     SimpleImputer(strategy='median')),
                                                                                    ('standardscaler',
                                                                                     StandardScaler())]),
                                                          transformers=[('bedrooms',
                                                                         Pipeline(steps=[('simpleimputer',
                                                                                          SimpleImputer(strategy='median')),
                                                                                         ('functiontransformer',
                                                                                          FunctionTransformer(feature_names_out=<f...
                                                                         <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b87d49940>)])),
                                       ('svr', SVR())]),
             param_grid=[{'svr__C': [10.0, 30.0, 100.0, 300.0, 1000.0, 3000.0,
                                     10000.0, 30000.0],
                          'svr__kernel': ['linear']},
                         {'svr__C': [1.0, 3.0, 10.0, 30.0, 100.0, 300.0,
                                     1000.0],
                          'svr__gamma': [0.01, 0.03, 0.1, 0.3, 1.0, 3.0],
                          'svr__kernel': ['rbf']}],
             scoring='neg_root_mean_squared_error')
svr_grid_search_rmse = -grid_search.best_score_
svr_grid_search_rmse
np.float64(70059.9277356289)
grid_search.best_params_
{'svr__C': 10000.0, 'svr__kernel': 'linear'}

Kết quả cho thấy Kernel Linear tốt hơn RBF trong trường hợp này, và giá trị C tốt nhất nằm ở biên trên, gợi ý nên thử C lớn hơn nữa.

Bài tập 2: RandomizedSearchCV cho SVM

Thử thay thế Grid Search bằng Randomized Search.

from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import expon, loguniform

# Note: gamma bị bỏ qua khi kernel là "linear"
param_distribs = {
        'svr__kernel': ['linear', 'rbf'],
        'svr__C': loguniform(20, 200_000),
        'svr__gamma': expon(scale=1.0),
    }

rnd_search = RandomizedSearchCV(svr_pipeline,
                                param_distributions=param_distribs,
                                n_iter=50, cv=3,
                                scoring='neg_root_mean_squared_error',
                                random_state=42)
rnd_search.fit(housing.iloc[:5000], housing_labels.iloc[:5000])
RandomizedSearchCV(cv=3,
                   estimator=Pipeline(steps=[('preprocessing',
                                              ColumnTransformer(remainder=Pipeline(steps=[('simpleimputer',
                                                                                           SimpleImputer(strategy='median')),
                                                                                          ('standardscaler',
                                                                                           StandardScaler())]),
                                                                transformers=[('bedrooms',
                                                                               Pipeline(steps=[('simpleimputer',
                                                                                                SimpleImputer(strategy='median')),
                                                                                               ('functiontransformer',
                                                                                                FunctionTransformer(feature_names_...
                                                                               <sklearn.compose._column_transformer.make_column_selector object at 0x7e2b87d49940>)])),
                                             ('svr', SVR())]),
                   n_iter=50,
                   param_distributions={'svr__C': <scipy.stats._distn_infrastructure.rv_continuous_frozen object at 0x7e2b98296e70>,
                                        'svr__gamma': <scipy.stats._distn_infrastructure.rv_continuous_frozen object at 0x7e2b91ebc260>,
                                        'svr__kernel': ['linear', 'rbf']},
                   random_state=42, scoring='neg_root_mean_squared_error')
svr_rnd_search_rmse = -rnd_search.best_score_
svr_rnd_search_rmse
np.float64(56152.053691161)
rnd_search.best_params_
{'svr__C': np.float64(157055.10989448498),
 'svr__gamma': np.float64(0.26497040005002437),
 'svr__kernel': 'rbf'}

Randomized Search đã tìm ra bộ tham số tốt hơn hẳn cho RBF kernel, vượt qua kết quả của Grid Search trước đó.

# Kiểm tra phân phối của gamma
s = expon(scale=1).rvs(100_000, random_state=42)
((s > 0.105) & (s < 2.29)).sum() / 100_000
np.float64(0.80066)

Bài tập 3: Chọn lọc đặc trưng (SelectFromModel)

Sử dụng SelectFromModel để chỉ giữ lại các đặc trưng quan trọng nhất trước khi đưa vào mô hình dự đoán.

from sklearn.feature_selection import SelectFromModel

selector_pipeline = Pipeline([
    ('preprocessing', preprocessing),
    ('selector', SelectFromModel(RandomForestRegressor(random_state=42),
                                 threshold=0.005)),  # ngưỡng độ quan trọng tối thiểu
    ('svr', SVR(C=rnd_search.best_params_["svr__C"],
                gamma=rnd_search.best_params_["svr__gamma"],
                kernel=rnd_search.best_params_["svr__kernel"])),
])
selector_rmses = -cross_val_score(selector_pipeline,
                                  housing.iloc[:5000],
                                  housing_labels.iloc[:5000],
                                  scoring="neg_root_mean_squared_error",
                                  cv=3)
pd.Series(selector_rmses).describe()
0
count 3.000000
mean 56622.643481
std 2272.666703
min 54243.242290
25% 55548.509493
50% 56853.776696
75% 57812.344076
max 58770.911457

Kết quả không cải thiện nhiều, có thể do ngưỡng threshold chưa tối ưu.

Bài tập 4: Transformer tùy chỉnh dùng KNN

Tạo một transformer huấn luyện KNeighborsRegressor chỉ dựa trên tọa độ để dự đoán thu nhập trung vị (làm mượt nhiễu không gian).

from sklearn.base import MetaEstimatorMixin, clone

class FeatureFromRegressor(BaseEstimator, TransformerMixin, MetaEstimatorMixin):
    def __init__(self, regressor, target_features):
        self.regressor = regressor
        self.target_features = target_features

    def fit(self, X, y=None):
        if hasattr(X, "columns"):
            self.feature_names_in_ = list(X.columns)
            X_df = X
        else:
            X_df = pd.DataFrame(X)

        self.input_features_ = [c for c in X_df.columns
                                if c not in self.target_features]
        self.regressor_ = clone(self.regressor)
        self.regressor_.fit(X_df[self.input_features_],
                            X_df[self.target_features])
        return self

    def transform(self, X):
        columns = X.columns if hasattr(X, "columns") else None
        X_df = pd.DataFrame(X, columns=columns)
        preds = self.regressor_.predict(X_df[self.input_features_])
        if preds.ndim == 1:
            preds = preds.reshape(-1, 1)
        extra_columns = [f"pred_{t}" for t in self.target_features]
        preds_df = pd.DataFrame(preds, columns=extra_columns, index=X_df.index)
        return pd.concat([X_df, preds_df], axis=1)

    def get_feature_names_out(self, input_features=None):
        extra_columns = [f"pred_{t}" for t in self.target_features]
        return self.feature_names_in_ + extra_columns

Thử nghiệm Transformer này:

from sklearn.neighbors import KNeighborsRegressor

knn_reg = KNeighborsRegressor(n_neighbors=3, weights="distance")
knn_transformer = FeatureFromRegressor(knn_reg, ["median_income"])
geo_features = housing[["latitude", "longitude", "median_income"]]
knn_transformer.fit_transform(geo_features, housing_labels)
       latitude  longitude  median_income  pred_median_income
13096     37.80    -122.42         2.0987            3.347233
14973     34.14    -118.38         6.0876            6.899400
3785      38.36    -121.98         2.4330            2.900900
14689     33.75    -117.11         2.2618            2.261800
20507     33.77    -118.15         3.5292            3.475633
...         ...        ...            ...                 ...
14207     33.86    -118.40         4.7105            4.939100
13105     36.32    -119.31         2.5733            3.301550
19301     32.59    -117.06         4.0616            4.061600
19121     34.06    -118.40         4.1455            4.145500
19888     37.66    -122.41         3.2833            4.250667

[16512 rows x 4 columns]
knn_transformer.get_feature_names_out()
['latitude', 'longitude', 'median_income', 'pred_median_income']

Tích hợp vào Pipeline chính thay cho ClusterSimilarity:

from sklearn.base import clone

transformers = [(name, clone(transformer), columns)
                for name, transformer, columns in preprocessing.transformers]
geo_index = [name for name, _, _ in transformers].index("geo")
transformers[geo_index] = ("geo", knn_transformer,
                           ["latitude", "longitude", "median_income"])

new_geo_preprocessing = ColumnTransformer(transformers)

new_geo_pipeline = Pipeline([
    ('preprocessing', new_geo_preprocessing),
    ('svr', SVR(C=rnd_search.best_params_["svr__C"],
                gamma=rnd_search.best_params_["svr__gamma"],
                kernel=rnd_search.best_params_["svr__kernel"])),
])

new_pipe_rmses = -cross_val_score(new_geo_pipeline,
                                  housing.iloc[:5000],
                                  housing_labels.iloc[:5000],
                                  scoring="neg_root_mean_squared_error",
                                  cv=3)
pd.Series(new_pipe_rmses).describe()
0
count 3.000000
mean 68782.438065
std 2458.334599
min 66161.322618
25% 67655.268231
50% 69149.213845
75% 70092.995789
max 71036.777733

Kết quả tệ hơn so với dùng K-Means Cluster Similarity. Có thể cần tinh chỉnh tham số cho KNN.

Bài tập 5: Tự động khám phá với RandomSearchCV

Dùng Randomized Search để tìm tham số tối ưu cho cả bước tiền xử lý (KNN) và mô hình chính (SVR).

param_distribs = {
    "preprocessing__geo__regressor__n_neighbors": range(1, 30),
    "preprocessing__geo__regressor__weights": ["distance", "uniform"],
    "svr__C": loguniform(20, 200_000),
    "svr__gamma": expon(scale=1.0),
}

new_geo_rnd_search = RandomizedSearchCV(new_geo_pipeline,
                                        param_distributions=param_distribs,
                                        n_iter=50,
                                        cv=3,
                                        scoring='neg_root_mean_squared_error',
                                        random_state=42)
new_geo_rnd_search.fit(housing.iloc[:5000], housing_labels.iloc[:5000])
RandomizedSearchCV(cv=3,
                   estimator=Pipeline(steps=[('preprocessing',
                                              ColumnTransformer(transformers=[('bedrooms',
                                                                               Pipeline(steps=[('simpleimputer',
                                                                                                SimpleImputer(strategy='median')),
                                                                                               ('functiontransformer',
                                                                                                FunctionTransformer(feature_names_out=<function ratio_name at 0x7e2b87d589a0>,
                                                                                                                    func=<function column_ratio at 0x7e2b87d58900>)),
                                                                                               ('standardscaler',
                                                                                                StandardSc...
                   param_distributions={'preprocessing__geo__regressor__n_neighbors': range(1, 30),
                                        'preprocessing__geo__regressor__weights': ['distance',
                                                                                   'uniform'],
                                        'svr__C': <scipy.stats._distn_infrastructure.rv_continuous_frozen object at 0x7e2b9803cd40>,
                                        'svr__gamma': <scipy.stats._distn_infrastructure.rv_continuous_frozen object at 0x7e2b90149910>},
                   random_state=42, scoring='neg_root_mean_squared_error')
new_geo_rnd_search_rmse = -new_geo_rnd_search.best_score_
new_geo_rnd_search_rmse
np.float64(64573.262757363635)

Kết quả có cải thiện nhưng vẫn chưa vượt qua phương pháp dùng K-Means.

Bài tập 6: Viết lại StandardScalerClone hoàn chỉnh

Cài đặt lại lớp StandardScaler với đầy đủ tính năng: inverse_transform và hỗ trợ tên đặc trưng (get_feature_names_out).

import numpy as np
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_is_fitted, validate_data

class StandardScalerClone(TransformerMixin, BaseEstimator):
    def __init__(self, with_mean=True):  # no *args or **kwargs!
        self.with_mean = with_mean

    def fit(self, X, y=None):
        X = validate_data(self, X, ensure_2d=True)
        self.n_features_in_ = X.shape[1]
        if self.with_mean:
            self.mean_ = np.mean(X, axis=0)
        self.scale_ = np.std(X, axis=0, ddof=0)
        self.scale_[self.scale_ == 0] = 1  # Tránh chia cho 0
        return self

    def transform(self, X):
        check_is_fitted(self)
        X = validate_data(self, X, ensure_2d=True, reset=False)
        if self.with_mean:
            X = X - self.mean_
        return X / self.scale_

    def inverse_transform(self, X):
        check_is_fitted(self)
        X = validate_data(self, X, ensure_2d=True, reset=False)
        return X * self.scale_ + self.mean_

    def get_feature_names_out(self, input_features=None):
        if input_features is None:
            return getattr(self, "feature_names_in_",
                           [f"x{i}" for i in range(self.n_features_in_)])
        else:
            if len(input_features) != self.n_features_in_:
                raise ValueError("Invalid number of features")
            if hasattr(self, "feature_names_in_") and not np.all(
                self.feature_names_in_ == input_features
            ):
                raise ValueError("input_features ≠ feature_names_in_")
            return input_features

Kiểm tra tính tuân thủ API của Scikit-Learn:

from sklearn.utils.estimator_checks import check_estimator

check_estimator(StandardScalerClone())
[{'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_cloneable',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_cloneable',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_tags_renamed',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_valid_tag_types',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_repr',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_no_attributes_set_in_init',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit_score_takes_y',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_overwrite_params',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_dont_overwrite_parameters',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_fit_returns_self',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_readonly_memmap_input',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_unfitted',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_do_not_raise_errors_in_init_or_set_params',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_n_features_in_after_fitting',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_mixin_order',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_positive_only_tag_during_fit',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_dtypes',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_complex_data',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_dtype_object',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_empty_data_messages',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_pipeline_consistency',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_nan_inf',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_sparse_tag',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_sparse_array',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimator_sparse_matrix',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_pickle',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_estimators_pickle',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_f_contiguous_array_estimator',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformer_data_not_an_array',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformer_general',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformer_preserve_dtypes',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformer_general',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformers_unfitted',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_transformer_n_iter',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_parameters_default_constructible',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_methods_sample_order_invariance',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_methods_subset_invariance',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit2d_1sample',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit2d_1feature',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_get_params_invariance',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_set_params',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_dict_unchanged',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit_idempotent',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit_check_is_fitted',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_n_features_in',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit1d',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'},
 {'estimator': StandardScalerClone(),
  'check_name': 'check_fit2d_predict1d',
  'exception': None,
  'status': 'passed',
  'expected_to_fail': False,
  'expected_to_fail_reason': 'Check is not expected to fail'}]

Kiểm tra tính chính xác của thuật toán:

rng = np.random.default_rng(seed=42)
X = rng.random((1000, 3))
scaler = StandardScalerClone()
X_scaled = scaler.fit_transform(X)

# Kiểm tra công thức chuẩn hóa
assert np.allclose(X_scaled, (X - X.mean(axis=0)) / X.std(axis=0))
# Kiểm tra trường hợp không trừ mean
scaler = StandardScalerClone(with_mean=False)
X_scaled_uncentered = scaler.fit_transform(X)
assert np.allclose(X_scaled_uncentered, X / X.std(axis=0))
# Kiểm tra hàm nghịch đảo
scaler = StandardScalerClone()
X_back = scaler.inverse_transform(scaler.fit_transform(X))
assert np.allclose(X, X_back)
# Kiểm tra tên đặc trưng output
assert np.all(scaler.get_feature_names_out() == ["x0", "x1", "x2"])
assert np.all(scaler.get_feature_names_out(["a", "b", "c"]) == ["a", "b", "c"])
# Kiểm tra với DataFrame
df = pd.DataFrame({"a": rng.random(100), "b": rng.random(100)})
scaler = StandardScalerClone()
X_scaled = scaler.fit_transform(df)
assert np.all(scaler.feature_names_in_ == ["a", "b"])
assert np.all(scaler.get_feature_names_out() == ["a", "b"])

Mọi kiểm thử đều thông qua! Chúc mừng bạn đã hoàn thành Chương 2: Dự án Machine Learning đầu tiên. Bạn đã nắm vững quy trình từ xử lý dữ liệu thô đến tối ưu hóa mô hình phức tạp. Xin chào và hẹn gặp lại.