Tổng quan

Dự án này xây dựng hệ thống dự đoán giá nhà sử dụng các kỹ thuật hồi quy nâng cao. Luồng xử lý được thiết kế theo nguyên tắc module hóa, đảm bảo tính tái lập và tránh rò rỉ dữ liệu.

Source code: https://github.com/doantrongthai/AIO2025---Sales-Prediction

Quy trình dự án (Pipeline)

Hình 1: Lưu đồ thuật toán tổng quát của dự án

Cấu trúc thư mục

  • house-price-prediction/
  • config.py (Tập trung cấu hình)
  • utils.py (Hàm tiện ích tải và biến đổi dữ liệu)
  • preprocessing.py (Luồng tiền xử lý)
  • models.py (Định nghĩa và đánh giá mô hình)
  • visualization.py (Phân tích SHAP)
  • main.py (Luồng chính)
  • shap_analysis.py (Đánh giá bootstrap)

Cấu hình (Config)

VARIANCE_THRESHOLD = 0
POLYNOMIAL_DEGREES = [1, 2, 3]
ELASTIC_NET_PARAMS = {
    'alphas': np.logspace(-4, 3, 20),
    'l1_ratios': np.linspace(0, 1, 10)
}
CV_SPLITS = 5
N_BOOTSTRAPS = 1000

Tệp config.py tập trung tất cả siêu tham số quan trọng vào một chỗ để dễ thay đổi, quản lý.

Tiền xử lý dữ liệu (Data Preprocessing)

Custom IQRClipper

class IQRClipper(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        # Tính tứ phân vị để xác định ngưỡng ngoại lệ
        q25 = np.percentile(X, 25, axis=0)
        q75 = np.percentile(X, 75, axis=0)
        iqr = q75 - q25
        self.lower_ = q25 - 1.5 * iqr
        self.upper_ = q75 + 1.5 * iqr
        return self

    def transform(self, X):
        # Cắt giá trị nằm ngoài ngưỡng về biên
        return np.clip(X, self.lower_, self.upper_)

IQRClipper xử lý ngoại lệ bằng cách cắt về ngưỡng thay vì xóa, giữ nguyên kích thước tập dữ liệu. Phương pháp IQR định nghĩa ngoại lệ là giá trị nằm ngoài khoảng [Q₁ - 1.5 × IQR, Q₃ + 1.5 × IQR], trong đó IQR = Q₃ - Q₁.

Xử lý đặc trưng số học

def get_numeric_transformer(variance_threshold=0):
    return Pipeline(steps=[
        ('variance_remover', VarianceThreshold(threshold=variance_threshold)),
        ('imputer', KNNImputer()),
        ('winsorization', IQRClipper()),
        ('scaler', StandardScaler())
    ])

Luồng xử lý đặc trưng số theo 4 bước. Bước 1 loại bỏ đặc trưng có phương sai thấp. Bước 2 dùng KNNImputer điền giá trị thiếu dựa trên K hàng xóm gần nhất. Bước 3 cắt ngoại lệ bằng IQR. Bước 4 chuẩn hóa về trung bình bằng 0, độ lệch chuẩn bằng 1.

Xử lý đặc trưng phân loại

def get_categorical_transformer():
    return Pipeline(steps=[
        ('encoder', OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)),
        ('imputer', KNNImputer())
    ])

Luồng này mã hóa đặc trưng phân loại thành số bằng OrdinalEncoder, sau đó điền giá trị thiếu. Dùng OrdinalEncoder thay vì OneHotEncoder để tránh tạo quá nhiều đặc trưng.

Kết hợp

def get_preprocessor(num_features, cat_features, variance_threshold=0):
    return ColumnTransformer(transformers=[
        ('num_transformer', get_numeric_transformer(variance_threshold), num_features),
        ('cat_transformer', get_categorical_transformer(), cat_features)
    ])

ColumnTransformer áp dụng các luồng xử lý khác nhau cho từng loại đặc trưng, sau đó tự động nối kết quả.

Biến đổi logarit của đầu ra

def log_transform_target(y):
    return np.log1p(y)

def inverse_log_transform(y_log):
    return np.expm1(y_log)

Giá nhà có phân phối lệch phải nên cần biến đổi logarit để mô hình học tốt hơn. Sau khi dự đoán phải biến đổi ngược để có giá trị thực.

Định nghĩa mô hình

Các mô hình cơ bản

def get_base_models():
    return {
        'linear_regression': LinearRegression(),
        'ridge': Ridge(),
        'lasso': Lasso()
    }

Hàm này trả về một dictionary chứa 3 mô hình cơ bản để so sánh. Hồi quy tuyến tính không có regularization. Ridge dùng L2 regularization. Lasso dùng L1 regularization.

Mô hình bền vững (Robust model)

def get_robust_models(elastic_params=None):
    models = {
        'ridge': Ridge(),
        'lasso': Lasso(),
        'huber': HuberRegressor(),
        'quantile': QuantileRegressor(quantile=0.5, alpha=0),
        'ransac': RANSACRegressor()
    }

    if elastic_params:
        models['elasticnet'] = ElasticNet(
            alpha=elastic_params.get('alpha', 1.0),
            l1_ratio=elastic_params.get('l1_ratio', 0.5)
        )

    return models

Hàm này trả về các mô hình bền vững với ngoại lệ. HuberRegressor kết hợp hàm mất mát MSE và MAE. QuantileRegressor dự đoán trung vị thay vì trung bình. RANSACRegressor loại bỏ ngoại lệ tự động.

Đánh giá mô hình (Model Evaluation)

def evaluate_models(models, preprocessor, x_train, y_train_log, x_val, y_val):
    results = []

    for name, model in models.items():
        pipeline = Pipeline(steps=[
            ('preprocessor', preprocessor),
            ('model', model)
        ])

        pipeline.fit(x_train, y_train_log)

        y_val_pred_log = pipeline.predict(x_val)
        y_val_pred = np.expm1(y_val_pred_log)

        val_rmse = np.sqrt(mean_squared_error(y_val, y_val_pred))
        val_r2 = r2_score(y_val, y_val_pred)

        results.append({
            'model': name,
            'rmse': val_rmse,
            'r2': val_r2
        })

    return pd.DataFrame(results).sort_values(by='r2', ascending=False)

Hàm này đánh giá nhiều mô hình cùng lúc. Với mỗi mô hình, hàm tạo một pipeline kết hợp bộ tiền xử lý và mô hình, sau đó huấn luyện trên tập huấn luyện. Kết quả dự đoán được biến đổi ngược về giá gốc trước khi tính RMSE và R².

Thử nghiệm đặc trưng có dạng đa thức

def polynomial_feature_experiment(preprocessor, x_train, y_train_log, degrees, cv_splits=5):
    modes = [('full', False), ('interaction_only', True)]
    results = []

    for mode_name, interaction_only in modes:
        for d in degrees:
            pipeline = Pipeline([
                ('preprocessor', preprocessor),
                ('poly', PolynomialFeatures(
                    degree=d,
                    interaction_only=interaction_only,
                    include_bias=False
                )),
                ('regularizer', Ridge(alpha=1.0))
            ])

            cv = KFold(n_splits=cv_splits, shuffle=True, random_state=42)
            scores = cross_val_score(pipeline, x_train, y_train_log, cv=cv, scoring='r2')

            results.append({
                'type': mode_name,
                'degree': d,
                'mean_r2': np.mean(scores),
                'std_r2': np.std(scores)
            })

    return pd.DataFrame(results)

Hàm này thử nghiệm đặc trưng có dạng là đa thức với các bậc khác nhau. Có hai chế độ là đầy đủ tạo cả bình phương và tương tác, chỉ tương tác chỉ tạo tương tác. Trong đó, Ridge được sử dụng để regularize tránh hiện tượng quá khớp (overfitting). Kết quả được đánh giá qua kiểm định chéo (cross-validation).

Tối ưu ElasticNet

def tune_elasticnet(preprocessor, x_train, y_train_log, alphas, l1_ratios, cv_splits=5):
    param_grid = {
        'model__alpha': alphas,
        'model__l1_ratio': l1_ratios
    }

    elastic_pipeline = Pipeline(steps=[
        ('preprocessor', preprocessor),
        ('model', ElasticNet(max_iter=10000))
    ])

    cv = KFold(n_splits=cv_splits, shuffle=True, random_state=42)

    grid_search = GridSearchCV(
        estimator=elastic_pipeline,
        param_grid=param_grid,
        cv=cv,
        scoring='r2',
        n_jobs=-1
    )

    grid_search.fit(x_train, y_train_log)

    return grid_search.best_params_, grid_search.best_score_

Hàm này tìm tham số tối ưu cho ElasticNet bằng tìm kiếm lưới (Grid Search) với kiểm định chéo (Cross Validation). ElasticNet kết hợp L1 regularization và L2 regularization, trong đó alpha điều chỉnh độ mạnh regularization và l1_ratio quyết định tỷ lệ giữa L1 và L2.

Kết quả thực nghiệm

Thí nghiệm 1: Các mô hình cơ bản

Mô hình RMSE
Ridge Regression 25,348.68 0.908276
Linear Regression 25,513.45 0.907080
Lasso Regression 84,373.89 -0.016223

Ridge cho kết quả tốt nhất với R² bằng 0.908. Lasso bị thiếu khớp nghiêm trọng do alpha mặc định quá cao, phạt quá mạnh đưa hầu hết hệ số về 0.

Thí nghiệm 2: Mô hình đặc trưng đa thức

Loại Bậc Trung bình R² Độ lệch chuẩn R²
Đầy đủ 1 0.8758 0.0345
Đầy đủ 2 0.6381 0.0679
Đầy đủ 3 0.7494 0.0409
Chỉ tương tác 1 0.8758 0.0345
Chỉ tương tác 2 0.6277 0.0819
Chỉ tương tác 3 0.7463 0.0488

Bậc 1 cho R² cao nhất. Bậc 2 gây quá khớp nghiêm trọng vì tạo quá nhiều đặc trưng, R² giảm xuống 0.638 và độ lệch chuẩn tăng cao. Bậc 3 phục hồi một phần nhưng vẫn kém hơn bậc 1. Kết luận là không nên dùng đặc trưng đa thức cho tập dữ liệu này.

Thí nghiệm 3: Điều chỉnh ElasticNet

Kết quả cho rằng: Tham số tốt nhất là alpha=0.002976, l1_ratio=1.0, R² kiểm định chéo tốt nhất: 0.8842

Tìm kiếm lưới tìm ra alpha rất nhỏ cho thấy regularization nhẹ là đủ. l1_ratio bằng 1.0 nghĩa là Lasso thuần túy, mô hình tự chọn L1 regularization tốt hơn L2 cho tập dữ liệu này. Sau điều chỉnh, ElasticNet đạt R² bằng 0.884, cải thiện đáng kể so với Lasso mặc định.

Thí nghiệm 4: Mô hình bền vững

Mô hình RMSE
Quantile Regression 23,425.07 0.921669
Ridge Regression 25,348.68 0.908276
ElasticNet 25,362.10 0.908179
Huber Regression 80,165.34 0.082627
Lasso Regression 84,373.89 -0.016223
RANSAC 725,525.14 -74.141

Quantile Regression thắng áp đảo với R² bằng 0.922 và RMSE bằng 23,425. Mô hình này vượt trội nhờ bền vững với ngoại lệ và dự đoán trung vị thay vì trung bình. Ridge Regression và ElasticNet đã điều chỉnh cho kết quả tương đương nhau. Huber Regression thất bại có thể do epsilon mặc định không phù hợp. RANSAC có kểt quả rất kém với R² bằng âm 74 cho thấy không phù hợp với tập dữ liệu này.

Phân tích SHAP và đánh giá Bootstrap

Kiểm định chéo Bootstrap

def evaluate_with_cv_bootstrap(model, X, y, n_splits=5, n_bootstraps=1000):
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    all_rmse_scores = []
    all_mae_scores = []
    all_r2_scores = []

    for train_index, test_index in kf.split(X):
        X_train, X_test = X[train_index], X[test_index]
        y_train, y_test = y[train_index], y[test_index]

        model.fit(X_train, y_train)
        y_pred = model.predict(X_test)

        test_indices = np.arange(len(y_test))

        for _ in range(n_bootstraps):
            bootstrap_indices = np.random.choice(
                test_indices,
                size=len(test_indices),
                replace=True
            )
            y_test_bootstrapped = y_test[bootstrap_indices]
            y_pred_bootstrapped = y_pred[bootstrap_indices]

            all_rmse_scores.append(
                np.sqrt(mean_squared_error(y_test_bootstrapped, y_pred_bootstrapped))
            )
            all_mae_scores.append(
                mean_absolute_error(y_test_bootstrapped, y_pred_bootstrapped)
            )
            all_r2_scores.append(
                r2_score(y_test_bootstrapped, y_pred_bootstrapped)
            )

    return {
        "mean_rmse": np.mean(all_rmse_scores),
        "ci_rmse": np.percentile(all_rmse_scores, [2.5, 97.5]),
        "mean_mae": np.mean(all_mae_scores),
        "ci_mae": np.percentile(all_mae_scores, [2.5, 97.5]),
        "mean_r2": np.mean(all_r2_scores),
        "ci_r2": np.percentile(all_r2_scores, [2.5, 97.5])
    }

Hàm này kết hợp kiểm định chéo 5 phần với 1000 mẫu bootstrap để ước lượng khoảng tin cậy cho các chỉ số. Với mỗi phần, sau khi huấn luyện và dự đoán, hàm tạo 1000 mẫu bootstrap từ tập kiểm tra bằng cách lấy mẫu ngẫu nhiên có hoàn lại. Mỗi mẫu bootstrap được dùng để tính các chỉ số, cuối cùng tính khoảng tin cậy 95% từ phân vị 2.5% và 97.5%. Cách tiếp cận này cho phép đánh giá độ không chắc chắn của hiệu suất mô hình.

Phân tích SHAP

def plot_shap_summary(model, X_train, X_test, feature_names, model_name):
    explainer = shap.Explainer(model, X_train)
    shap_values = explainer(X_test)

    plt.figure(figsize=(10, 8))
    shap.summary_plot(
        shap_values,
        X_test,
        feature_names=feature_names,
        show=False
    )
    plt.title(f"Biểu đồ tóm tắt SHAP - {model_name}")
    plt.tight_layout()

    filename = f"shap_summary_{model_name}.png"
    plt.savefig(filename, dpi=150, bbox_inches='tight')
    plt.close()

Hàm này tạo biểu đồ tóm tắt SHAP để giải thích mô hình. SHAP tính đóng góp của mỗi đặc trưng đến dự đoán. Trong biểu đồ, đặc trưng được xếp theo mức quan trọng từ trên xuống dưới. Giá trị SHAP dương làm tăng dự đoán, âm làm giảm. Màu đỏ là giá trị đặc trưng cao, xanh là thấp. Ví dụ nếu chất lượng tổng thể có nhiều điểm đỏ ở bên phải, nghĩa là chất lượng cao làm tăng giá nhà.

Kết quả phân tích SHAP

Hình 2: Phân tích SHAP với Ridge, Linear và Lasso — theo thứ tự từ trái sang phải.

Phân tích SHAP cho thấy sự khác biệt rõ rệt trong cách các mô hình xử lý đặc trưng. Với Ridge Regression, các đặc trưng quan trọng nhất bao gồm OverallQual (chất lượng tổng thể), HalfBath, và các đặc trưng liên quan đến chất lượng tầng hầm. Đặc biệt, OverallQual thể hiện mối quan hệ rõ ràng: giá trị cao (màu đỏ) tập trung ở phía dương, cho thấy nhà chất lượng cao có giá trị lớn hơn. Các đặc trưng GarageCars và FullBath cũng đóng góp đáng kể, với giá trị cao làm tăng giá nhà.

Linear Regression cho thấy pattern khác biệt với tập trung vào các đặc trưng tương tác thời gian như YearBuilt_YrSold và các đặc trưng diện tích. Đáng chú ý là YearBuilt có ảnh hưởng mạnh với màu đỏ phân tán rộng, cho thấy nhà mới xây có xu hướng đắt hơn. MasVnrArea (diện tích ốp tường) và GarageArea cũng xuất hiện với vai trò quan trọng, phản ánh tầm quan trọng của yếu tố diện tích trong định giá.

Lasso Regression, với khả năng loại bỏ đặc trưng không cần thiết, tập trung vào tập đặc trưng gọn gàng hơn. OverallQual vẫn dẫn đầu nhưng kèm theo các đặc trưng như GarageArea, GarageCars và BedroomAbvGr. Đặc biệt, 1stFlrSF (diện tích tầng 1) có phân bố SHAP value rộng, cho thấy đây là yếu tố quyết định quan trọng. Sự xuất hiện của MSSubClass (loại nhà) với nhiều điểm tập trung gần 0 cho thấy Lasso đã điều chỉnh ảnh hưởng của đặc trưng này để tránh quá khớp.

Tổng kết

So sánh toàn bộ thí nghiệm

Thí nghiệm Mô hình tốt nhất RMSE Nhận xét
Các mô hình cơ bản Ridge 25,349 0.908 Ridge với điều chuẩn L2 cho kết quả tốt nhất trong nhóm mô hình cơ bản, vượt trội hơn hồi quy tuyến tính không điều chuẩn và Lasso bị thiếu khớp
Đa thức Bậc 1 Không có 0.876 Đặc trưng tuyến tính (bậc 1) cho R² cao nhất, trong khi bậc 2 và 3 gây quá khớp nghiêm trọng do tạo quá nhiều đặc trưng không cần thiết
ElasticNet Lasso đã điều chỉnh Không có 0.884 Tìm kiếm lưới tìm được alpha bằng 0.003 và l1_ratio bằng 1.0, cho thấy Lasso với điều chuẩn nhẹ phù hợp hơn, cải thiện đáng kể so với Lasso mặc định
Robust Quantile Regression 23,425 0.922 Quantile Regression thắng áp đảo nhờ dự đoán trung vị thay vì trung bình, xử lý ngoại lệ hiệu quả, phù hợp với phân phối giá nhà lệch phải

Quantile Regression là mô hình tốt nhất với R² bằng 0.922 và RMSE bằng 23,425, cải thiện 1.5% so với Ridge. Hiệu suất vượt trội này đến từ việc cực tiểu hóa MAE thay vì MSE, giúp mô hình ít bị ảnh hưởng bởi các giá trị cực trị trong cả đặc trưng đầu vào lẫn mục tiêu. Trong khi Ridge và ElasticNet đã điều chỉnh cho kết quả tương đương nhau ở mức 0.908, các mô hình bền vững khác như Huber và RANSAC thất bại do siêu tham số mặc định không phù hợp với đặc thù của tập dữ liệu.

Kết luận

Kiến trúc luồng xử lý là nền tảng quan trọng đảm bảo tính khoa học của quy trình học máy. Việc đóng gói toàn bộ các bước tiền xử lý và mô hình vào một luồng xử lý duy nhất không chỉ tránh được rò rỉ dữ liệu mà còn đảm bảo tính nhất quán khi áp dụng biến đổi từ huấn luyện sang kiểm định và kiểm tra. Cách tiếp cận này cũng tạo nền tảng vững chắc cho triển khai vì toàn bộ logic xử lý được tuần tự hóa thành một đối tượng duy nhất.

Kết quả thực nghiệm cho thấy độ phức tạp không đồng nghĩa với hiệu suất tốt hơn. Đặc trưng đa thức tạo hàng nghìn đặc trưng mới nhưng làm giảm R² từ 0.876 xuống 0.638 do quá khớp. Điều này minh chứng cho nguyên tắc dao cạo Occam trong học máy: mô hình đơn giản với đặc trưng phù hợp thường vượt trội hơn mô hình phức tạp với quá nhiều đặc trưng không cần thiết.

Điều chỉnh siêu tham số đóng vai trò quyết định đến hiệu suất cuối cùng. Lasso với alpha mặc định cho R² âm hoàn toàn thất bại, nhưng sau khi điều chỉnh tìm được alpha phù hợp, ElasticNet đạt R² bằng 0.884 tương đương Ridge. Tìm kiếm lưới với 200 tổ hợp tham số giúp khám phá không gian tham số một cách có hệ thống, tìm ra cấu hình tối ưu mà trực giác khó có thể đoán trước.

Đánh giá mô hình cần được thực hiện một cách bền vững thông qua kiểm định chéo kết hợp bootstrap. Phân chia huấn luyện kiểm tra đơn lẻ có thể cho kết quả may mắn hoặc không may tùy thuộc vào cách chia dữ liệu. Bootstrap với 1000 mẫu cho phép ước lượng khoảng tin cậy của các chỉ số, cung cấp thông tin về độ không chắc chắn và giúp so sánh mô hình một cách có ý nghĩa thống kê. Ví dụ nếu khoảng tin cậy của hai mô hình chồng lấp đáng kể thì sự khác biệt về hiệu suất trung bình có thể không có ý nghĩa thống kê.

Cuối cùng, việc thử nghiệm các mô hình bền vững là cần thiết khi làm việc với dữ liệu thực tế thường chứa ngoại lệ và nhiễu. Quantile Regression với chiến lược dự đoán trung vị và cực tiểu hóa MAE cho thấy ưu thế rõ rệt so với các mô hình cực tiểu hóa MSE truyền thống. Điều này đặc biệt quan trọng với bài toán dự đoán giá nhà, nơi phân phối có đuôi dài và giá trị cực trị có thể ảnh hưởng lớn đến mô hình nếu không được xử lý đúng cách.