# 勤奋的凯尔森同学10000h刻意练习Day6:机器学习基础 —— 线性回归

所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。
tips: 文章末尾附有jupyter完整代码

今天正式进入机器学习。机器学习是AI的核心方法论,它让计算机从数据中学习规律,而不是靠人写死规则。NumPy和Pandas是它的数据底座,Matplotlib是它的可视化语言。今天的主角是线性回归(回归问题的入门):预测房价、销售额这类连续数值,核心是用最小二乘或梯度下降学出拟合数据的直线(高维时是超平面)。梯度下降更是整个机器学习的通用求解引擎,理解了它,Day7的逻辑回归、后面的神经网络和XGBoost都会一通百通。

# 1.1 机器学习概述

# 1.1.1 机器学习在学什么

传统编程是"人写规则,机器执行规则";机器学习是"人给数据和答案,机器自己学出规则"。

传统编程:  规则 + 数据 → 答案
机器学习:  数据 + 答案 → 规则(模型)
1
2

一个模型就是一个函数 ,输入特征 ,输出预测 。学习的过程就是调整函数的参数,让预测值尽量接近真实值。

# 1.1.2 三大范式

范式 数据特点 目标 典型任务
监督学习 有标签(输入+输出对) 学输入到输出的映射 分类、回归
无监督学习 只有输入,没有标签 发现数据内部结构 聚类、降维
强化学习 环境给奖励信号 学一个策略最大化长期奖励 游戏、机器人

本系列的主线是监督学习(分类 + 回归),Day10会覆盖无监督学习(聚类 + 降维)。

# 1.1.3 监督学习的两类任务

  • 回归(Regression):预测连续值。比如预测房价、温度、销售额。输出是实数。
  • 分类(Classification):预测离散类别。比如判断邮件是否垃圾、图片是猫还是狗。输出是类别标签。

线性回归是回归任务的入门;分类任务的入门则是Day7的主角——逻辑回归。两个名字都带"回归",任务却完全不同,到时候别搞混。

# 1.2 数学基础:梯度下降

梯度下降是几乎所有机器学习算法的求解引擎。理解它,Day7的逻辑回归、后面的神经网络、XGBoost就都通了。

# 1.2.1 损失函数:衡量预测错得多离谱

模型的预测 ,真实值是 。损失函数 衡量单次预测的误差,代价函数 是所有样本损失的平均。

线性回归用均方误差(MSE):

前面的 是为了求导后把平方的2约掉,不影响最优解。

# 1.2.2 梯度:下山最快的方向

梯度 是代价函数对每个参数的偏导数组成的向量,它指向函数值上升最快的方向。要找最小值,就朝负梯度方向走。

想象你站在山上,目标是走到山谷最低点。
梯度 = 当前位置最陡的上坡方向
负梯度 = 最陡的下坡方向
每走一步,重新看脚下最陡的下坡方向,继续走。
1
2
3
4

# 1.2.3 梯度下降的迭代公式

把"负梯度方向"和"每步走多大(学习率)"组合起来,参数的迭代更新规则就是:

  • :模型参数
  • :学习率(learning rate),控制每步走多大
  • :当前参数下的梯度

学习率的坑:

学习率 现象 后果
太大 一步跨过最低点,甚至越走越高 发散,损失不降反升
太小 每次只挪一点点 收敛极慢,可能卡在局部极小值
合适 稳步下降到谷底 快速收敛

# 1.2.4 用NumPy手推梯度下降

下面用一个完整的小例子 手推一遍梯度下降的全过程,把「预测 → 算误差 → 求梯度 → 更新参数」四步走通。

# 1. 设定数据与模型

取3个无噪声样本点(方便手算):

1 0 3
2 1 5
3 2 7

真实关系是 ,即最优参数 , 。我们假装不知道这个答案,用梯度下降去逼近。

模型:,样本数 。

# 2. 损失函数与梯度(偏导)推导

用均方误差(前面加 是为了求导后把平方的2约掉,不影响最优解):

对两个参数分别求偏导(链式法则:先对平方项求导得 ,再乘括号内对参数的导数, 里的2正好约掉):

直觉:(截距)的梯度是所有误差的平均;(斜率)的梯度是「误差乘以对应 」的平均—— 越大的样本,对斜率的修正力度越大。

# 3. 手动迭代(学习率 )

初始化参数:, 。

第0轮迭代:

预测值 :

误差
1 0 3 0 -3
2 1 5 0 -5
3 2 7 0 -7

计算梯度:

更新参数(朝负梯度方向走一步):

第1轮迭代(, ):

预测值 :

误差
1 0 3 0.500 -2.500
2 1 5 1.133 -3.867
3 2 7 1.767 -5.233

计算梯度:

更新参数:

# 4. 收敛趋势

两轮之后,参数从 走到 ,正在向真实值 靠近。误差在逐步缩小(梯度的绝对值也在变小),说明走的方向是对的。继续迭代足够多轮(比如1000次),参数会收敛到 , 。

下面的NumPy代码,就是把上面的手算过程向量化、循环化——用矩阵运算一次性算出所有样本的预测和梯度。

import numpy as np
import matplotlib.pyplot as plt

# 造数据: y = 2x + 3 + 噪声
np.random.seed(42)
X = 2 * np.random.rand(100, 1)
y = 2 * X + 3 + np.random.randn(100, 1) * 0.5

# 给X加一列偏置项x0=1,这样 θ0就是截距
X_b = np.c_[np.ones((100, 1)), X]   # shape (100, 2)

# 梯度下降
lr = 0.1  # 学习率
n_iter = 1000  # 迭代次数
m = 100  # 样本数

theta = np.random.randn(2, 1)  # 随机初始化参数 [θ0, θ1]

for i in range(n_iter):
    y_pred = X_b.dot(theta)  # ① 预测: X_b · θ, shape (100, 1)
    errors = y_pred - y  # ② 误差: ŷ - y, shape (100, 1)
    gradients = (2 / m) * X_b.T.dot(errors)  # ③ 梯度: (2/m)·X_bᵀ·误差, shape (2, 1)
    theta = theta - lr * gradients  # ④ 更新参数: θ ← θ - α·∇J

print("拟合参数 θ:", theta.ravel())
# 期望输出约 [3.0, 2.0],即截距≈3,斜率≈2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

**这行梯度代码到底在算什么?**下面把 gradients = (2 / m) * X_b.T.dot(errors) 拆透。

# 系数 从哪来

代码用的损失函数是没有 的MSE:

对 求导时,平方项求导带出一个 ,所以梯度系数是 :

对比前面手算部分用的是带 的损失,那里梯度系数是 。两种写法最优解完全相同(只差一个常数因子,学习率会自动吸收),只要损失和梯度对应就行。代码这里选 的损失只是写法习惯。

# 矩阵运算三步走(对照形状)

设样本数 ,特征数(含偏置列)。

步骤 代码 形状 数学含义
① 预测 y_pred = X_b.dot(theta) ,每个样本的预测值
② 误差 errors = y_pred - y ,每个样本的预测误差
③ 梯度 X_b.T.dot(errors) ,把误差按特征加权求和

第 ③ 步是关键。 的第一行全是1,第二行是所有样本的 :

所以 得到的两个分量,正好就是前面手算的两个偏导数的求和部分:

再乘上系数 ,就得到完整梯度向量:

  • 第一个分量 :所有误差的平均(乘2)→ 修正截距
  • 第二个分量 :误差加权 后的平均(乘2)→ 修正斜率, 越大的样本话语权越大

这样,一行矩阵代码就把100个样本、2个参数的梯度一次性算完了,等价于前面手算时对3个样本分别求和的过程,只是规模更大、用向量化加速。我读研的时候,研究方向就是机器学习、深度学习,我的导师丁老师特别喜欢这种数学推导,所以我写论文的时候推导了一大堆,所以他觉得我很有水平,还让我给师弟师妹们上课讲解。

我们可以做个图来看一下效果咋样。

plt.scatter(X, y, s=20, alpha=0.6, label='samples')  # 原始散点
xs = np.array([[0], [2]])  # 取x两端点画直线
plt.plot(xs, np.c_[np.ones((2, 1)), xs].dot(theta),
         'r-', lw=2, label=f'y = {theta[1,0]:.3f}x + {theta[0,0]:.3f}')
plt.xlabel('x'); plt.ylabel('y'); plt.legend(); plt.title('Linear Regression via Gradient Descent')
plt.show()
1
2
3
4
5
6

# 1.3 线性回归

前面用梯度下降一路求解的对象,正是这一节的主角——线性回归。它假设输入和输出之间是线性关系:

写成矩阵形式:,其中 第一列全是1(对应截距 )。

# 1.3.1 最小二乘法(闭式解)

对MSE求导并令其等于0,可以直接解出最优参数:

# 仍以y = 2x + 3为例,最小二乘法一步直接求解
theta_ls = np.linalg.inv(X_b.T.dot(X_b)).dot(X_b.T).dot(y)
print("最小二乘 θ:", theta_ls.ravel())
1
2
3

优点:一步到位,不用调学习率。 缺点:要算矩阵求逆,当特征数 很大时(), 的复杂度会很慢。

这个正规方程的推导过程我放在了单独的文件里,有需要的同学可自取。

# 1.3.2 两种方法对比

方法 适用场景 时间复杂度
最小二乘 特征数少(<1万)
梯度下降 特征数多、数据量大 ,k为迭代次数

# 1.3.3 特征缩放

如果不同特征的量纲差别极大(比如年龄0~100,收入0~100000),梯度下降会走"之"字形,收敛极慢。解决方法是标准化:

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # 每个特征均值0,方差1
1
2
3
4

经验法则:用梯度下降必须做特征缩放;用最小二乘可以不做(但做了数值更稳定)。

# 1.4 多项式回归

线性回归只能拟合直线,但现实数据常常是非线性的。多项式回归通过给原始特征添加高次幂来拟合曲线,本质上还是线性回归(对参数 是线性的)。

import numpy as np
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression

# ===== 1. 造数据:y = 0.5x² + x + 2 + 噪声 =====
np.random.seed(42)  # 固定随机种子,保证结果可复现
X = 6 * np.random.rand(100, 1) - 3  # 100行1列,x落在 [-3, 3)
y = 0.5 * X**2 + X + 2 + np.random.randn(100, 1) * 0.5  # 抛物线 + 高斯噪声

# ===== 2. 特征升维:[x] → [x, x²] =====
# 可以理解为x是第一个变量,x²是第二个变量,总共两个可变参数进行学习的过程
poly = PolynomialFeatures(degree=2,  # 最高2次项
                          include_bias=False)  # 不加全1列,截距交给模型自己算
X_poly = poly.fit_transform(X)                  # (100,1) → (100,2)

# ===== 3. 用线性回归拟合升维后的特征 =====
lin_reg = LinearRegression()
lin_reg.fit(X_poly, y)  # X_poly是特征矩阵,y是标签

print("系数:", lin_reg.coef_)  # ≈ [[1.0, 0.5]],分别对应x和x²
print("截距:", lin_reg.intercept_)              # ≈ [2.0]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 1.5 正则化:对抗过拟合

模型在训练集上表现极好,但在测试集上一塌糊涂——这就是过拟合。过拟合的本质是模型把训练数据里的噪声也学进去了,表现就是参数 变得很大、很敏感。

正则化的思路简单粗暴:既然参数太大会过拟合,那就在损失函数里加一个惩罚项,让"参数大"这件事本身也付出代价。这样最小化损失时,模型不得不在「拟合数据」和「保持参数小」之间做权衡。

正则化后的损失函数统一写成两项之和:

  • 拟合项MSE:,负责让模型在训练数据上预测准确
  • 惩罚项Penalty:只跟参数 有关,跟数据无关,负责压制参数的大小
  • :正则化强度,是需要手动调的超参数

的取值决定了两项的权衡:

取值 效果 后果
惩罚项消失,退化为普通线性回归 可能过拟合
适中 拟合数据的同时压制参数 缓解过拟合
惩罚项压倒一切,参数被迫全为0 退化成只预测均值,欠拟合

注意:求和下标是 到 ,不包含截距 。截距只是整体平移预测值,不影响模型复杂度,所以通常不对它正则化。

# 1.5.1 岭回归(Ridge / L2)

岭回归在MSE后面加的是参数的平方和:

逐项拆解:

  • :拟合项,让预测值贴近真实值
  • :所有系数的平方和(不含截距),参数越大,这一项越大
  • :控制惩罚力度, 越大,参数被压得越狠

为什么L2能让参数变小,但不会变成0?

看惩罚项对单个参数 的梯度(导数):

梯度的大小跟 成正比: 大时,惩罚力度大,把它往回拉;但当 已经很小时,梯度也趋近于0,拉力变得微乎其微。所以L2会把参数持续压缩、无限趋近于0,但几乎不会精确等于0。

几何直觉:L2的约束区域是一个圆(二维)或超球(高维)。MSE的等高线是椭圆,两个区域相切的点通常不会正好落在坐标轴上,所以参数不会被压到0。

from sklearn.linear_model import Ridge
ridge = Ridge(alpha=1.0)
ridge.fit(X_scaled, y)
print(ridge.score(X_scaled, y))
1
2
3
4

# 1.5.2 Lasso回归(Lasso / L1)

Lasso在MSE后面加的是参数的绝对值和:

逐项拆解:

  • :拟合项,同上
  • :所有系数的绝对值之和(不含截距),参数越大惩罚越重
  • :正则化强度

为什么L1能让某些参数直接变成0(稀疏性)?

这是L1和L2最本质的区别。看惩罚项对单个参数 的梯度:

关键在于:这个梯度的大小是恒定的 ,跟 本身多大无关。也就是说,不管 已经多小,惩罚项始终以固定的力度把它往0的方向推。当推力超过MSE想保留它的力度时, 就会被精确地推到0并停在那里(在0点处,L1的次梯度包含0,0是一个稳定的吸引点)。

结果就是:Lasso会把不重要的特征系数直接压成0,等价于自动做了特征选择——系数为0的特征等于被模型丢弃了。

几何直觉:L1的约束区域是一个菱形(二维)或十字形多面体(高维),它的顶点正好落在坐标轴上。MSE的椭圆等高线更可能在某个顶点处与菱形相切,而顶点意味着某些坐标为0,于是这些特征的系数就变成了0。

from sklearn.linear_model import Lasso
lasso = Lasso(alpha=0.1)
lasso.fit(X_scaled, y)
print("Lasso 系数:", lasso.coef_)  # 部分系数会是0
1
2
3
4

# 1.5.3 L1 vs L2

特性 L2(Ridge) L1(Lasso)
惩罚项 (平方和) $\sum
惩罚梯度 (随 减小而减弱) (恒定力度)
系数是否归零 不会,只会趋近于0 会,不重要的系数直接变0
特征选择 否 是(自动产生稀疏解)
解的唯一性 唯一 可能不唯一
适用场景 所有特征都有用,只想压缩幅度 特征多且疑似有冗余,想自动筛选

# 1.5.4 正则化的两个关键前提

① 必须先做特征缩放

正则化惩罚的是参数 的绝对值大小。如果特征之间量纲差异巨大(比如年龄0-100、收入0-1000000),同样的预测贡献下,量纲小的特征对应参数会很大,量纲大的特征参数反而很小。不缩放的话,正则化会不公平地重点惩罚量纲小的特征。所以使用Ridge/Lasso前必须用 StandardScaler 或 MinMaxScaler 把特征缩放到同一尺度。

② 正则化是在用偏差换方差(偏差-方差权衡)

加了惩罚项后,模型参数被限制住,表达能力下降,对训练数据的拟合会变差一点(偏差上升)。但同时模型不再能随意拟合噪声,对不同训练集的敏感度降低,泛化更稳定(方差下降)。正则化的目标就是找到一个 ,让「偏差的增加」换来「方差的减少」,总体测试误差最小。

# 1.6 sklearn线性回归实战——加州房价预测

# 1.6.1 数据集介绍

加州房价数据集(California Housing)来自1990年美国人口普查,共20640个街区(block group)样本,每个样本包含8个统计特征,目标值是该街区的房价中位数,单位是10万美元(取值范围0.15 ~ 5.0,最高的5.0是截断值,约4.8% 的样本被截在这里)。

特征 含义
MedInc 街区收入中位数(万美元)
HouseAge 房屋年龄中位数
AveRooms 平均房间数
AveBedrms 平均卧室数
Population 街区人口
AveOccup 平均家庭成员数
Latitude 纬度
Longitude 经度

这是一个非常经典的回归练手数据集:特征量纲差异巨大(人口上千、纬度只有几十),且房价与特征之间存在非线性关系和地理交互——正好用来检验前面学的特征缩放和正则化。

# 1.6.2 加载数据与快速EDA

from sklearn.datasets import fetch_california_housing

# as_frame=True直接返回pandas DataFrame,方便先看数据
bunch = fetch_california_housing(as_frame=True)
df = bunch.frame
print(df.head())
print(df.describe().round(2))
1
2
3
4
5
6
7
   MedInc  HouseAge  AveRooms  AveBedrms  Population  AveOccup  Latitude  Longitude  MedHouseVal
0  8.3252      41.0  6.984127   1.023810       322.0  2.555556     37.88    -122.23        4.526
1  8.3014      21.0  6.238137   0.971880      2401.0  2.109842     37.86    -122.22        3.585
2  7.2574      52.0  8.288136   1.073446       496.0  2.802260     37.85    -122.24        3.521
...
1
2
3
4
5

EDA时重点留意两件事:

  • 量纲差异极大:Population最大35682,而AveBedrms在1附近。不缩放的话梯度下降会走"之"字形,正则化也会不公平地惩罚不同特征。
  • 存在极端异常值:AveOccup(平均家庭成员数)最大达到1243,明显是特殊群体聚居区;这些离群点会拉偏线性模型。

# 1.6.3 划分数据集 + 特征缩放

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# 模型训练用ndarray形式
housing = fetch_california_housing()
X, y = housing.data, housing.target
print("数据形状:", X.shape)          # (20640, 8)

# 80% 训练 / 20% 测试,random_state保证每次划分一致
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# scaler只在训练集上fit,再transform测试集
# 否则测试集的均值/方差泄漏进预处理,评估结果虚高
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 1.6.4 训练三个模型并对比

from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import mean_squared_error, r2_score

models = {
    'LinearRegression': LinearRegression(),
    'Ridge(α=1.0)': Ridge(alpha=1.0),
    'Lasso(α=0.1)': Lasso(alpha=0.1),
}

for name, model in models.items():
    model.fit(X_train_scaled, y_train)
    y_pred = model.predict(X_test_scaled)
    mse = mean_squared_error(y_test, y_pred)
    rmse = np.sqrt(mse)  # RMSE和目标值同量纲,更好解释
    r2 = r2_score(y_test, y_pred)
    print(f"{name:20s} MSE={mse:.4f}  RMSE={rmse:.4f}  R²={r2:.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
LinearRegression     MSE=0.5559  RMSE=0.7456  R²=0.5758
Ridge(α=1.0)         MSE=0.5559  RMSE=0.7456  R²=0.5758
Lasso(α=0.1)         MSE=0.6796  RMSE=0.8244  R²=0.4814
1
2
3

结果解读:

  • RMSE ≈ 0.75:目标值单位是10万美元,所以模型预测一个街区的房价平均偏差约7.5万美元。RMSE比MSE更适合向业务方解释,因为它和预测目标同量纲。
  • R² ≈ 0.58:模型解释了测试集房价方差的58%。这个分数不算高,但不是代码有问题——线性模型只能学"直线/超平面"关系,而房价与位置、收入之间存在大量非线性交互,0.58基本就是纯线性模型在这个数据集上的天花板。提升要靠Day8的决策树和集成模型。
  • Ridge(α=1.0) 与普通回归结果几乎完全相同:样本数20640远大于特征数8(),数据量足够把8个参数估计得很稳,轻微的L2惩罚自然激不起什么水花。正则化的主战场是高维、小样本、特征共线的场景。
  • Lasso(α=0.1) 明显更差(R²=0.48):惩罚太狠了。但它做了一件Ridge做不到的事——把一部分特征系数直接压成了0。

# 残差分析:给模型做个体检

光看R² 和RMSE这两个汇总指标还不够——它们会把"系统性偏差"藏起来。比如模型对所有高价区都系统性低估,平均误差可能很小,但模型其实是错的。残差(residual) = 真实值 − 预测值()是逐样本的误差,把它画出来能看到汇总指标看不到的问题。

线性回归有几个经典假设:残差近似正态、零均值、同方差(方差随预测值不变)、相互独立。下面三张图分别检验这些假设,接着1.6.4训练好的 LinearRegression 来看(记 y_pred 为它在测试集上的预测):

import scipy.stats as stats

# 复用1.6.4训练好的LinearRegression
lin_reg = models['LinearRegression']
y_pred = lin_reg.predict(X_test_scaled)
residuals = y_test - y_pred  # 残差 = 真实值 - 预测值

plt.figure(figsize=(15, 4))

# ① 残差直方图:看是否近似正态。
# 把所有残差扔进40个"筐"(bins)里数个数,看看都堆在哪儿。中间高、两边低、左右对称的钟形,红虚线(均值)几乎压在0上,才算正常。
plt.subplot(1, 3, 1)
plt.hist(residuals, bins=40, edgecolor='k', alpha=0.7)
plt.axvline(residuals.mean(), color='r', linestyle='--',
           label=f'均值={residuals.mean():.3f}')
plt.xlabel('残差'); plt.ylabel('频数')
plt.title('① 残差直方图(看正态性)'); plt.legend()

# ② 残差vs预测值:看是否随机分布(同方差)
# 横轴是模型给出的预测值,纵轴是对应的残差,每个样本画一个点,再画一条y=0的红线。健康的图应该是一条上下均匀的"带子"——点像撒胡椒面一样,随机散在红线两侧,看不出任何形状。
plt.subplot(1, 3, 2)
plt.scatter(y_pred, residuals, s=10, alpha=0.4)
plt.axhline(0, color='r', linestyle='--')
plt.xlabel('预测值'); plt.ylabel('残差')
plt.title('② 残差 vs 预测值(看同方差)')

# ③ Q-Q图:正态性检验
# 把你的残差从小到大排成一队(比如第1小、第2小……第500小);去问"标准正态分布":同样排在第1小、第2小……的位置,理论上应该是多少?把这些"实际值vs理论值"成对画成散点图。如果你的残差完美服从正态分布,那么实际值 = 理论值,所有点必然落在一条45°的直线上
plt.subplot(1, 3, 3)
stats.probplot(residuals, dist="norm", plot=plt)
plt.title('③ 残差 Q-Q 图(正态性检验)')

plt.tight_layout(); plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

读图要点:

  • ① 直方图:理想形状是钟形(正态)、均值落在0附近。加州房价的残差直方图右尾明显更长——房价有5.0的截断上限,模型对那批"被截断的高价样本"系统性预测偏低,残差为正且偏大。
  • ② 残差vs预测值:理想状态是"随机散布在0线两侧、不随预测值变化趋势"。如果出现漏斗形(预测值越大、残差波动越大),说明异方差——模型对高价区预测得更不准。加州房价图里能看到预测值接近5时残差被截断成一堆横线,正是标签截断的副作用。
  • ③ Q-Q图:把残差分位数和标准正态分位数对照。残差若服从正态,散点应贴着那条红线。两端翘起说明重尾(极端误差比正态多),这是截断数据和离群点导致的。

残差分析告诉我们什么?加州房价线性模型的残差违反了正态性和同方差假设,根因是标签截断 + 非线性关系。线性回归对这些假设敏感,但这里做的是预测而非置信区间推断,影响有限;要根治得去掉截断样本或上非线性模型(决策树/集成),这恰好呼应1.7学习曲线给出的"高偏差"结论。

# 1.6.5 看系数:Lasso真的在做特征选择

因为特征已经标准化(均值0、方差1),系数的绝对值可以直接比较重要性——这也是做正则化前必须缩放的另一个原因:

import pandas as pd

coef_df = pd.DataFrame(
    {name: model.coef_ for name, model in models.items()},
    index=housing.feature_names
)
print(coef_df.round(4))
1
2
3
4
5
6
7
            LinearRegression  Ridge(α=1.0)  Lasso(α=0.1)
MedInc                0.8544        0.8543        0.7106
HouseAge              0.1225        0.1226        0.1065
AveRooms             -0.2944       -0.2942       -0.0000
AveBedrms             0.3393        0.3390        0.0000
Population           -0.0023       -0.0023       -0.0000
AveOccup             -0.0408       -0.0408       -0.0000
Latitude             -0.8969       -0.8962       -0.0115
Longitude            -0.8698       -0.8691       -0.0000
1
2
3
4
5
6
7
8
9

这张表把1.5节的理论全部验证了:

  • Ridge:每个系数都被轻微压缩(0.8969 → 0.8962),但没有一个是0;
  • Lasso:8个特征里直接扔掉了5个(AveRooms、AveBedrms、Population、AveOccup、Longitude全为0),只保留收入、房龄和纬度——这就是L1稀疏性的真实效果;
  • 收入(MedInc)和地理位置(Latitude/Longitude)系数最大,符合常识:收入越高房价越贵,同收入下纬度越往北(旧金山湾方向)越贵、经度越往西靠海越贵。
  • AveRooms(-)和AveBedrms(+)同时出现大系数,是因为这两个特征高度相关(房间多卧室自然多),形成了共线性——Ridge应对共线性的能力也体现在这里,系数被分摊得更稳定。

# 系数可视化:一张图看清谁说了算

表格对比8个特征系数虽然准确,但要逐行扫读。条形图能把"谁正谁负、谁大谁小"一眼看清。因为特征已标准化,系数绝对值就是"该特征变动1个标准差对房价的影响",可以公平比较:

# 复用1.6.5的coef_df,挑普通线性回归列排序后画条形图
coef = coef_df['LinearRegression'].sort_values()

colors = ['green' if c > 0 else 'red' for c in coef]
plt.figure(figsize=(8, 5))
plt.barh(coef.index, coef.values, color=colors)
plt.axvline(0, color='black', linewidth=0.8)
plt.xlabel('系数(标准化后的影响)')
plt.title('加州房价线性回归系数:绿色=正向影响,红色=负向影响')
plt.tight_layout(); plt.show()
1
2
3
4
5
6
7
8
9
10

读图:绿色条(正向贡献)最长的是MedInc(收入越高房价越高)和AveBedrms;红色条(负向贡献)最长的是Latitude和Longitude(纬度越高、经度越靠西即越靠海,房价越高,所以系数为负)。地理坐标和收入是房价的三大主力特征,这和1.6.5表格的结论完全一致,但条形图比数字表直观得多——汇报给业务方时,一张图胜过一张表。

# 1.6.6 交叉验证自动选α

α是拍脑袋定的吗?不是。用 RidgeCV / LassoCV 让交叉验证自己挑:

from sklearn.linear_model import RidgeCV, LassoCV

alphas = np.logspace(-3, 3, 100)  # 造100个候选α,生成 从10⁻³到10³、共100个数的等比数列:0.001, 0.00115, ..., 1000
ridge_cv = RidgeCV(alphas=alphas, cv=5, scoring='neg_mean_squared_error')  # 对每个候选α,将训练集均分为5份,轮流用4份训练、1份验证,最终选出平均表现最优的α
ridge_cv.fit(X_train_scaled, y_train)
pred = ridge_cv.predict(X_test_scaled)
print(f"Ridge 最优 α={ridge_cv.alpha_:.4f}  R²={r2_score(y_test, pred):.4f}")

# α 范围调整为从10^-4到10^1(0.0001 ~ 10):L1正则化对小α更敏感,通常最优α比岭回归更小
lasso_cv = LassoCV(alphas=np.logspace(-4, 1, 100), cv=5, random_state=42)
lasso_cv.fit(X_train_scaled, y_train)
pred = lasso_cv.predict(X_test_scaled)
print(f"Lasso 最优 α={lasso_cv.alpha_:.5f}  R²={r2_score(y_test, pred):.4f}")
print(f"Lasso 非零系数: {np.sum(lasso_cv.coef_ != 0)} / 8")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Ridge 最优 α=0.1150  R²=0.5758
Lasso 最优 α=0.00064  R²=0.5765
Lasso 非零系数: 8 / 8
1
2
3

交叉验证选出的 α 都很小,R² 提升微乎其微(0.5758 → 0.5765),Lasso在最优 α 下也保留了全部8个特征。结论和1.6.4一致:这个数据集样本多、特征少、没有冗余高维特征,正则化没有太多发挥空间。记住这个反例——以后遇到成百上千个特征的场景(文本、基因数据),你会看到正则化和Lasso特征选择的威力。

两个评估指标的公式:

  • MSE(均方误差):,对大误差惩罚更重(平方放大),越小越好
  • R²(决定系数):,表示"模型比直接猜均值强多少",越接近1越好;为负说明还不如猜均值

顺带验证:单次train_test_split有多不稳定

1.6.4报的R²=0.5758只是一次 train_test_split(random_state=42) 的结果。换个random_state会变多少?这正是交叉验证要回答的问题。用 cross_val_score 做5折交叉验证,等于换了5种切法各训一次再取平均:

from sklearn.model_selection import cross_val_score
from sklearn.pipeline import make_pipeline

# Pipeline把缩放+模型绑成一个整体,避免交叉验证时数据泄漏(见1.7.2)
pipe = make_pipeline(StandardScaler(), LinearRegression())

# 5折交叉验证,scoring用R²
cv_scores = cross_val_score(pipe, X, y, cv=5, scoring='r2', n_jobs=-1)
print(f"5 折 R²: {np.round(cv_scores, 4)}")
print(f"均值={cv_scores.mean():.4f}  标准差={cv_scores.std():.4f}")

# 对比:换几个random_state看单次切分有多飘
for rs in [0, 7, 42, 123, 2024]:
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=rs)
    sc = StandardScaler().fit(Xtr)
    m = LinearRegression().fit(sc.transform(Xtr), ytr)
    print(f"random_state={rs:4d}  R²={r2_score(yte, m.predict(sc.transform(Xte))):.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
5 折 R²: [0.5559 0.6039 0.5517 0.5986 0.5571]
均值=0.5734  标准差=0.0228
random_state=   0  R²=0.5823
random_state=   7  R²=0.5789
random_state=  42  R²=0.5758
random_state= 123  R²=0.5912
random_state=2024  R²=0.5710
1
2
3
4
5
6
7

单次切分的R² 在0.571 ~ 0.591之间漂移,最大差0.02——别小看这0.02,调参时常被当成"提升"。交叉验证给出0.5734 ± 0.0228的区间,告诉我们:线性模型在这个数据集上的真实水平就是0.57左右,0.02以内的波动都可能是切分噪声。这也是1.6.6开头用 RidgeCV / LassoCV 自动选 α 的底层逻辑——不能用一次切分定胜负,要5折取均值才稳。

# 1.7 学习曲线:判断过拟合还是欠拟合

模型调优时第一个要回答的问题是:**我现在的瓶颈到底是过拟合还是欠拟合?**因为两者的处方完全相反——过拟合要"做减法"(加正则、简化模型),欠拟合要"做加法"(加特征、换复杂模型),搞反了会越调越差。**学习曲线(learning curve)**就是用来回答这个问题的诊断工具。

# 1.7.1 学习曲线是什么

横轴是参与训练的样本数量(从少到多),纵轴是误差。对每个样本量,分别计算两种误差:

  • 训练误差:模型在"用于训练它的数据"上的误差;
  • 交叉验证误差:模型在"没见过的数据"上的误差(用K折交叉验证)。

sklearn的 learning_curve() 自动完成这件事(接着1.6的 X, y 用):

from sklearn.model_selection import learning_curve
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression

# 把"缩放 + 模型"打包成一个estimator
# 如果先对全部数据集X做标准化,再送入交叉验证,那么每一轮验证集的均值、方差信息已经提前被缩放器 “见过” 了,相当于验证集信息泄露到了训练过程,会高估模型真实泛化能力。而Pipeline会把缩放和模型绑定,在交叉验证的每一折中,都只在当前折的训练子集上拟合缩放器,再变换当前折的验证子集,保证验证集全程对训练过程不可见,完全符合交叉验证的评估逻辑。
model = make_pipeline(StandardScaler(), LinearRegression())

train_sizes, train_scores, val_scores = learning_curve(
    model,  # 任意estimator
    X, y,
    cv=5,  # 5折交叉验证
    scoring='neg_mean_squared_error',  # 评分指标
    train_sizes=np.linspace(0.1, 1.0, 10),  # 用10%、20%…100% 的样本分别训练
    n_jobs=-1,  # 并行加速
)

# sklearn约定scoring一律"越大越好",MSE被存成负数neg_mean_squared_error,这里取负号还原成真正的MSE(详见下方说明)
train_mse = -train_scores.mean(axis=1)  # 每一折一个分数,再对折数取平均
val_mse = -val_scores.mean(axis=1)
print(train_sizes)
print(np.round(val_mse, 2))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[ 1651  3302  4953  6604  8256  9907 11558 13209 14860 16512]
[102.61  25.57  13.1   0.99  0.83  0.59  0.6   0.58  0.57  0.56]
1
2

注意验证MSE在样本极少时高达102,随样本量增加快速回落到0.56——样本太少时线性模型在经纬度等特征上外推会失控,这也是学习曲线能告诉我们的信息之一。

为什么要加负号? sklearn统一遵循"分数越大越好"的约定,所以MSE这种越小越好的指标被存成负数(neg_mean_squared_error),画图前加负号还原成真正的MSE。

# 1.7.2 一个必须避开的坑:交叉验证中的数据泄漏

上面的代码为什么不直接用1.6缩放好的 X_train_scaled,而是把 StandardScaler 塞进Pipeline?

如果在 learning_curve() 之前就用全量数据 fit_transform 做标准化,交叉验证的每一折里,验证折的均值和方差已经泄漏进了预处理过程——模型提前"偷看"了验证集的统计信息,验证误差会偏乐观。

Pipeline把"缩放 + 模型"绑成一个整体后,learning_curve() 在每一折内部只会用该折的训练部分fit scaler、再transform验证部分,和测试集"只transform不fit"是同一条铁律。凡是带交叉验证的流程(cross_val_score、GridSearchCV……),预处理都应该这样包进Pipeline。

# 1.7.3 一次看清三种拟合形态

光看一条曲线很难建立直觉。我们复用1.4节的二次函数数据,用degree=1 / 2 / 10三个模型分别画学习曲线,对应欠拟合、拟合良好、过拟合:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.model_selection import learning_curve

plt.rcParams['font.sans-serif'] = ['PingFang SC']  # 苹方,macOS默认中文字体
plt.rcParams['axes.unicode_minus'] = False

# 复用1.4的数据:y = 0.5x² + x + 2 + 噪声
np.random.seed(42)
X = 6 * np.random.rand(100, 1) - 3  # 生成100个一维样本X,取值范围[-3, 3]。
y = 0.5 * X**2 + X + 2 + np.random.randn(100, 1) * 0.5

# 通过PolynomialFeatures(degree)生成对应阶数的多项式特征,再用线性回归拟合
# degree=1:退化为普通直线回归,模型最简单,表达能力不足。
# degree=2:与真实数据规律阶数一致,是理论上的最优模型。
# degree=10:高次多项式,模型复杂度极高,参数远多于数据量。
models = [
    ('欠拟合:degree=1(直线)',  make_pipeline(PolynomialFeatures(1),  LinearRegression())),
    ('拟合良好:degree=2(二次)', make_pipeline(PolynomialFeatures(2),  LinearRegression())),
    ('过拟合:degree=10(十次)', make_pipeline(PolynomialFeatures(10), LinearRegression())),
]

# 生成10个均匀分布的比例值,从10%到100%,代表依次使用训练集的10%、20%…100%数据来训练模型,观察误差随样本量的变化
train_sizes = np.linspace(0.1, 1.0, 10)
# 创建1行3列的子图,分别对应三个模型
fig, axes = plt.subplots(1, 3, figsize=(15, 4.2))
# learning_curve函数返回三个值:
# sizes:每个点对应的实际训练样本数(与比例对应)。
# tr:训练集得分,形状为 [训练样本梯度数, 交叉验证折数]。
# va:验证集得分,形状与tr一致。
# scoring='neg_mean_squared_error':sklearn统一使用「越大越好」的评分逻辑,因此均方误差MSE取负数。后续代码通过-tr.mean(axis=1)转回真实MSE。
# mean(axis=1):对5折交叉验证的结果取平均,得到每个样本量下的平均误差。
for ax, (title, model) in zip(axes, models):
    sizes, tr, va = learning_curve(
        model, X, y, cv=5,
        scoring='neg_mean_squared_error', train_sizes=train_sizes
    )
    tr_mse, va_mse = -tr.mean(axis=1), -va.mean(axis=1)
    ax.plot(sizes, tr_mse, 'o-', label='训练误差')
    ax.plot(sizes, np.minimum(va_mse, 3.0), 's-', label='验证误差')  # 截断显示
    ax.set_title(title); ax.set_xlabel('训练样本数')
    ax.set_ylim(-0.1, 3.0); ax.grid(alpha=0.3)
    if va_mse[0] > 3:  # 过拟合模型在样本极少时误差会爆炸,文字标注
        ax.annotate(f'首个点 MSE≈{va_mse[0]:.0f}\n(样本太少时外推爆炸)',
                    xy=(sizes[0], 2.95), xytext=(28, 2.2), fontsize=9,
                    arrowprops=dict(arrowstyle='->', color='tab:orange'))
axes[0].set_ylabel('MSE'); axes[0].legend()
plt.tight_layout(); plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

三张图的终点(用满全部样本时)实测数值:

模型 训练MSE 验证MSE 两线间距
degree=1(欠拟合) 2.189 2.222 0.033(很小)
degree=2(良好) 0.192 0.212 0.020(很小)
degree=10(过拟合) 0.177 0.268 0.091(明显大)

读图口诀:先看高度,再看间距。

  • 欠拟合(左图):两条线都趴在高位(~2.2),挨得很近,而且加数据也不降——模型的表达能力到顶了,训练集都学不好。
  • 拟合良好(中图):两条线都收敛到低位(~0.2),间距小。
  • 过拟合(右图):训练误差一路压到接近0,验证误差始终高一块,中间留着明显的缝;样本极少时验证MSE甚至高达24万——10次多项式拿着几个点做疯狂外推。随着样本增多缝隙会收窄,所以过拟合时增加数据确实有用。

# 1.7.4 在加州房价上实战

把Pipeline学习曲线用到1.6的真实数据上:

from sklearn.datasets import fetch_california_housing

housing = fetch_california_housing()
X, y = housing.data, housing.target

model = make_pipeline(StandardScaler(), LinearRegression())
sizes, tr, va = learning_curve(
    model, X, y, cv=5, scoring='neg_mean_squared_error',
    train_sizes=np.linspace(0.4, 1.0, 8), n_jobs=-1
)

plt.plot(sizes, -tr.mean(axis=1), 'o-', label='训练误差')
plt.plot(sizes, -va.mean(axis=1), 's-', label='交叉验证误差')
plt.xlabel('训练样本数'); plt.ylabel('MSE')
plt.title('加州房价:线性回归学习曲线')
plt.legend(); plt.grid(alpha=0.3); plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

曲线特征:两条线很快在0.52 / 0.56附近同时走平,间距很小。诊断结论:

  1. 不是过拟合(间距小,训练误差也不低);
  2. 是轻微的高偏差(欠拟合),瓶颈在模型表达力——线性模型学不到"地段×收入"这种非线性交互;
  3. 继续收集数据基本没用(曲线已经平了),正确的处方是换更复杂的模型或做特征工程——这正好引出Day8的决策树和随机森林。

# 小结

今天搭起了机器学习的地基。线性回归用最小化MSE学习参数,梯度下降是通用的求解引擎,最小二乘给出闭式解;特征缩放是梯度下降和正则化共同的前提;多项式回归通过特征升维扩展表达力;Ridge(L2)压缩参数、Lasso(L1)把不重要特征直接压成0实现特征选择,正则化本质是用偏差换方差。实战部分我们在加州房价数据集上走了完整流程:残差分析给模型体检,交叉验证让评估和选 α 更可靠,学习曲线则帮我们判断瓶颈是过拟合还是欠拟合,从而决定做减法还是做加法。但线性模型只能学"直线/超平面",面对"地段 × 收入"这种非线性交互就到顶了——下一天(Day7)我们转向分类问题,用逻辑回归把线性输出套进sigmoid当概率,完成从回归到分类的切换。

明天Day7见!See you tomorrow!

最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing