Administrator
发布于 2026-09-09 / 0 阅读
0
0

统计学习实验二:正则化回归与模型选择

实验二:正则化回归与模型选择 - 完整详细学习笔记

实验目标与核心思想

这个实验课设计了四个紧密相连的学习目标。首先要通过多项式回归来直观展示过拟合现象和偏差-方差权衡这一机器学习的核心概念。其次,要学会使用交叉验证来估计模型的泛化误差,这是评估模型真实性能的关键技术。第三,要理解Ridge和Lasso这两种正则化方法如何通过不同的惩罚机制来缓解过拟合。最后,要掌握使用交叉验证来选择最优超参数的实践方法。

练习1:过拟合与偏差-方差权衡

数据生成机制

实验的数据生成过程精心设计了一个非线性回归问题。解释变量 X 服从区间 [-1, 1] 上的均匀分布 \mathcal{U}(-1, 1),这保证了输入数据在定义域内均匀覆盖。目标变量的生成遵循关系式:

Y = \mathrm{sinc}(2X) + \varepsilon

这里的 \mathrm{sinc} 函数定义为 \mathrm{sinc}(t) = \frac{\sin t}{t},特别地在 t=0 处定义 \mathrm{sinc}(0)=1 以保证函数的连续性。这个函数在信号处理中非常重要,它具有平滑的波形特征,在原点附近达到最大值,然后逐渐衰减并产生振荡。选择这个函数作为真实关系是因为它既不是多项式函数,又具有足够的复杂性,能够很好地测试多项式回归的拟合能力。

噪声项 \varepsilon \sim \mathcal{N}(0, \sigma^2) 是独立于 X 的高斯噪声,标准差 \sigma 设定为0.4。这个噪声水平的选择经过精心考虑:太小会使过拟合现象不明显,太大则会掩盖真实的函数关系。0.4的设定使得信噪比适中,既能观察到清晰的函数形态,又能产生足够的随机性来展示偏差-方差权衡。

让我们看看数据生成的核心代码实现:

def meanFunction(X):
    return np.sinc(2.*X)  # NumPy的sinc函数自动处理了x=0的情况

def generateData(n):
    """
    生成n个数据点
    n: 要生成的数据点数量
    """
    # 生成均匀分布的X,范围是[-1, 1]
    X = np.random.uniform(-1, 1, size=(n, 1))  # 生成n个样本,每个是1维的
    
    # 计算真实的均值函数值
    y_mean = meanFunction(X)  # 计算每个X对应的真实函数值
    
    # 添加高斯噪声,标准差为0.4
    noise = np.random.normal(0, 0.4, size=y_mean.shape)  # 生成与y_mean相同形状的噪声
    
    # 生成最终的y值:真实函数值 + 噪声
    y = y_mean + noise  # 将噪声加到真实值上
    
    return X, y  # 返回特征X和目标值y

这里有几个关键的设计选择。np.random.uniform(-1, 1, size=(n, 1)) 生成的是列向量而不是一维数组,这是为了与scikit-learn的输入格式保持一致。噪声标准差0.4的选择使得信噪比约为2.5(因为sinc函数的最大值为1),这个水平既能观察到清晰的函数形态,又能产生足够的随机性。这个函数的设计使得每次调用都能生成新的独立同分布的数据集,这对后续的蒙特卡罗模拟至关重要。

多项式回归模型

预测模型采用 p 次多项式回归:

h(x) = \beta_0 + \beta_1 x + \cdots + \beta_p x^p = \sum_{j=0}^{p} \beta_j x^{j}

这个模型通过线性组合不同次幂的 x 来逼近任意连续函数。根据Weierstrass逼近定理,任何连续函数都可以用多项式任意精确地逼近,但这需要足够高的次数。然而在实践中,高次多项式虽然拥有更强的表达能力,却也更容易过拟合训练数据。

代码实现中,generateData函数精确地实现了上述数据生成过程。它首先生成均匀分布的 X 值,然后计算对应的真实函数值 \mathrm{sinc}(2X),最后添加高斯噪声。这个函数的设计使得每次调用都能生成新的独立同分布的数据集,这对后续的蒙特卡罗模拟至关重要。

实现时使用了scikit-learn的Pipeline功能:

def polyReg(degree):
    model = Pipeline([
        ("poly", PolynomialFeatures(degree=degree)),
        ("lr", LinearRegression(fit_intercept=False))
    ])
    return model

这个Pipeline设计非常巧妙。PolynomialFeatures(degree=degree) 将一维输入 x 扩展为 [1, x, x^2, ..., x^p] 的特征矩阵。设置 fit_intercept=False 是因为多项式特征的第一列已经是全1的列(对应 x^0),不需要额外的截距项。Pipeline确保了特征转换和模型训练的一致性,在预测新数据时会自动应用相同的特征转换。这种设计模式使得模型训练过程更加简洁和模块化。

不同复杂度模型的拟合表现

实验生成了20个训练样本,然后分别拟合1、3、5、7、9、11次的多项式模型。这个样本量的选择很有讲究:20个样本对于低次多项式来说足够估计参数,但对于高次多项式(如11次有12个参数)则会导致严重的过拟合。

实验通过循环拟合不同次数的多项式,展示了从欠拟合到过拟合的完整过程:

degrees = np.arange(1, 12, 2)  # [1, 3, 5, 7, 9, 11]
fig, ax = plt.subplots(2, 3, figsize=(12, 6), constrained_layout=True)

for id in range(6):
    degree = degrees[id]  # 获取当前的多项式次数
    # 创建并训练模型
    model = polyReg(degree).fit(X, y)  # 用20个样本训练
    
    ii = id // 3
    jj = id % 3
    ax[ii,jj].scatter(X, y, s=10, label='Data')
    ax[ii,jj].plot(xplot, yplot, linestyle='dashed', color='grey', label='Mean function')
    # 在密集的点上预测以得到平滑曲线
    ax[ii,jj].plot(xplot, model.predict(xplot.reshape(-1,1)), 
                   linewidth=1, color='black', label='PolyReg')

使用200个密集点 xplot = np.linspace(-1, 1, 200).reshape(-1, 1) 来绘制预测曲线,这样能得到平滑的可视化效果,而不是只在训练点处的离散预测。从可视化结果可以观察到典型的欠拟合到过拟合的转变过程。1次多项式(直线)明显欠拟合,无法捕捉数据的非线性模式。3次和5次多项式开始接近真实函数的形态。而当次数增加到9和11时,模型开始在训练点之间产生剧烈的振荡,这就是Runge现象的体现,表明模型过度拟合了包含噪声的训练数据。

偏差-方差分解的蒙特卡罗分析

为了定量分析偏差-方差权衡,实验进行了2000次蒙特卡罗模拟。每次模拟生成新的20个样本的训练集,训练不同次数的多项式模型,然后在固定点 x=0 处进行预测。选择 x=0 作为评估点是因为这是定义域的中心,且真实值 \mathrm{sinc}(0)=1 容易计算。

偏差的计算公式为预测值的期望与真实值之差:\text{Bias} = E[\hat{y}] - y_{\text{true}}。它衡量的是模型的系统性偏离,反映了模型的表达能力不足。方差的计算公式为 \text{Var} = E[(\hat{y} - E[\hat{y}])^2],它衡量的是预测的不稳定性,反映了模型对训练数据的敏感程度。

偏差-方差分析的代码实现展示了统计机器学习的核心思想:

# 设置候选的多项式次数
degrees = np.arange(1, 8)  # [1, 2, 3, 4, 5, 6, 7]

# 设置查询点(我们要评估的点)
xquery = np.array([[0.]])  # 选择x=0这个点

# 获取查询点的真实值
ref = meanFunction(xquery)  # 计算sin(0)/0 = 1

# Monte Carlo模拟参数
Nmc = 2000  # 重复2000次实验

# 初始化存储预测结果的数组
pred = np.zeros((Nmc, degrees.shape[0]))  # 2000行7列的矩阵

# 执行Monte Carlo模拟
for imc in range(Nmc):  # 每次循环代表一次新实验
    # 生成新的20个样本的数据集
    X_sim, y_sim = generateData(n)  
    
    # 对每个多项式次数进行拟合和预测
    for ideg, degree in enumerate(degrees):
        # 创建并训练模型
        model = polyReg(degree).fit(X_sim, y_sim)  
        
        # 在x=0处进行预测并存储
        pred[imc, ideg] = model.predict(xquery)[0, 0]  # 获取标量预测值

# 计算偏差和方差
# 偏差 = E[预测值] - 真实值
bias = np.mean(pred, axis=0) - ref.flatten()  

# 方差 = E[(预测值 - E[预测值])^2]
variance = np.var(pred, axis=0)

这段代码的精妙之处在于通过大量重复实验来估计期望和方差。pred 矩阵的每一行是一次独立实验的结果,每一列对应一个多项式次数。np.mean(pred, axis=0) 沿着实验维度求平均,得到每个模型的预测期望。选择 xquery = np.array([[0.]]) 是因为在定义域中心评估最具代表性。

实验结果清晰地展示了偏差-方差权衡的规律。低次多项式具有高偏差但低方差:模型太简单无法准确拟合真实函数,但在不同训练集上的预测结果比较稳定。高次多项式则相反,具有低偏差但高方差:模型能够很好地拟合训练数据(甚至包括噪声),但对训练集的变化非常敏感,导致预测不稳定。总误差(偏差平方加方差)在中等复杂度时达到最小值,这就是最优的模型复杂度。

箱线图直观地展示了预测值的分布情况。随着多项式次数增加,预测值的中位数逐渐接近真实值(偏差减小),但分布的离散程度明显增大(方差增加)。特别是高次多项式出现了许多异常值,这表明模型在某些训练集上产生了极端的预测。

练习2:泛化误差与交叉验证

泛化误差的真实估计

练习2首先重用了练习1中的20个训练样本和训练好的7个不同次数的多项式模型:

# 设置多项式的次数范围
degrees = np.arange(1, 8, 1)  # 创建数组[1, 2, 3, 4, 5, 6, 7]

# 创建一个列表来存储所有训练好的模型
models = []  # 初始化空列表

# 训练每个次数的多项式模型
for degree in degrees:
    # 为当前次数创建多项式回归pipeline
    model = polyReg(degree)  # 调用之前定义的polyReg函数
    
    # 使用练习1中的数据(X, y)训练模型
    # X和y是20个样本的训练数据
    model.fit(X, y)  # 拟合模型
    
    # 将训练好的模型添加到列表中
    models.append(model)  # 保存模型供后续使用

然后生成了10000个测试样本来评估泛化误差。这个大规模测试集能够提供泛化误差的准确估计,因为根据大数定律,当测试样本足够多时,测试误差会收敛到真实的泛化误差。

生成大规模测试集并计算训练误差和测试误差的代码展示了过拟合的本质:

# 生成大规模测试集
ntest = 10000  # 测试集大小
Xtest, ytest = generateData(ntest)  # 使用相同的数据生成函数

# 初始化存储误差的列表
erTrain = []  # 训练误差列表
erTest = []   # 测试误差列表

# 计算每个模型的训练误差和测试误差
for i, model in enumerate(models):
    # 步骤1:计算训练误差
    # 在训练数据上进行预测
    y_train_pred = model.predict(X)  # 用训练数据X预测
    # 计算均方误差
    train_mse = mean_squared_error(y, y_train_pred)  # 真实y vs 预测y
    erTrain.append(train_mse)  # 存储训练误差
    
    # 步骤2:计算测试误差(泛化误差)
    # 在测试数据上进行预测
    y_test_pred = model.predict(Xtest)  # 用测试数据Xtest预测
    # 计算均方误差
    test_mse = mean_squared_error(ytest, y_test_pred)  # 真实ytest vs 预测值
    erTest.append(test_mse)  # 存储测试误差

使用10000个测试样本是基于大数定律的考虑:当样本足够多时,经验误差会收敛到期望误差。mean_squared_error 函数计算 \frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2,这是标准的均方误差定义。

两者的对比揭示了过拟合的本质:训练误差随着模型复杂度单调递减,因为更复杂的模型总能更好地拟合训练数据;但测试误差呈现U型曲线,先减小后增大,在4次多项式附近达到最小值。训练误差与测试误差之间的差距反映了过拟合的程度。低次多项式时两者都较大,这是欠拟合的表现。高次多项式时训练误差很小但测试误差很大,这是严重过拟合的标志。最佳的模型复杂度应该在两者差距较小且测试误差最低的位置。

留一法交叉验证

实际应用中我们通常无法获得大量测试数据,因此需要使用交叉验证来估计泛化误差。留一法交叉验证(Leave-One-Out Cross-Validation, LOOCV)是k折交叉验证的极端情况,其中k等于样本数n。

留一法交叉验证的实现展示了如何在数据有限时估计泛化误差:

# 设置留一法交叉验证参数
n_splits = n  # 留一法:折数等于样本数(n=20)

# 创建KFold交叉验证对象
kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
# 参数解释:
# n_splits=20: 将数据分成20份,每次留出1个样本
# shuffle=True: 打乱数据顺序,避免数据顺序带来的偏差
# random_state=42: 固定随机种子,保证结果可重现

# 初始化CV误差存储矩阵
erCV = np.zeros((n, len(degrees)))  # 20行(每个fold)x 7列(每个degree)

# 执行交叉验证循环
for icv, (itrain, itest) in enumerate(kf.split(X)):
    # icv: 当前折的索引(0到19)
    # itrain: 训练集索引数组(19个索引)
    # itest: 测试集索引数组(1个索引,留一法)
    
    # 提取当前折的训练数据和测试数据
    X_train_fold = X[itrain]  # 19个训练样本的特征
    y_train_fold = y[itrain]  # 19个训练样本的目标值
    X_test_fold = X[itest]    # 1个测试样本的特征
    y_test_fold = y[itest]    # 1个测试样本的目标值
    
    # 对每个多项式次数进行训练和测试
    for ideg, degree in enumerate(degrees):
        # 创建新的模型实例(每折都要重新训练)
        model_cv = polyReg(degree)
        
        # 在当前折的训练数据上训练模型
        model_cv.fit(X_train_fold, y_train_fold)  # 用19个样本训练
        
        # 在留出的测试样本上进行预测
        y_pred_fold = model_cv.predict(X_test_fold)  # 预测1个样本
        
        # 计算预测误差(平方误差)
        error = (y_test_fold - y_pred_fold) ** 2  # 计算平方误差
        erCV[icv, ideg] = error[0, 0]  # 存储误差值,注意取标量

# 计算每个degree的平均CV误差
mean_cv_error = np.mean(erCV, axis=0)  # 对20个fold求平均

kf.split(X) 生成器依次产生训练集和测试集的索引。设置 shuffle=True 打乱了数据顺序,这很重要,因为原始数据可能有某种顺序模式。在每一折中,使用19个样本训练模型,用剩余的1个样本测试。这个过程重复20次,每个样本都恰好被用作测试集一次。虽然计算开销较大(需要训练20×7=140个模型),但对于小样本数据集,LOOCV能够最大限度地利用有限的数据,提供偏差较小的误差估计。

每折都创建新的模型实例确保了评估的独立性。交叉验证误差矩阵erCV的维度是20×7,记录了每个折在每个多项式次数下的预测误差。对每个次数求平均得到CV误差的估计。结果显示CV误差成功地识别出了最佳的模型复杂度(4次多项式),这与真实测试误差的最小值位置一致,验证了交叉验证作为模型选择工具的有效性。

GridSearchCV的便捷实现

GridSearchCV提供了更便捷的交叉验证实现方式。它自动化了参数网格搜索和交叉验证的过程:

from sklearn.model_selection import GridSearchCV

# Define the model
model = polyReg(None)

# define the hyperparameters (in this case, the degrees)
param_grid = {
    "poly__degree": degrees  # 双下划线访问Pipeline内部参数
}

polyCV = GridSearchCV(
    estimator=model,
    param_grid=param_grid,
    cv=n,  # 留一法
    scoring="neg_mean_squared_error",  # 负MSE因为要最大化
)

polyCV.fit(X, y)
print('The CV scores are:', -polyCV.cv_results_['mean_test_score'])
print('The best parameter index is:', polyCV.best_index_)
print('The best model is:', polyCV.best_estimator_)

参数网格param_grid = {"poly__degree": degrees}指定了要搜索的超参数范围。这里的"poly__degree"使用双下划线语法来访问Pipeline中的嵌套参数。设置scoring="neg_mean_squared_error"是因为GridSearchCV默认最大化评分,而我们希望最小化均方误差,所以使用负值。cv=n指定使用留一法交叉验证。搜索完成后,best_index_best_estimator_cv_results_提供了详细的结果信息,包括最佳参数、最佳模型和所有参数组合的性能。

练习3:正则化回归

正则化的必要性

在这个练习中,我们将多项式次数固定为10,即:

h(x) = \sum_{j=0}^{\color{red}{10}} \beta_jx^{j}

核心问题是:10次多项式有11个参数,但我们只有20个训练样本,参数数量占样本数量的一半以上,这种情况下普通最小二乘法很容易过拟合。正则化通过惩罚大的系数来缓解这个问题。

Ridge回归的L2正则化

Ridge回归通过添加L2惩罚项来控制模型复杂度:

\hat\beta^{\text{Ridge}} = \arg\min_\beta |\underline{y}-\underline{X}\beta|^2 + \lambda |\beta|^2

这里 |\beta|^2 = \sum_{j=0}^{p} \beta_j^2 是参数向量的L2范数平方。惩罚项的作用是防止参数变得过大,从而减少模型对训练数据中噪声的敏感性。参数 \lambda 控制正则化强度:\lambda=0 时退化为普通最小二乘,\lambda 增大时参数被压缩得更厉害。

Ridge回归有解析解:\hat\beta^{\text{Ridge}} = (\underline{X}^T\underline{X} + \lambda I)^{-1}\underline{X}^T\underline{y}。与普通最小二乘相比,Ridge回归在矩阵 \underline{X}^T\underline{X} 上添加了 \lambda I,这不仅解决了多重共线性问题(保证矩阵可逆),还起到了收缩参数的作用。从贝叶斯角度看,Ridge回归相当于对参数施加了均值为0、方差为 1/\lambda 的高斯先验。

Lasso回归的L1正则化

Lasso回归使用L1惩罚:

\hat\beta^{\text{LASSO}} = \arg\min_\beta \frac{1}{2n}|\underline{y}-\underline{X}\beta|^2 + \lambda |\beta|_1

注意损失函数的缩放因子 \frac{1}{2n} 与Ridge不同,这影响了 \lambda 的取值范围。L1范数 |\beta|*1 = \sum*{j=0}^{p} |\beta_j| 的几何特性使得Lasso能够产生稀疏解,即许多参数会被精确地压缩到0。这种特性使Lasso同时具有参数估计和变量选择的功能。

Lasso没有解析解,需要通过迭代算法求解,如坐标下降法或近端梯度法。L1惩罚在原点处不可导的特性正是产生稀疏性的原因:优化过程中参数容易"卡"在0点。从贝叶斯角度看,Lasso相当于对参数施加了Laplace先验分布。

需要注意的是,两种方法的损失函数定义略有不同:Ridge回归使用残差平方和,而Lasso使用二分之一的均方误差。这种差异影响了超参数 \lambda 的尺度,因此在选择 \lambda 时需要考虑这个因素。此外,如果特征包含常数项,对应的系数(即截距)也会被惩罚,这在某些情况下可能不是我们想要的效果。

正则化多项式回归的实现

正则化回归的Pipeline实现展示了特征标准化的重要性:

def regularizedPolyReg(degree: int, regType: str, hyper: float):
    """
    构建正则化的多项式回归模型
    
    参数:
    degree: 多项式的次数
    regType: 正则化类型,'ridge'或'lasso'
    hyper: 正则化超参数λ
    """
    
    # 步骤1:根据regType选择回归方法
    if regType.lower() == 'ridge':
        # 创建Ridge回归器,alpha是正则化强度
        regressor = Ridge(alpha=hyper, fit_intercept=False)
    elif regType.lower() == 'lasso':
        # 创建Lasso回归器,alpha是正则化强度
        # max_iter增加到1000000以确保收敛
        regressor = Lasso(alpha=hyper, fit_intercept=False, max_iter=1000000)
    
    # 步骤2:构建Pipeline
    model = Pipeline([
        # 第一步:创建多项式特征
        ("poly", PolynomialFeatures(degree=degree)),
        
        # 第二步:标准化特征(中心化并缩放)
        # 这很重要!确保所有特征在相同的尺度上
        ("scaler", StandardScaler()),
        
        # 第三步:应用正则化回归
        ("regression", regressor)
    ])
    
    return model  # 返回构建好的模型

StandardScaler() 对每个特征进行标准化:x' = \frac{x - \mu}{\sigma}。它将每个特征转换为均值为0、标准差为1的分布。这确保了所有特征在相同的尺度上,使得正则化惩罚对所有参数公平。如果不进行标准化,数值范围较大的特征对应的参数会受到更强的惩罚,这会导致不合理的偏差。

在多项式回归中,标准化尤其重要,因为 x, x^2, ..., x^{10} 的数值范围差异极大。例如当 x \in [-1, 1] 时,x^{10} 的范围是 [0, 1],但其值通常非常小,如果不标准化,高次项的参数几乎不会受到惩罚。Lasso需要更多迭代(max_iter=1000000)因为L1优化问题的收敛较慢。

正则化效果的可视化分析

实验通过对比展示了两种正则化方法的不同效果:

# 使用练习1中的数据(20个样本)
# 设置10次多项式
degree = 10  # 高次多项式,容易过拟合

# 选择正则化参数
# 注意:由于缩放因子不同,我们为两种方法选择不同的λ
lambda_ridge = 1.0    # Ridge的λ值
lambda_lasso = 0.01   # Lasso的λ值(更小,因为损失函数已经除以n)

# 训练Ridge回归模型
model_ridge = regularizedPolyReg(degree, 'ridge', lambda_ridge)
model_ridge.fit(X, y)  # 用20个样本训练

# 训练Lasso回归模型
model_lasso = regularizedPolyReg(degree, 'lasso', lambda_lasso)
model_lasso.fit(X, y)  # 用20个样本训练

# 训练普通的10次多项式(作为对比)
model_normal = polyReg(degree)  # 使用练习1的函数
model_normal.fit(X, y)

获取和分析正则化后的系数展示了两种方法的不同效果:

# 从Pipeline中提取系数
coef_ridge = model_ridge.named_steps['regression'].coef_
coef_lasso = model_lasso.named_steps['regression'].coef_

# Ridge:所有系数都非零但被压缩
print(f"Ridge系数的L2范数: {np.linalg.norm(coef_ridge):.4f}")
print(f"Ridge非零系数个数: {np.sum(np.abs(coef_ridge) > 1e-10)}/11")

# Lasso:产生稀疏解
print(f"Lasso系数的L1范数: {np.linalg.norm(coef_lasso, 1):.4f}")
print(f"Lasso非零系数个数: {np.sum(np.abs(coef_lasso) > 1e-10)}/11")

# 显示Lasso的稀疏性
for i, coef in enumerate(coef_lasso[:11]):
    if abs(coef) < 1e-10:
        print(f"  x^{i}: 系数被压缩到0")
    else:
        print(f"  x^{i}: 系数 = {coef:.4f}")

named_steps['regression'] 访问Pipeline中的回归器对象。使用 1e-10 作为阈值判断系数是否为0,因为数值计算的精度限制。

实验结果清晰地展示了两种正则化方法的不同效果。Ridge回归产生了一条平滑的曲线,虽然没有完美拟合训练数据,但避免了剧烈的振荡。所有参数都被压缩但保持非零,这反映了L2惩罚的特性:它倾向于将参数均匀地缩小。

Lasso回归不仅产生了平滑的拟合曲线,还实现了自动的特征选择。通过将某些高次项的系数压缩到精确的0,Lasso有效地降低了模型的实际复杂度。这种稀疏性在高维问题中特别有价值,因为它提供了可解释的模型。Lasso的稀疏性通过零系数的数量直观地体现出来。

与无正则化的10次多项式相比,两种正则化方法都成功地控制了过拟合。普通多项式回归产生的曲线在训练点之间有剧烈的振荡,这是典型的过拟合表现。正则化通过限制模型的复杂度,在拟合度和平滑性之间找到了更好的平衡。

理论问题:损失函数缩放因子的影响

Task 1要求思考Ridge和Lasso中不同缩放因子对结果的影响。Ridge使用 |\underline{y}-\underline{X}\beta|^2,而Lasso使用 \frac{1}{2n}|\underline{y}-\underline{X}\beta|^2。这种差异意味着:

  1. 对于相同的 \lambda 值,Lasso的经验风险项权重更小,正则化项相对更重要
  2. 在选择超参数时,Lasso通常需要比Ridge更小的 \lambda 值来达到相似的正则化效果
  3. 在交叉验证中,这种缩放差异不影响最优模型的选择(因为是相对比较),但影响最优 \lambda 的绝对值

超参数选择的完整工作流

最后的任务模拟了真实的机器学习工作流程。使用75个样本的数据集,通过train_test_split分割出50个训练样本和25个测试样本。测试集被严格保留,只在最终评估时使用,这遵循了机器学习的基本原则。

# 生成更大的数据集
np.random.seed(120)  # 设置随机种子
n = 75  # 总样本数
X_full, y_full = generateData(n)  # 生成75个样本

# 步骤1:分割数据集
X_train, X_test, y_train, y_test = train_test_split(
    X_full, y_full,  # 输入数据
    test_size=25,    # 测试集大小(25个样本)
    random_state=42  # 随机种子,保证结果可重现
)

print(f"训练集大小: {X_train.shape[0]} 样本")
print(f"测试集大小: {X_test.shape[0]} 样本")

train_test_split 确保测试集完全独立,这是机器学习的基本原则。测试集被严格保留,只在最终评估时使用。

对于超参数 \lambda 的选择,使用对数空间的网格搜索:Ridge的范围是 [10^{-1}, 10^2],Lasso的范围是 [10^{-3}, 10^0]。使用对数空间是因为正则化参数的影响通常是指数级的,线性空间的搜索可能错过最优值。两种方法使用不同范围是因为损失函数的缩放不同。

# 步骤2:为Ridge和Lasso设置超参数候选值
# Ridge的λ范围
ridge_lambdas = np.logspace(-1, 2, 15)  # 从0.1到100,15个值
# Lasso的λ范围
lasso_lambdas = np.logspace(-3, 0, 15)  # 从0.001到1,15个值

# 步骤3:使用GridSearchCV进行超参数选择
from sklearn.model_selection import GridSearchCV

# Ridge的交叉验证
ridge_model = regularizedPolyReg(10, 'ridge', None)  # λ稍后由GridSearch设置
ridge_param_grid = {'regression__alpha': ridge_lambdas}

ridge_cv = GridSearchCV(
    ridge_model,
    ridge_param_grid,
    cv=10,  # 10折交叉验证
    scoring='neg_mean_squared_error',
    n_jobs=-1  # 使用所有CPU核心加速
)
ridge_cv.fit(X_train, y_train)

# Lasso的交叉验证
lasso_model = regularizedPolyReg(10, 'lasso', None)
lasso_param_grid = {'regression__alpha': lasso_lambdas}

lasso_cv = GridSearchCV(
    lasso_model,
    lasso_param_grid,
    cv=10,  # 10折交叉验证
    scoring='neg_mean_squared_error',
    n_jobs=-1
)
lasso_cv.fit(X_train, y_train)

np.logspace 生成对数均匀分布的网格点,这对于正则化参数很重要,因为其影响通常是指数级的。使用对数空间是因为正则化参数的影响通常是指数级的,线性空间的搜索可能错过最优值。两种方法使用不同范围是因为损失函数的缩放不同。n_jobs=-1 利用所有CPU核心并行计算,显著加速网格搜索。

10折交叉验证在训练集上进行,每折使用45个样本训练,5个样本验证。这比留一法计算效率更高,同时仍能提供可靠的性能估计。GridSearchCV自动完成了整个流程:对每个 \lambda 值进行10折CV,计算平均性能,选择最佳参数。

# 步骤4:评估最佳模型在测试集上的表现
# Ridge的最佳模型
best_ridge_lambda = ridge_cv.best_params_['regression__alpha']
ridge_train_score = -ridge_cv.best_score_  # 转换回正的MSE
ridge_test_pred = ridge_cv.predict(X_test)
ridge_test_score = mean_squared_error(y_test, ridge_test_pred)

# Lasso的最佳模型
best_lasso_lambda = lasso_cv.best_params_['regression__alpha']
lasso_train_score = -lasso_cv.best_score_
lasso_test_pred = lasso_cv.predict(X_test)
lasso_test_score = mean_squared_error(y_test, lasso_test_pred)

# 比较分析
if ridge_test_score < lasso_test_score:
    print(f"Ridge表现更好,测试误差低 {(lasso_test_score - ridge_test_score):.4f}")
else:
    print(f"Lasso表现更好,测试误差低 {(ridge_test_score - lasso_test_score):.4f}")

最终在独立测试集上的评估提供了模型真实性能的无偏估计。交叉验证曲线展示了典型的U型或L型模式。当 \lambda 太小时,正则化不足,模型过拟合;当 \lambda 太大时,正则化过度,模型欠拟合。最佳的 \lambda 值在两者之间取得平衡。误差带(通过标准差计算)反映了性能估计的不确定性,这对于判断不同 \lambda 值之间是否有显著差异很重要。

最终在独立测试集上的评估提供了模型真实性能的无偏估计。比较Ridge和Lasso的测试误差可以帮助选择最适合特定问题的正则化方法。通常Ridge在预测精度上略好,而Lasso在需要模型解释性时更有优势,因为它能自动进行特征选择,产生稀疏的模型。


评论