# 勤奋的凯尔森同学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 机器学习在学什么
传统编程是"人写规则,机器执行规则";机器学习是"人给数据和答案,机器自己学出规则"。
传统编程: 规则 + 数据 → 答案
机器学习: 数据 + 答案 → 规则(模型)
2
一个模型就是一个函数 ,输入特征 ,输出预测 。学习的过程就是调整函数的参数,让预测值尽量接近真实值。
# 1.1.2 三大范式
| 范式 | 数据特点 | 目标 | 典型任务 |
|---|---|---|---|
| 监督学习 | 有标签(输入+输出对) | 学输入到输出的映射 | 分类、回归 |
| 无监督学习 | 只有输入,没有标签 | 发现数据内部结构 | 聚类、降维 |
| 强化学习 | 环境给奖励信号 | 学一个策略最大化长期奖励 | 游戏、机器人 |
本系列的主线是监督学习(分类 + 回归),Day10会覆盖无监督学习(聚类 + 降维)。
# 1.1.3 监督学习的两类任务
- 回归(Regression):预测连续值。比如预测房价、温度、销售额。输出是实数。
- 分类(Classification):预测离散类别。比如判断邮件是否垃圾、图片是猫还是狗。输出是类别标签。
线性回归是回归任务的入门;分类任务的入门则是Day7的主角——逻辑回归。两个名字都带"回归",任务却完全不同,到时候别搞混。
# 1.2 数学基础:梯度下降
梯度下降是几乎所有机器学习算法的求解引擎。理解它,Day7的逻辑回归、后面的神经网络、XGBoost就都通了。
# 1.2.1 损失函数:衡量预测错得多离谱
模型的预测 ,真实值是 。损失函数 衡量单次预测的误差,代价函数 是所有样本损失的平均。
线性回归用均方误差(MSE):
前面的 是为了求导后把平方的2约掉,不影响最优解。
# 1.2.2 梯度:下山最快的方向
梯度 是代价函数对每个参数的偏导数组成的向量,它指向函数值上升最快的方向。要找最小值,就朝负梯度方向走。
想象你站在山上,目标是走到山谷最低点。
梯度 = 当前位置最陡的上坡方向
负梯度 = 最陡的下坡方向
每走一步,重新看脚下最陡的下坡方向,继续走。
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
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()
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())
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
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]
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))
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
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))
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
...
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)
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}")
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
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()
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))
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
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()
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")
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
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}")
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
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))
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]
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()
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()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
曲线特征:两条线很快在0.52 / 0.56附近同时走平,间距很小。诊断结论:
- 不是过拟合(间距小,训练误差也不低);
- 是轻微的高偏差(欠拟合),瓶颈在模型表达力——线性模型学不到"地段×收入"这种非线性交互;
- 继续收集数据基本没用(曲线已经平了),正确的处方是换更复杂的模型或做特征工程——这正好引出Day8的决策树和随机森林。
# 小结
今天搭起了机器学习的地基。线性回归用最小化MSE学习参数,梯度下降是通用的求解引擎,最小二乘给出闭式解;特征缩放是梯度下降和正则化共同的前提;多项式回归通过特征升维扩展表达力;Ridge(L2)压缩参数、Lasso(L1)把不重要特征直接压成0实现特征选择,正则化本质是用偏差换方差。实战部分我们在加州房价数据集上走了完整流程:残差分析给模型体检,交叉验证让评估和选 α 更可靠,学习曲线则帮我们判断瓶颈是过拟合还是欠拟合,从而决定做减法还是做加法。但线性模型只能学"直线/超平面",面对"地段 × 收入"这种非线性交互就到顶了——下一天(Day7)我们转向分类问题,用逻辑回归把线性输出套进sigmoid当概率,完成从回归到分类的切换。
明天Day7见!See you tomorrow!