teach.pascalyim.com
目录

ML · 章节 2

机器学习 2 —— 回归

在 Kaggle 上打开

课程章节 · 中央理工里尔学院 · Pascal Yim · 2026 年

上一章我们搭建了 Python 数据科学的工具链,从这一章开始,我们正式踏入监督学习的世界。回归 (regression) 是最古老、也最具直觉性的预测任务:给定一些输入变量,我们希望预测一个连续的数值——房屋的价格、鲍鱼的年龄、汽车的二氧化碳排放量。本章将以最小二乘法为起点,逐步走向多元回归、模型评估、非线性扩展、最近邻方法,并最终汇聚到 scikit-learn 的 Pipeline 这一工程化抽象。

1. 最小二乘线性回归

1.1 寻找最佳直线

设我们手头有一组观测点 (xi,yi)(x_i, y_i),i=1,,ni = 1, \dots, n。最简单的预测假设是:yyxx 之间存在线性关系。我们用一条直线

y^=ax+b\hat{y} = a x + b

去逼近数据。其中 aa 是斜率,bb 是截距。如何挑选这两个参数,才能让这条直线"最贴合"散点云呢?

最自然的回答来自 19 世纪初的勒让德 (Legendre) 与高斯 (Gauss):最小化预测值与真实值之间误差的平方和。对每一个观测点,残差为

ei=yi(axi+b),e_i = y_i - (a x_i + b),

我们希望找到使代价函数

J(a,b)=i=1n(yi(axi+b))2J(a, b) = \sum_{i=1}^{n} \bigl(y_i - (a x_i + b)\bigr)^2

达到最小值的 (a,b)(a, b)。这就是 最小二乘法 (Ordinary Least Squares, OLS)。

关键点:为什么是平方而不是绝对值?平方让代价函数处处可导,从而可以用微积分求显式解;同时它对大误差的"惩罚"更重,使模型尽量避免出现严重偏离的预测。

1.2 解析解的推导

由于 J(a,b)J(a, b) 是关于 a,ba, b 的二次函数,它的最小值在两个偏导数同时为零的点取得。先对 bb 求导:

Jb=2i=1n(yiaxib)=0.\frac{\partial J}{\partial b} = -2 \sum_{i=1}^{n} \bigl(y_i - a x_i - b\bigr) = 0.

整理后两边除以 nn,即得

b=yˉaxˉ,b = \bar{y} - a \bar{x},

其中 xˉ\bar{x}yˉ\bar{y} 分别是 xxyy 的样本均值。这条结果具有很优美的几何意义:回归直线必然经过样本的重心 (xˉ,yˉ)(\bar{x}, \bar{y})

接着对 aa 求导,并把上面的 bb 代回去,经过简单整理得到

a=i=1n(xixˉ)(yiyˉ)i=1n(xixˉ)2=Cov(x,y)Var(x).a = \frac{\sum_{i=1}^n (x_i - \bar{x})(y_i - \bar{y})}{\sum_{i=1}^n (x_i - \bar{x})^2} = \frac{\mathrm{Cov}(x, y)}{\mathrm{Var}(x)}.

关键点:斜率 aa 等于 xxyy协方差除以 xx方差。当 xx 的波动越能"带动" yy 一起变化,斜率就越大。

1.3 用 NumPy 手工实现

理解了公式,我们就可以脱离任何机器学习框架,自己实现一个最小二乘回归器:

class MyLinearRegression: def __init__(self): self.a = None self.b = None def fit(self, x, y): x = np.array(x) y = np.array(y) x_mean = np.mean(x) y_mean = np.mean(y) numerator = np.sum((x - x_mean) * (y - y_mean)) denominator = np.sum((x - x_mean) ** 2) self.a = numerator / denominator self.b = y_mean - self.a * x_mean def predict(self, x): x = np.array(x) return self.a * x + self.b

这段代码刻意模仿了 scikit-learn 的接口规范——fit 用于学习参数,predict 用于产生预测。这样的接口约定在 Python 机器学习生态中已被普遍接受,后续我们调用任何 sklearn 的回归器,都会发现这一对方法。

1.4 残差分析

模型拟合完毕后,绝不能止步于一个数字。残差

ri=yiy^ir_i = y_i - \hat{y}_i

蕴含了模型在每个样本点上的预测误差,把它画出来往往能揭示出比指标本身更深刻的问题。一个良好的线性模型,残差应当:

  • 在零附近随机分布,看不出明显的趋势线;
  • 方差大致保持恒定,不随 xx 增大而扩张;
  • 没有局部聚集的"团簇"。

如果残差呈现弯曲的弧形,这往往意味着真实关系是非线性的;如果残差在 xx 较大时方差陡增,则提示存在异方差性 (heteroscedasticity)。在汽车排放数据集 co2_mini 上,残差通常会显示一条非常轻微的弧度,这与发动机效率随排量增加而递减的物理现象一致——线性模型已经做得不错,但还有改进的空间。

绘制残差图的代码相当简洁:

residus = y - y_hat fig = px.scatter( x=x, y=residus, labels={'x': '消耗量', 'y': '残差'}, title='线性回归残差图', ) fig.add_hline(y=0) fig.show()

观察残差应当成为每一次训练完模型后的条件反射。一个仅靠 R2R^2 数字看起来不错的模型,可能在残差图上暴露出系统性偏差;反之,一个 R2R^2 平庸的模型,如果残差均匀干净,也许只是因为数据本身噪声大,模型已经做到极限了。

2. 评价回归质量的指标

2.1 误差类指标

有了预测值 y^i\hat{y}_i,我们希望用一个标量数字来概括"模型整体偏差有多大"。常见的回归指标如下:

MAE — 平均绝对误差

MAE=1ni=1nyiy^i.\mathrm{MAE} = \frac{1}{n} \sum_{i=1}^n |y_i - \hat{y}_i|.

它的单位与 yy 一致,对大偏差的反应较为温和。

MSE — 均方误差

MSE=1ni=1n(yiy^i)2.\mathrm{MSE} = \frac{1}{n} \sum_{i=1}^n (y_i - \hat{y}_i)^2.

平方使得大误差被严重放大,因此 MSE 会"偏爱"那些尽可能避免离谱预测的模型。

RMSE — 均方根误差

RMSE=MSE.\mathrm{RMSE} = \sqrt{\mathrm{MSE}}.

开根号后单位回到 yy 的量纲,便于解释。

选择哪个指标,取决于业务语境。预测发电厂的输出功率时,我们最怕的是偶尔出现的大失误,因此应该用 RMSE;预测一个城市每个街区的房价中位数时,我们更关心典型误差有多大,MAE 更合适;若我们要把误差作为百分比汇报给非技术用户,MAPE 则一目了然。

MAPE — 平均绝对百分比误差

MAPE=1ni=1nyiy^iyi.\mathrm{MAPE} = \frac{1}{n} \sum_{i=1}^n \left| \frac{y_i - \hat{y}_i}{y_i} \right|.

它给出相对误差,对量级跨度大的目标(例如房价)很有意义,但当 yiy_i 接近零时会数值不稳。

关键点:MAE 与 RMSE 可能讲述不同的故事。如果它们相差甚远,通常意味着数据中存在少数严重的异常预测——RMSE 的平方放大了它们的影响。

2.2 决定系数 R2R^2

R2R^2 衡量模型相对于"始终预测均值"这个朴素基线的改进幅度:

R2=1SSresSStot,SSres=i(yiy^i)2,SStot=i(yiyˉ)2.R^2 = 1 - \frac{\mathrm{SS}_{\text{res}}}{\mathrm{SS}_{\text{tot}}}, \qquad \mathrm{SS}_{\text{res}} = \sum_i (y_i - \hat{y}_i)^2, \qquad \mathrm{SS}_{\text{tot}} = \sum_i (y_i - \bar{y})^2.

它的取值含义直观:

  • R2=1R^2 = 1:模型完美解释了所有方差;
  • R2=0R^2 = 0:模型与"永远预测均值"等效;
  • R2<0R^2 < 0:模型表现比朴素基线还差——这种情况完全有可能,通常是过拟合或评估代码出错的信号。

关键点:一个高的 R2R^2 并不等同于良好的模型。它可能掩盖过拟合,也可能因数据本身固有的方差很小而虚高。务必联合多个指标和残差分析来做判断。

3. 用 sklearn 进行多元线性回归

3.1 从直线到超平面

当我们有两个解释变量 x1,x2x_1, x_2,模型变为

y^=a1x1+a2x2+b,\hat{y} = a_1 x_1 + a_2 x_2 + b,

在三维空间里它对应一个平面。推广到 nn 个特征:

y^=b+j=1najxj.\hat{y} = b + \sum_{j=1}^n a_j x_j.

将所有样本组织成矩阵 XRm×nX \in \mathbb{R}^{m \times n}、目标向量 yRmy \in \mathbb{R}^m,可以用矩阵形式紧凑地写出最小二乘解(若 XTXX^T X 可逆):

a^=(XTX)1XTy.\hat{a} = (X^T X)^{-1} X^T y.

实践中,scikit-learn 并不真的去显式求逆,而是调用数值更稳定的最小二乘求解器(基于 SVD 或 QR 分解),这样即使 XTXX^T X 病态也能给出可信的系数。

3.2 sklearn 接口实战

我们以经典的 abalone_mini 数据集为例。鲍鱼是一种海贝,贝壳上的环数 (Rings) 与年龄成正比。任务是用物理测量(Length, Diameter, Height, Weight)预测 Rings

from sklearn.linear_model import LinearRegression X = df[['Length', 'Weight']] y = df['Rings'] model = LinearRegression() model.fit(X, y) y_hat = model.predict(X) print(model.coef_, model.intercept_)

注意几个工程细节:

  1. X 必须是二维的——即便只有一个特征,也要写 df[['Length']] 而不是 df['Length'];
  2. model.coef_ 是系数向量 (a1,a2)(a_1, a_2),model.intercept_bb;
  3. predict 返回的形状与 y 一致;
  4. 若希望 sklearn 自动加截距,可在构造时传 fit_intercept=False;
  5. sklearn 内部使用的求解器是 scipy.linalg.lstsq,它对秩亏 (rank-deficient) 的设计矩阵也能给出最小范数解。

关键点:多元回归的系数 aja_j 表示在固定其他变量的前提下,xjx_j 每变化一个单位时 y^\hat{y} 的变化。这正是它相对于一系列单变量回归的核心价值——分离出每个因子的"净效应"。

4. 训练集与测试集

4.1 为什么必须留出测试集?

把模型在训练数据上的表现视为它的"真实水准",几乎总是会得到一个乐观且具有误导性的估计。这就像让学生把考试题就拿来当复习题:成绩好不代表他真的理解了知识。

机器学习中,我们关心的是泛化能力 (generalization):模型在未见数据上的表现。为此,我们将原始数据划分为不相交的两部分:

  • 训练集 (X_train, y_train):用于拟合参数;
  • 测试集 (X_test, y_test):仅用于最后的评估,不能参与任何训练决策。

4.2 train_test_split

from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, )

参数解释:

  • test_size=0.2:保留 20% 作为测试集;
  • random_state=42:固定随机种子,保证可复现。

随后训练与评估流程一气呵成:

model = LinearRegression() model.fit(X_train, y_train) y_hat_test = model.predict(X_test)

关键点:永远在测试集上报告最终指标。如果你同时在训练集与测试集上算 MAE,差距过大就提示过拟合;两者都很差则提示欠拟合

5. 交叉验证

5.1 单次划分的局限

如果不固定 random_state,每次执行 train_test_split 都会得到不同的训练/测试切分。运行三次,可能得到三个差异显著的 R2R^2 值。这种波动说明:单次划分得到的指标对随机抽签高度敏感,不能算作模型性能的可靠估计。

5.2 kk 折交叉验证

kk 折交叉验证 (k-fold cross-validation) 给出了更稳健的方案:

  1. 将数据集均匀切成 kk 份(称作 "fold");
  2. 每次轮流让其中 1 份作为测试集,其余 k1k-1 份作为训练集;
  3. 重复 kk 次,得到 kk 个性能分数;
  4. 报告它们的均值(总体水平)与标准差(稳定性)。

k=5k = 5 时,每一个样本都恰好被用于评估一次,被用于训练四次。这种"全员参与"的特性,使交叉验证给出的指标比单次训练/测试划分更具有代表性。

kk 的取值通常在 5 到 10 之间。kk 越大,每折训练集越大,因此每一次训练出的模型越接近用全部数据训练的"真实"模型,但计算成本随之线性增长。极端情况是 k=nk = n,称作留一交叉验证 (Leave-One-Out, LOO):每次只留出一个样本测试。LOO 的偏差最低,但方差大,且在 nn 很大时计算上不可承受。

from sklearn.model_selection import cross_val_score scores = cross_val_score(model, X, y, cv=5, scoring='r2') print(scores.mean(), scores.std())

scoring 参数指定评价指标。回归中常用的有 'r2''neg_mean_absolute_error''neg_mean_squared_error''neg_root_mean_squared_error''neg_mean_absolute_percentage_error'

关键点:误差类指标在 sklearn 中以负号形式返回。这是因为 cross_val_score 内部约定"分数越大越好",而误差是越小越好。要看真正的 MAE,只需 mae = -scores

6. 多项式回归

6.1 当线性假设失效

线性模型 y^=ax+b\hat{y} = a x + b 只能捕捉直线关系。但许多物理量并非线性——例如鲍鱼的重量长度之间近似服从立方关系(因为重量正比于体积)。

引入非线性的一个朴素却高效的办法,是手工构造多项式特征:

y^=β0+β1x+β2x2+β3x3.\hat{y} = \beta_0 + \beta_1 x + \beta_2 x^2 + \beta_3 x^3.

注意,虽然 y^\hat{y} 关于 xx 是非线性的,但关于参数 β\beta 仍然是线性的。因此我们仍可直接套用线性回归来求解!

6.2 实现

X = df[['Length']].copy() X['Length2'] = X['Length'] ** 2 X['Length3'] = X['Length'] ** 3 model = LinearRegression() model.fit(X, y)

或者使用 PolynomialFeatures 自动生成。多项式阶数越高,拟合训练集越好,但过拟合的风险也越大——这正是后续我们要谈正则化和模型选择的伏笔。

关键点:多项式回归是一个绝佳的"特征工程"案例。我们没有改变模型的本质(仍是线性回归),只是把领域知识(重量与长度的立方关系)以新特征的形式注入到了 XX 中。机器学习实践中,优秀的特征往往比复杂的模型更重要——这条原则贯穿整个工程师的职业生涯。

7. k 近邻回归

7.1 思想

k 近邻 (k-Nearest Neighbors, k-NN) 是一种与线性模型截然不同的范式。它不学习任何全局公式,而是直接保存训练集。要预测新样本 xx 时,它做三件事:

  1. 计算 xx 与训练集中每个样本的距离;
  2. 挑出距离最小的 kk 个邻居 Nk(x)\mathcal{N}_k(x);
  3. 取这些邻居目标值的均值作为预测:
y^(x)=1kiNk(x)yi.\hat{y}(x) = \frac{1}{k} \sum_{i \in \mathcal{N}_k(x)} y_i.

7.2 距离的选择

默认采用 闵可夫斯基距离:

d(x,x)=(j=1pxjxjp)1/p.d(x, x') = \left( \sum_{j=1}^{p} |x_j - x'_j|^p \right)^{1/p}.

p=2p=2 即欧几里得距离,p=1p=1 即曼哈顿距离。

from sklearn.neighbors import KNeighborsRegressor model = KNeighborsRegressor(n_neighbors=5) model.fit(X_train, y_train) y_hat = model.predict(X_test)

参数 n_neighbors 是核心超参数:

  • kk 太小(如 k=1k=1):模型对噪声极度敏感,方差大;
  • kk 太大:预测被远处样本"稀释",变成全局均值,偏差大

关键点:k-NN 没有真正的"训练阶段"——所有计算都推迟到预测时。这种惰性学习 (lazy learning) 在小数据上效果惊人,但在大数据上预测代价很高。

8. 标准化:k-NN 的生死线

k-NN 的全部行为都基于距离。如果两个特征的量纲悬殊——比如房屋面积 sqft_living 在 300 到 4000 之间,而 bathrooms 仅在 1 到 5 之间——那么面积上的差异会完全压垮浴室数的差异。算法实际上变成了只用面积来判断邻居,其他变量形同虚设。

8.1 三种常用缩放器

StandardScaler:中心化并按标准差缩放,

xscaled=xμσ.x_{\text{scaled}} = \frac{x - \mu}{\sigma}.

MinMaxScaler:线性映射到 [0,1][0, 1]

RobustScaler:使用中位数与四分位距,对离群值鲁棒

from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)

关键点:fit_transform 只在训练集上调用,transform 用同一个缩放器作用于测试集。任何在测试集上重新拟合缩放器的做法都会导致数据泄漏 (data leakage),让你高估模型性能。

9. Pipeline:一条龙的工程抽象

把"标准化 + 模型"两个步骤手工拼接,代码繁琐且容易出错——尤其在交叉验证中,你需要在每一个 fold 内部重新拟合缩放器。scikit-learn 的 Pipeline 优雅地解决了这个问题:

from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsRegressor pipe = Pipeline([ ('scaler', StandardScaler()), ('model', KNeighborsRegressor(n_neighbors=5)), ]) pipe.fit(X_train, y_train) y_hat = pipe.predict(X_test)

调用 pipe.fit(X_train, y_train) 时,内部按顺序执行:

  1. StandardScalerX_train 上学习 μ,σ\mu, \sigma 并对其变换;
  2. KNeighborsRegressor 在变换后的数据上训练。

调用 pipe.predict(X_test) 时,自动用训练时学到的 μ,σ\mu, \sigma 来变换 X_test,然后送给 k-NN。

Pipeline 还能与 cross_val_score 完美组合——每个 fold 内部缩放器都会被重新拟合,从根本上杜绝数据泄漏

关键点:写机器学习代码时,只要预处理步骤不平凡,就用 Pipeline。它是把"研究脚本"升级为"生产代码"的关键一步。

练习

  1. 手工实现:在 co2_mini 数据集(燃料消耗 vs CO₂ 排放)上,用本章的 MyLinearRegression 类拟合一条直线,绘制散点图与回归线,并画出残差。残差是否表现出非随机的模式?

  2. 多元回归:在 abalone_mini 数据集上,用 LengthWeight 两个变量预测 Rings。报告 coef_intercept_,并解释每个系数的物理含义。

  3. 训练/测试:对练习 2 重新做 train_test_split 划分(test_size=0.2)。比较训练集与测试集上的 MAE 和 R2R^2,讨论模型是否过拟合。

  4. 交叉验证:用 5 折交叉验证(scoring='neg_mean_absolute_percentage_error')在 abalone_mini 上评估线性回归。报告均值与标准差,并解释负号的含义。

  5. 多项式拟合:用 LengthLength^2Length^3 预测 Weight。多项式回归相对于简单线性回归的提升有多大?把阶数加到 10 阶,会出现什么现象?

  6. k-NN:在 house_mini 数据集上,不做标准化和 StandardScaler 标准化,分别用 k-NN(n_neighbors=5)预测 price。两者 RMSE 相差多少?为什么?

  7. Pipeline 综合:把练习 6 重写为一个 Pipeline,并用交叉验证比较 LinearRegression 与 k-NN 在 house_mini 上的表现。哪种模型更适合这个任务?

拓展阅读