监督学习实战指南

如果你刚刚入门机器学习,监督学习绝对是你第一个要掌握的领域。简单来说,监督学习就是”有老师教”的学习模式——我们有输入数据和对应的正确答案,模型通过学习这些配对数据,掌握从输入预测输出的能力。这篇文章会带你深入理解监督学习的核心算法,从原理到实战,把那些面试官爱问、你实际项目又常用的知识点讲透。

逻辑回归:看似回归实则分类

很多人第一次看到”逻辑回归”这个名字就懵了——明明名字里有”回归”,为什么却是用来做分类的?这个问题问得好,实际上逻辑回归骨子里确实带着回归的影子,但它被发明出来的主要用途就是解决分类问题。

Sigmoid函数的魔力

逻辑回归的核心是Sigmoid函数,公式长这样:σ(z) = 1/(1+e^(-z))。这个函数的图像是一个S型曲线,输出值永远在0到1之间。你可以把它理解成一个”概率转换器”——任意实数经过它都能变成一个合法的概率值。

当我们用逻辑回归做二分类时,模型会输出一个0到1之间的概率值。比如判断一封邮件是不是垃圾邮件,输出0.8就意味着”有80%的概率是垃圾邮件”。然后我们设定一个阈值(通常默认是0.5),大于等于0.5判定为正类,小于0.5判定为负类。

为什么逻辑回归这么重要

你可能会问,既然深度学习这么强大,为什么还要学逻辑回归这个”上古时代”的算法?原因有几个:第一,它简单高效,训练速度快,适合作为baseline模型;第二,它的输出是概率值,可解释性强,在金融风控、医疗诊断等领域特别受欢迎;第三,它是理解神经网络激活函数的基础——ReLU、LeakyReLU这些函数都可以看成是Sigmoid的变种或简化。

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score, classification_report
 
# 生成模拟的二分类数据集
X, y = make_classification(
    n_samples=1000,
    n_features=20,
    n_informative=15,
    n_redundant=5,
    n_classes=2,
    random_state=42
)
 
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)
 
# 特征标准化(逻辑回归对特征尺度敏感)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
 
# 训练逻辑回归模型
lr_model = LogisticRegression(
    penalty='l2',           # L2正则化
    C=1.0,                  # 正则化强度的倒数,越小正则化越强
    solver='lbfgs',         # 优化算法
    max_iter=1000,          # 最大迭代次数
    random_state=42
)
 
lr_model.fit(X_train_scaled, y_train)
 
# 预测并评估
y_pred = lr_model.predict(X_test_scaled)
y_pred_proba = lr_model.predict_proba(X_test_scaled)[:, 1]
 
print(f"准确率: {accuracy_score(y_test, y_pred):.4f}")
print("\n分类报告:")
print(classification_report(y_test, y_pred))

决策树:人类可理解的模型

如果说逻辑回归像个黑盒子,决策树就是个白盒子——它的决策过程清晰可见,你可以把整棵树画出来,任何人都能看懂模型是怎么做预测的。

信息增益与基尼系数

构建决策树的核心问题是如何选择最佳的分裂特征。这里有两个主要指标:信息增益(基于熵)和基尼系数。

熵用来衡量数据的不确定性,公式是H = -∑p_i·log2(p_i)。当数据完全纯净(所有样本都属于同一类)时,熵为0;当数据完全混乱(各类均匀分布)时,熵达到最大值。信息增益就是分裂前后熵的变化——我们希望分裂后数据的纯度提高,也就是熵降低。

基尼系数则更直接,Gini = 1 - ∑p_i²,它衡量的是从数据中随机抽取两个样本,它们被分错的概率。基尼系数越小,数据越纯净。

实际使用中,scikit-learn的DecisionTreeClassifier默认使用基尼系数,因为它比熵快一点,而且效果差不多。

决策树的致命弱点

决策树虽然可解释性强,但它有个严重的缺陷——容易过拟合。一棵完全生长的决策树可以对训练数据达到100%准确率,但在新数据上往往表现很差。这就是所谓的”过拟合”问题。

解决这个问题的办法就是剪枝(pruning)。预剪枝是在树的生长过程中就进行限制,比如限制最大深度、最小样本数、信息增益阈值等;后剪枝是先让树完全生长,然后再从下往上剪掉那些增益不显著的分支。

from sklearn.tree import DecisionTreeClassifier, plot_tree
import matplotlib.pyplot as plt
 
# 使用不同深度的决策树展示过拟合
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
 
for idx, max_depth in enumerate([1, 5, 15]):
    tree_clf = DecisionTreeClassifier(
        max_depth=max_depth,
        min_samples_split=5,
        min_samples_leaf=2,
        random_state=42
    )
    tree_clf.fit(X_train, y_train)
    
    train_acc = tree_clf.score(X_train, y_train)
    test_acc = tree_clf.score(X_test, y_test)
    
    plot_tree(tree_clf, ax=axes[idx], filled=True, 
              class_names=['Class 0', 'Class 1'],
              feature_names=[f'Feature {i}' for i in range(X.shape[1])],
              max_depth=3)
    axes[idx].set_title(f'Max Depth={max_depth}\nTrain: {train_acc:.3f}, Test: {test_acc:.3f}')
 
plt.tight_layout()
plt.savefig('decision_tree_comparison.png', dpi=150)
plt.show()

随机森林:集体的智慧

单棵决策树容易过拟合,那如果我们构建很多棵树,让它们投票决定结果呢?这就是随机森林的核心思想。

Bootstrap与特征子空间

随机森林的”随机”体现在两个地方。第一是Bootstrap抽样——对每棵树的训练数据,都是从原始数据集中有放回地随机抽取n个样本,这样每棵树用的数据略有不同,增加了模型的多样性。

第二是特征子空间——每次分裂节点时,不是从所有特征中选择最佳分裂,而是随机选取一部分特征(比如sqrt(n_features)个),然后从这些特征中找最优分裂。这进一步增加了树之间的差异性。

当进行预测时,随机森林让所有树独立预测,然后采用多数投票(分类)或平均(回归)来得到最终结果。这种集成策略能有效抵消单棵树的偏差,显著提升模型的泛化能力。

随机森林的关键参数

n_estimators很好理解,就是树的数量。通常越多越好,但边际效益递减。一般100-500棵树就够用了,如果数据量大或特征多,可以用到1000棵。

max_depth控制每棵树的最大深度。如果不设限制,树会一直分裂到每个叶子节点只有一个样本。设一个合理的深度(比如10-20)通常能取得不错的效果。

min_samples_split和min_samples_leaf分别控制分裂所需的最小样本数和叶子节点的最小样本数。设大一点可以防止过拟合,但设太大可能导致欠拟合。

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import confusion_matrix, roc_auc_score
import seaborn as sns
 
# 训练随机森林
rf_model = RandomForestClassifier(
    n_estimators=200,
    max_depth=15,
    min_samples_split=5,
    min_samples_leaf=2,
    max_features='sqrt',      # 每次分裂考虑的特征数
    bootstrap=True,
    oob_score=True,           # 使用袋外数据评估
    random_state=42,
    n_jobs=-1                 # 使用所有CPU核心
)
 
rf_model.fit(X_train, y_train)
 
# 特征重要性分析
feature_importance = rf_model.feature_importances_
feature_names = [f'Feature {i}' for i in range(X.shape[1])]
 
# 取前10个最重要的特征
top_n = 10
top_indices = np.argsort(feature_importance)[-top_n:]
top_features = [feature_names[i] for i in top_indices]
top_importance = feature_importance[top_indices]
 
plt.figure(figsize=(10, 6))
plt.barh(top_features, top_importance)
plt.xlabel('Importance')
plt.title('Top 10 Feature Importances (Random Forest)')
plt.tight_layout()
plt.savefig('rf_feature_importance.png', dpi=150)
plt.show()
 
# 模型评估
y_pred_rf = rf_model.predict(X_test)
y_pred_proba_rf = rf_model.predict_proba(X_test)[:, 1]
 
print(f"OOB Score: {rf_model.oob_score_:.4f}")
print(f"Test AUC-ROC: {roc_auc_score(y_test, y_pred_proba_rf):.4f}")
 
# 混淆矩阵
cm = confusion_matrix(y_test, y_pred_rf)
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')
plt.xlabel('Predicted')
plt.ylabel('Actual')
plt.title('Confusion Matrix - Random Forest')
plt.savefig('rf_confusion_matrix.png', dpi=150)

XGBoost与LightGBM:Kaggle竞赛神器

如果说随机森林是集成学习的经典之作,XGBoost和LightGBM就是它的进化版。这两个算法在Kaggle竞赛中几乎成了标配,几乎所有结构化数据的比赛都能看到它们的身影。

Gradient Boosting的核心思想

XGBoost全称是eXtreme Gradient Boosting,核心思想是梯度提升。不同于随机森林的并行构建多棵树,梯度提升是串行构建的——每一棵新树都是为了纠正前面所有树的错误。

具体来说,我们先用全部数据训练一棵树,这棵树预测完后会有一些错误。然后我们计算这些错误的梯度(残差),用这些梯度作为目标值训练第二棵树。第二棵树专注于学习如何修正第一棵树的错误。第三棵树再学习第二棵树的残差……如此循环,最终的预测结果是所有树的预测之和。

XGBoost的独门绝技

XGBoost之所以比传统梯度提升树快很多,主要靠三个技术:一是二阶泰勒展开近似目标函数,比传统的一阶展开精度更高;二是对缺失值和稀疏数据的自动处理;三是Block存储结构,支持特征预排序,使得分裂点查找更快。

XGBoost还有一个很重要的特性是正则化。它在目标函数中直接加入了正则项:L1的叶子数量惩罚和L2的叶子权重惩罚。这使得XGBoost天然不容易过拟合,比单纯的调参效果更好。

import xgboost as xgb
from xgboost import XGBClassifier
 
# XGBoost训练
xgb_model = XGBClassifier(
    n_estimators=300,
    max_depth=6,
    learning_rate=0.1,
    subsample=0.8,            # 每棵树使用的样本比例
    colsample_bytree=0.8,     # 每棵树使用的特征比例
    reg_alpha=0.1,            # L1正则化
    reg_lambda=1.0,           # L2正则化
    gamma=0.1,                # 分裂最小损失减少阈值
    min_child_weight=1,       # 叶子节点的最小权重和
    objective='binary:logistic',
    eval_metric='auc',
    use_label_encoder=False,
    random_state=42,
    n_jobs=-1
)
 
# 训练并使用早停
xgb_model.fit(
    X_train, y_train,
    eval_set=[(X_test, y_test)],
    verbose=50,               # 每50轮输出一次
    early_stopping_rounds=30  # 30轮没有提升就停止
)
 
# 预测
y_pred_xgb = xgb_model.predict(X_test)
y_pred_proba_xgb = xgb_model.predict_proba(X_test)[:, 1]
 
print(f"Best Iteration: {xgb_model.best_iteration}")
print(f"Test AUC-ROC: {roc_auc_score(y_test, y_pred_proba_xgb):.4f}")

LightGBM:更快的梯度提升

LightGBM是微软开源的高效梯度提升框架,它的主要优势是训练速度极快,内存占用低。这让它在大规模数据集上特别有用。

LightGBM使用了一种叫Histogram的算法来近似寻找最佳分裂点——把连续特征值离散化到若干个箱子(bins)里,只在箱子的边界上找分裂点。这大大减少了计算量。另外LightGBM还使用了基于梯度的单边采样(GOSS)和互斥特征绑定(EFB)等技术进一步加速。

import lightgbm as lgb
 
# LightGBM参数
lgb_params = {
    'objective': 'binary',
    'metric': 'auc',
    'boosting_type': 'gbdt',
    'num_leaves': 31,              # 叶子节点数,控制模型复杂度
    'max_depth': -1,               # 不限制深度
    'learning_rate': 0.05,
    'n_estimators': 500,
    'subsample': 0.8,
    'colsample_bytree': 0.8,
    'reg_alpha': 0.1,
    'reg_lambda': 1.0,
    'min_child_samples': 20,
    'random_state': 42,
    'n_jobs': -1,
    'verbose': -1
}
 
# 创建数据集
lgb_train = lgb.Dataset(X_train, label=y_train)
lgb_test = lgb.Dataset(X_test, label=y_test, reference=lgb_train)
 
# 训练
lgb_model = lgb.train(
    lgb_params,
    lgb_train,
    valid_sets=[lgb_train, lgb_test],
    valid_names=['train', 'valid'],
    callbacks=[
        lgb.early_stopping(stopping_rounds=50),
        lgb.log_evaluation(period=50)
    ]
)
 
# 预测
y_pred_proba_lgb = lgb_model.predict(X_test)
print(f"Test AUC-ROC: {roc_auc_score(y_test, y_pred_proba_lgb):.4f}")
 
# 特征重要性可视化
lgb.plot_importance(lgb_model, max_num_features=10, figsize=(10, 6))
plt.title('LightGBM Feature Importance')
plt.savefig('lgb_feature_importance.png', dpi=150)

分类与回归的统一框架

你可能注意到了,上面讲的大多数算法既可以用于分类,也可以用于回归——逻辑回归、决策树、随机森林、XGBoost、LightGBM都有分类和回归两个版本。理解这个统一框架对你是很重要的。

分类和回归的本质区别在于输出的形式:分类输出离散的类别标签,回归输出连续的数值。但从数学上看,两者的区别没那么大——都是学习一个从输入到输出的映射函数。

对于输出形式,分类模型通常输出概率(通过Sigmoid或Softmax),回归模型直接输出原始数值。对于损失函数,分类常用交叉熵损失,回归常用MSE或MAE。对于评估指标,分类用准确率、AUC、F1,回归用MSE、RMSE、R²。

在实际项目中,很多情况下分类和回归是相通的。比如预测房价是回归任务,但如果你把房价分成”高""中""低”三档,就变成了分类任务。反过来,有时候排序问题既可以当回归做(预测得分),也可以当分类做(预测是否点击)。

类别不平衡问题处理

真实世界的数据集很少是完美平衡的。欺诈检测中,欺诈交易可能只占0.1%;疾病筛查中,患者可能只占5%。如果不做处理,模型会倾向于预测占多数的类别。

采样策略

处理类别不平衡最直接的方法是采样——要么增加少数类的样本(过采样),要么减少多数类的样本(欠采样)。

SMOTE(Synthetic Minority Over-sampling Technique)是最经典的过采样方法。它的思路是在少数类的样本之间插值生成新样本。具体做法是:对于每个少数类样本,随机选择一个近邻,然后在两者之间的连线上随机选择一个点作为新样本。

欠采样则相反,是从多数类中选取部分样本。最简单的方法是随机欠采样,但这样会丢失很多有价值的信息。更智能的方法是Edited Nearest Neighbors(ENN)和Tomek Links,它们会删除那些边界样本,保留信息量更大的样本。

from imblearn.over_sampling import SMOTE, ADASYN
from imblearn.under_sampling import RandomUnderSampler, TomekLinks
from imblearn.combine import SMOTETomek, SMOTEENN
from imblearn.pipeline import Pipeline as ImbPipeline
 
# 创建不平衡数据集
X_imbalanced, y_imbalanced = make_classification(
    n_samples=1000,
    n_features=20,
    n_informative=15,
    n_redundant=5,
    n_classes=2,
    weights=[0.9, 0.1],    # 90%的class 0,10%的class 1
    random_state=42
)
 
print(f"原始类别分布: {np.bincount(y_imbalanced)}")
 
# SMOTE过采样
smote = SMOTE(random_state=42)
X_smote, y_smote = smote.fit_resample(X_imbalanced, y_imbalanced)
print(f"SMOTE后类别分布: {np.bincount(y_smote)}")
 
# 欠采样
rus = RandomUnderSampler(random_state=42)
X_rus, y_rus = rus.fit_resample(X_imbalanced, y_imbalanced)
print(f"欠采样后类别分布: {np.bincount(y_rus)}")
 
# SMOTE + 欠采样组合
smote_tomek = SMOTETomek(random_state=42)
X_combined, y_combined = smote_tomek.fit_resample(X_imbalanced, y_imbalanced)
print(f"组合采样后类别分布: {np.bincount(y_combined)}")
 
# 在不平衡数据集上对比不同策略的效果
from sklearn.model_selection import cross_val_score
 
strategies = {
    '原始数据': None,
    'SMOTE': SMOTE(random_state=42),
    'ADASYN': ADASYN(random_state=42),
    '欠采样': RandomUnderSampler(random_state=42),
    'SMOTE+Tomek': SMOTETomek(random_state=42)
}
 
results = {}
for name, sampler in strategies.items():
    if sampler is None:
        X_resampled, y_resampled = X_imbalanced, y_imbalanced
    else:
        X_resampled, y_resampled = sampler.fit_resample(X_imbalanced, y_imbalanced)
    
    clf = RandomForestClassifier(n_estimators=100, random_state=42)
    scores = cross_val_score(clf, X_resampled, y_resampled, cv=5, scoring='f1')
    results[name] = scores.mean()
    print(f"{name}: F1={scores.mean():.4f} (+/- {scores.std():.4f})")

阈值调整

除了改变数据本身,我们还可以调整决策阈值。默认的0.5阈值在类别不平衡时往往不是最优的。我们可以通过绘制Precision-Recall曲线或ROC曲线,找到最优阈值。

一个更直接的方法是:找到那个使某项指标(如F1分数)最大化的阈值。

from sklearn.metrics import precision_recall_curve, f1_score
 
# 计算不同阈值下的精确率和召回率
precisions, recalls, thresholds = precision_recall_curve(y_test, y_pred_proba_rf)
 
# 找到F1最大的阈值
f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-10)
optimal_idx = np.argmax(f1_scores[:-1])  # 最后一个元素是特殊情况
optimal_threshold = thresholds[optimal_idx]
 
print(f"最优阈值: {optimal_threshold:.4f}")
print(f"最优F1分数: {f1_scores[optimal_idx]:.4f}")
 
# 使用最优阈值进行预测
y_pred_optimal = (y_pred_proba_rf >= optimal_threshold).astype(int)
print(f"调整阈值后的F1: {f1_score(y_test, y_pred_optimal):.4f}")

过拟合与正则化

过拟合是机器学习的核心挑战之一。模型在训练数据上表现很好,但在新数据上表现糟糕,这就是过拟合。它的本质是模型学到了训练数据中的噪声,而不仅仅是真正的模式。

正则化:约束模型的复杂度

正则化是防止过拟合的主要手段。它的核心思想是对模型的复杂度进行惩罚——越复杂的模型要付出越大的代价。这样模型就被迫在拟合数据和保持简单之间找平衡。

L1正则化(Lasso)会在损失函数中加入|w|_1 = ∑|w_i|。它的特点是能让一些权重变为零,从而实现特征选择。如果你有100个特征但只有20个真正有用,L1正则化会自动把不重要的特征权重压成0。L1特别适合高维稀疏数据,比如文本分类中的词袋特征。

L2正则化(Ridge)在损失函数中加入|w|_2² = ∑w_i²。它不会让权重变成零,但会让它们变小。L2适合处理特征高度相关的情况,比如多个高度相关的基因表达特征。

ElasticNet结合了L1和L2的优点:loss = MSE + α·L1 + β·L2。它既能做特征选择,又能在特征相关时保持稳定。

from sklearn.linear_model import Ridge, Lasso, ElasticNet
from sklearn.model_selection import GridSearchCV
 
# 对比不同正则化的效果
alphas = [0.001, 0.01, 0.1, 1, 10, 100]
 
# Ridge回归 (L2)
ridge_scores = []
for alpha in alphas:
    ridge = Ridge(alpha=alpha, random_state=42)
    ridge.fit(X_train, y_train)
    ridge_scores.append(ridge.score(X_test, y_test))
 
# Lasso回归 (L1)
lasso_scores = []
non_zero_features = []
for alpha in alphas:
    lasso = Lasso(alpha=alpha, random_state=42, max_iter=10000)
    lasso.fit(X_train, y_train)
    lasso_scores.append(lasso.score(X_test, y_test))
    non_zero_features.append(np.sum(lasso.coef_ != 0))
 
# ElasticNet回归
elasticnet_scores = []
for alpha in alphas:
    en = ElasticNet(alpha=alpha, l1_ratio=0.5, random_state=42, max_iter=10000)
    en.fit(X_train, y_train)
    elasticnet_scores.append(en.score(X_test, y_test))
 
# 绘制对比图
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
 
axes[0].semilogx(alphas, ridge_scores, 'b-o', label='Ridge (L2)')
axes[0].semilogx(alphas, lasso_scores, 'r-o', label='Lasso (L1)')
axes[0].semilogx(alphas, elasticnet_scores, 'g-o', label='ElasticNet')
axes[0].set_xlabel('Alpha')
axes[0].set_ylabel('R² Score')
axes[0].set_title('Regularization Comparison')
axes[0].legend()
axes[0].grid(True)
 
axes[1].semilogx(alphas, non_zero_features, 'r-o')
axes[1].set_xlabel('Alpha')
axes[1].set_ylabel('Non-zero Features')
axes[1].set_title('L1 Feature Selection Effect')
axes[1].grid(True)
 
plt.tight_layout()
plt.savefig('regularization_comparison.png', dpi=150)

Dropout:神经网络专属的正则化

Dropout是神经网络中最重要的正则化技术之一。它的工作原理很简单:训练时,每个神经元有p%的概率被”关闭”(输出设为0)。这样每次只训练一个”稀疏”的网络,不同的神经元组合形成不同的子网络。最终预测时,所有神经元都参与,但输出要乘以(1-p)。

Dropout为什么有效?它防止了特征之间的过度协同适应。想象一下,如果某个特征A总是和特征B一起出现,网络可能会过度依赖这个组合。Dropout强迫每个神经元独立学习有用的特征,而不是依赖其他神经元的存在。

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
 
# 定义带Dropout的神经网络
class DropoutNet(nn.Module):
    def __init__(self, input_dim, dropout_rate=0.5):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Dropout(dropout_rate),  # 训练时随机丢弃
            
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Dropout(dropout_rate),
            
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(dropout_rate),
            
            nn.Linear(64, 1),
            nn.Sigmoid()
        )
    
    def forward(self, x):
        # 评估时关闭Dropout
        return self.network(x)
 
# 训练循环中Dropout的处理
def train_with_dropout(model, train_loader, test_loader, epochs=50):
    criterion = nn.BCELoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
    train_losses = []
    test_losses = []
    
    for epoch in range(epochs):
        model.train()  # 开启Dropout
        train_loss = 0
        for X_batch, y_batch in train_loader:
            optimizer.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()
            train_loss += loss.item()
        
        train_losses.append(train_loss / len(train_loader))
        
        model.eval()  # 关闭Dropout进行评估
        with torch.no_grad():
            test_loss = 0
            for X_batch, y_batch in test_loader:
                outputs = model(X_batch)
                loss = criterion(outputs, y_batch)
                test_loss += loss.item()
            test_losses.append(test_loss / len(test_loader))
        
        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch+1}: Train Loss={train_losses[-1]:.4f}, Test Loss={test_losses[-1]:.4f}")
    
    return train_losses, test_losses

调参经验:GridSearchCV与RandomizedSearchCV

机器学习模型的性能高度依赖超参数的选择。手动调参既费时又容易遗漏最优组合。GridSearchCV和RandomizedSearchCV是两种自动调参策略。

GridSearchCV会遍历所有参数组合,如果参数空间很大,计算量会爆炸。比如3个参数,每个4个取值,就要训练4³=64个模型。但它的好处是覆盖全面,不会漏掉最优组合。

RandomizedSearchCV在参数空间中随机采样指定次数。它特别适合参数空间大、训练相对快的情况。直觉上随机搜索好像不如网格搜索,但研究发现随机搜索往往能找到和网格搜索差不多甚至更好的结果,而且速度快得多。

from sklearn.model_selection import GridSearchCV, RandomizedSearchCV
from scipy.stats import randint, uniform
 
# GridSearchCV示例
param_grid_xgb = {
    'n_estimators': [100, 200, 300],
    'max_depth': [3, 5, 7, 9],
    'learning_rate': [0.01, 0.05, 0.1, 0.2],
    'subsample': [0.7, 0.8, 0.9],
    'colsample_bytree': [0.7, 0.8, 0.9]
}
 
# 这个组合有3×4×4×3×3=432种组合,训练量很大
# 实际使用时建议先用粗粒度搜索,再细化
grid_search = GridSearchCV(
    XGBClassifier(random_state=42, use_label_encoder=False, eval_metric='auc'),
    param_grid_xgb,
    cv=5,
    scoring='roc_auc',
    n_jobs=-1,
    verbose=1
)
 
print("开始GridSearchCV...")
grid_search.fit(X_train, y_train)
 
print(f"\n最优参数: {grid_search.best_params_}")
print(f"最优CV分数: {grid_search.best_score_:.4f}")
 
# RandomizedSearchCV示例 - 适合大参数空间
param_dist_xgb = {
    'n_estimators': randint(100, 500),
    'max_depth': randint(3, 12),
    'learning_rate': uniform(0.01, 0.29),  # [0.01, 0.30]
    'subsample': uniform(0.6, 0.35),       # [0.6, 0.95]
    'colsample_bytree': uniform(0.6, 0.35),
    'min_child_weight': randint(1, 10),
    'gamma': uniform(0, 0.5)
}
 
random_search = RandomizedSearchCV(
    XGBClassifier(random_state=42, use_label_encoder=False, eval_metric='auc'),
    param_dist_xgb,
    n_iter=100,            # 随机采样100种组合
    cv=5,
    scoring='roc_auc',
    n_jobs=-1,
    verbose=1,
    random_state=42
)
 
print("\n开始RandomizedSearchCV...")
random_search.fit(X_train, y_train)
 
print(f"\n最优参数: {random_search.best_params_}")
print(f"最优CV分数: {random_search.best_score_:.4f}")

实战:完整Pipeline

最后,我们来一个完整的实战项目,把上面的知识串起来。这个例子会包含数据预处理、特征工程、模型选择、交叉验证、模型融合等完整流程。

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder, PolynomialFeatures
from sklearn.ensemble import VotingClassifier, StackingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
import warnings
warnings.filterwarnings('ignore')
 
# 模拟一个真实场景:信用风控二分类
# 假设我们有数值特征和类别特征
np.random.seed(42)
n_samples = 5000
 
# 生成数据
data = {
    'age': np.random.randint(18, 70, n_samples),
    'income': np.random.lognormal(10.5, 0.8, n_samples),
    'credit_score': np.random.randint(300, 850, n_samples),
    'employment_years': np.random.exponential(5, n_samples),
    'loan_amount': np.random.lognormal(9, 1, n_samples),
    'loan_purpose': np.random.choice(['home', 'car', 'education', 'business', 'other'], n_samples),
    'education': np.random.choice(['high_school', 'bachelor', 'master', 'phd'], n_samples),
    'default': np.zeros(n_samples, dtype=int)
}
 
# 生成目标变量(考虑特征之间的关系)
prob = 1/(1 + np.exp(-(
    -3 
    + 0.03 * data['income']/1000 
    - 0.01 * data['loan_amount']/10000
    + 0.005 * (data['credit_score'] - 600)
    - 0.2 * (data['loan_amount'] / data['income'])
    - 0.1 * (data['loan_purpose'] == 'business').astype(int)
    + 0.1 * (data['education'] == 'phd').astype(int)
)))
data['default'] = (np.random.random(n_samples) < prob).astype(int)
 
# 转换为DataFrame
import pandas as pd
df = pd.DataFrame(data)
 
# 定义特征
numeric_features = ['age', 'income', 'credit_score', 'employment_years', 'loan_amount']
categorical_features = ['loan_purpose', 'education']
 
# 划分数据集
X = df.drop('default', axis=1)
y = df['default']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
 
# 构建Pipeline
numeric_transformer = Pipeline(steps=[
    ('scaler', StandardScaler())
])
 
categorical_transformer = Pipeline(steps=[
    ('onehot', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])
 
preprocessor = ColumnTransformer(
    transformers=[
        ('num', numeric_transformer, numeric_features),
        ('cat', categorical_transformer, categorical_features)
    ]
)
 
# 定义多个基础模型
lr = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])
 
rf = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=200, max_depth=10, random_state=42, n_jobs=-1))
])
 
xgb_pipe = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', XGBClassifier(n_estimators=200, max_depth=6, learning_rate=0.1, 
                                   random_state=42, use_label_encoder=False, eval_metric='auc'))
])
 
lgb_pipe = Pipeline(steps=[
    ('preprocessor', preprocessor),
    ('classifier', lgb.LGBMClassifier(n_estimators=200, max_depth=6, learning_rate=0.1, 
                                       random_state=42, verbose=-1))
])
 
# 投票融合
voting_clf = VotingClassifier(
    estimators=[
        ('lr', lr),
        ('rf', rf),
        ('xgb', xgb_pipe),
        ('lgb', lgb_pipe)
    ],
    voting='soft'  # 使用概率投票,效果通常比硬投票好
)
 
# Stacking融合 - 用逻辑回归作为元学习器
stacking_clf = StackingClassifier(
    estimators=[
        ('rf', rf),
        ('xgb', xgb_pipe),
        ('lgb', lgb_pipe)
    ],
    final_estimator=LogisticRegression(max_iter=1000),
    cv=5,
    n_jobs=-1
)
 
# 训练和评估
models = {
    'Logistic Regression': lr,
    'Random Forest': rf,
    'XGBoost': xgb_pipe,
    'LightGBM': lgb_pipe,
    'Voting Ensemble': voting_clf,
    'Stacking Ensemble': stacking_clf
}
 
results = {}
for name, model in models.items():
    print(f"\n{'='*50}")
    print(f"Training: {name}")
    model.fit(X_train, y_train)
    
    y_pred = model.predict(X_test)
    y_pred_proba = model.predict_proba(X_test)[:, 1]
    
    auc = roc_auc_score(y_test, y_pred_proba)
    results[name] = {'auc': auc, 'model': model}
    print(f"AUC-ROC: {auc:.4f}")
 
# 找出最佳模型
best_model_name = max(results, key=lambda x: results[x]['auc'])
print(f"\n{'='*50}")
print(f"Best Model: {best_model_name} with AUC={results[best_model_name]['auc']:.4f}")

总结

监督学习是机器学习的基石,这篇文章涵盖了最核心的知识点。从逻辑回归到决策树,从随机森林到XGBoost/LightGBM,每种算法都有它的适用场景。

实际工作中,我的建议是先从简单的模型开始(逻辑回归、决策树),建立好评估框架和Pipeline,然后逐步尝试更复杂的模型。记住,模型不是越复杂越好,适合数据特点和业务需求的才是最好的。

类别不平衡、正则化、调参这些技巧,能在关键时刻让你的模型从”能用”变成”好用”。但最重要的还是对数据的理解和特征的工程——garbage in, garbage out,再好的算法也救不了差的数据。

动手实践才是学习机器学习的最佳方式。找一些真实数据集,跑一跑这些代码,改一改参数,感受一下不同选择带来的效果差异。祝你学习愉快!


本文为机器学习实战指南系列文章,主要涵盖监督学习的核心算法和实践技巧。