Skip to content

s04 偏差-方差权衡 — exercise.py 练习指南

Download exercise.py

练习目标

通过补全正则化梯度计算、K-Fold 交叉验证和 Bias-Variance 分解三个模块,从代码层面理解机器学习泛化理论的核心实践。

预备知识

在开始练习前,确保你已经理解了以下概念(参见 demo.py 代码详解 中的详细解释):

  • L1/L2 正则化的损失函数形式:JRidge=MSE+λθ22JLasso=MSE+λθ1
  • L2 正则化梯度:(λθj2)/θj=2λθj
  • L1 正则化梯度:(λθj)/θj=λsign(θj)
  • 偏置项不参与正则化:正则化的目标是约束模型复杂度(特征系数),偏置只影响截距
  • K-Fold 交叉验证的原理:K 份数据轮流做验证集,其余做训练集
  • Bias-Variance 分解公式:E[(yf^)2]=Bias[f^]2+Var[f^]+σ2

任务清单

任务1:实现带正则化的梯度计算 compute_regularized_gradient(X, y, w, lambda_, reg_type)

  • 用到的公式
    • MSE 梯度:MSEw=2nXT(Xwy)
    • L2 正则化梯度:(λwj2)wj=2λwj
    • L1 正则化梯度:(λ|wj|)wj=λsign(wj)
    • 总梯度 = MSE 梯度 + 正则化梯度
  • 实现步骤
    1. 计算 MSE 梯度 dw_mse = (2/n) * X.T @ (X @ w - y)
    2. 创建全零的正则化梯度数组 dw_reg = np.zeros(len(w))
    3. 根据 reg_type 填充 dw_reg[1:](跳过索引 0 即偏置项):
      • 'l2'dw_reg[1:] = 2 * lambda_ * w[1:]
      • 'l1'dw_reg[1:] = lambda_ * np.sign(w[1:])
    4. 返回 dw_mse + dw_reg
  • 需要调用的函数@ 运算符(矩阵乘法)、np.sign()np.zeros()
  • 关键细节w[1:] 跳过偏置项(索引 0),dw_reg[1:] 也只填充非偏置位置

任务2:实现 K-Fold 交叉验证 kfold_cross_validation(X, y, k, degree)

  • 算法流程
    1. 计算每折大小 fold_size = n // k
    2. i=0,1,,k1
      • 确定验证集起止索引:val_start = i * fold_size, val_end = (i+1) * fold_size(最后一折到末尾)
      • 验证集索引:val_idx = np.arange(val_start, val_end)
      • 训练集索引:使用 np.setdiff1d(np.arange(n), val_idx) 排除验证集索引
      • 划分数据:X_train = X[train_idx], y_train = y[train_idx]
      • 生成多项式特征并训练(使用正规方程或 np.linalg.pinv
      • 验证集预测并计算 MSE
    3. 返回平均 MSE 和所有折的 MSE 列表
  • 需要调用的函数np.arange()np.setdiff1d()polynomial_features()np.linalg.pinv()np.mean()
  • 期望输出val_mses 列表应有恰好 k 个元素

任务3(Bonus):实现 Bias-Variance 的经验估计 compute_bias_variance(X_test, y_true, n_trials, degree, noise_std)

  • 算法
    1. 生成 M 个不同的训练集(同一组 X 但每次加上新的随机噪声)
    2. 对每个训练集训练一个模型,记录对测试集的预测
    3. 计算所有模型预测的均值 E[f^(x)]、偏差 (E[f^]f)2、方差 Var(f^)
  • 实现步骤
    1. 循环 n_trials 次,每次:生成含噪声的 y,用正规方程训练模型,记录对 Xtest 的预测
    2. 计算 mean_preds = np.mean(predictions, axis=0)(所有模型在各测试点的平均预测)
    3. bias_sq = np.mean((mean_preds - y_true) ** 2)(平均预测与真实值的平方差)
    4. variance = np.mean(np.var(predictions, axis=0))(各测试点上预测的方差再平均)
  • 直觉理解
    • Bias 高 = 模型太简单,平均预测偏离真实函数
    • Variance 高 = 模型太复杂,对不同的噪声采样得到差异很大的模型

验证标准

运行 python exercise.py

  1. test_l2_gradient():偏置梯度 dw[1] 不应受正则化影响(因为偏置被排除在正则化之外)
  2. test_kfold():应生成恰好 5 个验证误差值
  3. test_bias_variance()(Bonus):Bias² 和 Variance 均为正值,预测形状为 (n_trials, n_test)

完整代码

py
# -*- coding: utf-8 -*-
"""
===============================================================================
s04_bias_variance/code/exercise.py — 过拟合、正则化与 Bias-Variance 练习
===============================================================================
本练习文件中,你需要完成以下任务:

练习目标:
  1. 实现 L2 正则化的梯度计算
  2. 实现 K-Fold 交叉验证的数据划分
  3. 通过多次训练计算 Bias² 和 Variance 的分解(Bonus)

提示:
  - L2 正则化梯度: ∂(λ·Σwⱼ²)/∂wⱼ = 2λ·wⱼ(偏置项不参与正则化)
  - L1 正则化梯度: ∂(λ·Σ|wⱼ|)/∂wⱼ = λ·sign(wⱼ)
  - 总梯度 = MSE 梯度 + 正则化梯度
  - K-Fold: 将数据分成 K 等份,每次用 1 份验证,K-1 份训练

运行方式:
  python exercise.py
===============================================================================
"""

import numpy as np
import matplotlib.pyplot as plt
import matplotlib
# 中文字体配置
matplotlib.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'DejaVu Sans']
matplotlib.rcParams['axes.unicode_minus'] = False  # 修复负号显示


# ============================================================================
# 辅助函数(已实现,可直接使用)
# ============================================================================

def generate_sine_data(n_samples=80, noise_std=0.3, random_seed=42):
    """生成 y = sin(2πx) + noise 的数据。"""
    np.random.seed(random_seed)
    X = np.random.uniform(0, 1, n_samples)
    X = np.sort(X)
    y = np.sin(2 * np.pi * X) + np.random.randn(n_samples) * noise_std
    return X, y


def polynomial_features(X, degree):
    """将一维特征 X 扩展为多项式特征矩阵 [1, x, x², ..., x^{degree}]。"""
    X = X.reshape(-1, 1)
    return np.hstack([X ** d for d in range(degree + 1)])


# ======================================================================
# TODO 1: 实现 L2 正则化的梯度
# ======================================================================
# 带 L2 正则化的损失函数:
#   J(w) = MSE + λ * Σ wⱼ²
#
# 总梯度:
#   ∂J/∂w = ∂(MSE)/∂w + ∂(λ·Σ wⱼ²)/∂w
#         = (2/n) * X^T @ (Xw - y) + 2λ * w
#
# 注意:偏置项(w[0])通常不参与正则化!
# 因为偏置不反映模型复杂度,只影响截距。
# 所以对于 w[0],正则化梯度为 0。
#
# 提示:
#   - 先计算 MSE 部分的梯度: dw_mse = (2/n) * X.T @ (Xw - y)
#   - 再计算 L2 正则化梯度: dw_l2 = 2 * lambda_ * w
#   - 偏置项 dw_l2[0] = 0
#   - 返回 dw_mse + dw_l2
# ======================================================================

def compute_regularized_gradient(X, y, w, lambda_, reg_type='l2'):
    """
    计算带正则化的梯度。

    参数:
        X: np.ndarray, 特征矩阵 (n, d),包含常数项列
        y: np.ndarray, 目标值 (n,)
        w: np.ndarray, 当前权重 (d,)
        lambda_: float, 正则化强度 λ
        reg_type: str, 'l2' 或 'l1'

    返回:
        dw: np.ndarray, 总梯度 (d,)
    """
    n = len(y)

    # TODO: 实现带正则化的梯度计算
    # 步骤 1: 计算 MSE 部分的梯度 dw_mse = (2/n) * X^T @ (X @ w - y)
    # 步骤 2: 创建正则化梯度数组 dw_reg,初始化为零
    # 步骤 3: 根据 reg_type 填充 dw_reg[1:](跳过偏置项 w[0])
    #   - 如果是 'l2': dw_reg[1:] = 2 * lambda_ * w[1:]
    #   - 如果是 'l1': dw_reg[1:] = lambda_ * sign(w[1:])
    # 步骤 4: 返回 dw_mse + dw_reg
    pass  # <-- 替换为你的代码


# ======================================================================
# TODO 2: 实现 K-Fold 交叉验证的数据划分
# ======================================================================
# K-Fold 交叉验证的流程:
#   1. 将数据分成 K 等份(折)
#   2. 对于每次迭代 i = 0, 1, ..., K-1:
#      a. 第 i 份作为验证集
#      b. 其余 K-1 份作为训练集
#      c. 用训练集训练模型,用验证集评估
#   3. 返回 K 次评估结果的平均值
#
# 例如:n=100, k=5,每份 20 个样本
#   - 迭代 1: 验证=索引 0-19,   训练=索引 20-99
#   - 迭代 2: 验证=索引 20-39,  训练=索引 0-19, 40-99
#   - ...
#   - 迭代 5: 验证=索引 80-99,  训练=索引 0-79
# ======================================================================

def kfold_cross_validation(X, y, k=5, degree=3):
    """
    执行 K-Fold 交叉验证,返回平均验证 MSE。

    参数:
        X: np.ndarray, 特征 (n,)
        y: np.ndarray, 目标值 (n,)
        k: int, 折数
        degree: int, 多项式次数

    返回:
        avg_val_mse: float, K 次验证的平均 MSE
        val_mses: list, 每次验证的 MSE
    """
    n = len(X)
    fold_size = n // k  # 每折的基础大小
    val_mses = []  # 存储每次验证的 MSE

    # TODO: 实现 K-Fold 交叉验证
    # 伪代码:
    # for i in range(k):
    #     # 确定验证集的起止索引
    #     val_start = i * fold_size
    #     val_end = (i + 1) * fold_size if i < k - 1 else n
    #
    #     # 创建验证集索引和训练集索引
    #     val_idx = np.arange(val_start, val_end)
    #     # 训练集索引:0 到 n-1 中排除 val_idx
    #     # 提示:使用 np.where 或 np.setdiff1d
    #
    #     # 划分数据
    #     X_train, y_train = X[train_idx], y[train_idx]
    #     X_val, y_val = X[val_idx], y[val_idx]
    #
    #     # 生成多项式特征
    #     Phi_train = polynomial_features(X_train, degree)
    #     Phi_val = polynomial_features(X_val, degree)
    #
    #     # 使用正规方程训练
    #     theta = np.linalg.inv(Phi_train.T @ Phi_train) @ Phi_train.T @ y_train
    #
    #     # 验证集预测并计算 MSE
    #     y_pred = Phi_val @ theta
    #     val_mse = np.mean((y_pred - y_val) ** 2)
    #     val_mses.append(val_mse)

    pass  # <-- 替换为你的代码

    # 返回平均 MSE 和所有验证 MSE
    # avg_val_mse = np.mean(val_mses)
    # return avg_val_mse, val_mses


# ======================================================================
# TODO 3 (Bonus): 计算 Bias² 和 Variance 的分解
# ======================================================================
# 通过对多个训练集训练多个模型,可以经验性地估计 Bias² 和 Variance。
#
# 算法(经验性估计):
#   1. 生成 M 个不同的训练集(从真实分布中独立采样)
#   2. 对每个训练集训练一个模型 f̂_m(x)
#   3. 对每个测试点 x:
#      a. 计算 M 个模型的平均预测: E[f̂(x)] ≈ (1/M) Σ f̂_m(x)
#      b. Bias²(x) = (E[f̂(x)] - f(x))² — 平均预测与真实值的差距
#      c. Variance(x) = (1/M) Σ (f̂_m(x) - E[f̂(x)])² — 各模型预测的离散度
#   4. 对整个测试集取平均
#
# 这帮助我们理解: 高偏差 → 模型太简单, 高方差 → 模型太复杂
# ======================================================================

def compute_bias_variance(X_test, y_true, n_trials=100, degree=3,
                          noise_std=0.3):
    """
    通过多次训练来估计 Bias² 和 Variance。

    参数:
        X_test: np.ndarray, 测试集特征 (m,)
        y_true: np.ndarray, 测试集真实值 (m,),无噪声的 f(x)
        n_trials: int, 重复训练的次数
        degree: int, 多项式次数
        noise_std: float, 每次重采样时添加的噪声标准差

    返回:
        avg_bias_sq: float, 平均 Bias²
        avg_variance: float, 平均 Variance
        predictions: np.ndarray, (n_trials, m),所有模型的预测
    """
    n_test = len(X_test)
    predictions = np.zeros((n_trials, n_test))  # 存储 M 次训练的预测

    # TODO (Bonus): 实现 Bias-Variance 的经验估计
    # 伪代码:
    # for trial in range(n_trials):
    #     # 生成新的训练数据(基于真实函数 + 新噪声)
    #     y_noisy = y_true + noise_std * np.random.randn(n_test)
    #     # 训练模型
    #     Phi_test = polynomial_features(X_test, degree)
    #     theta = np.linalg.inv(Phi_test.T @ Phi_test) @ Phi_test.T @ y_noisy
    #     predictions[trial] = Phi_test @ theta
    #
    # # 计算每个测试点的平均预测
    # mean_preds = np.mean(predictions, axis=0)  # (m,)
    #
    # # 计算 Bias²: 平均预测与真实值的平方差
    # # bias_sq = np.mean((mean_preds - y_true) ** 2)
    #
    # # 计算 Variance: 各模型预测的方差
    # # variance = np.mean(np.var(predictions, axis=0))
    #
    # return bias_sq, variance, predictions

    pass  # <-- 替换为你的代码


# ============================================================================
# 测试代码
# ============================================================================

def test_l2_gradient():
    """测试 L2 正则化梯度的实现。"""
    print("--- 测试 L2 正则化梯度 ---")

    # 创建简单的测试数据
    X = np.array([[1, 1], [2, 1], [3, 1]])  # 含偏置列的全 1
    y = np.array([2, 4, 6])
    w = np.array([0.5, 0.5])  # [w1, bias]
    lambda_ = 0.1

    try:
        dw = compute_regularized_gradient(X, y, w, lambda_, reg_type='l2')
    except Exception as e:
        print(f"✗ 函数出错: {e}")
        return

    print(f"  总梯度 dw = {dw}")
    # 检查偏置项的梯度是否不包含正则化分量
    if abs(dw[1] - (-2.0)) < 1.0:
        print(f"  偏置梯度 dw[1] = {dw[1]:.4f} (L2 正则化不应影响偏置)")
    else:
        print(f"  偏置梯度 dw[1] = {dw[1]:.4f},请确认偏置未被正则化")
    print("  ✓ 测试通过(请手动验证梯度值是否合理)")


def test_kfold():
    """测试 K-Fold 交叉验证的实现。"""
    print("\n--- 测试 K-Fold 交叉验证 ---")

    X, y = generate_sine_data(n_samples=60, random_seed=0)

    try:
        avg_mse, val_mses = kfold_cross_validation(X, y, k=5, degree=3)
    except Exception as e:
        print(f"✗ 函数出错: {e}")
        return

    if len(val_mses) == 5:
        print(f"  ✓ 正确生成了 5 个验证误差: {[f'{m:.4f}' for m in val_mses]}")
        print(f"  ✓ 平均验证 MSE = {avg_mse:.4f}")
    else:
        print(f"  ✗ 期望 5 个验证误差,但得到了 {len(val_mses)} 个")


def test_bias_variance():
    """测试 Bias-Variance 分解的实现。"""
    print("\n--- 测试 Bias-Variance 分解 (Bonus) ---")

    X_test = np.linspace(0, 1, 50)
    y_true = np.sin(2 * np.pi * X_test)

    try:
        bias_sq, variance, predictions = compute_bias_variance(
            X_test, y_true, n_trials=30, degree=3, noise_std=0.3
        )
    except Exception as e:
        print(f"  (Bonus 未完成或出错: {e})")
        return

    print(f"  Bias² = {bias_sq:.6f}")
    print(f"  Variance = {variance:.6f}")
    print(f"  预测形状: {predictions.shape}")
    print(f"  ✓ Bias-Variance 分解完成!")


def main():
    """主函数:运行所有测试。"""
    print("=" * 60)
    print("过拟合与正则化练习 — 请完成代码中的 TODO 标记")
    print("=" * 60)

    test_l2_gradient()
    test_kfold()
    test_bias_variance()

    print("\n" + "=" * 60)
    print("练习结束!如果测试通过,你的实现基本正确。")
    print("=" * 60)


if __name__ == '__main__':
    main()