第 03 章 · 什么是监督学习:从一条直线说起
这一章要解决什么:把"监督学习"这个词拆开,用最小到不能再小的例子看清它到底在干什么。 这一章没有 sklearn,只有三行手算 —— 因为你要先理解"学"是什么, 再去用
fit()。最容易踩的坑:把"预测"当成"预言"。模型给的是基于已有例子的外推, 不是对未来的洞察。这一章末尾会让你亲眼看到危险在哪。
本章你需要完成的代码:demos/ch03-what-is-supervised.py
3.1 一句话:监督学习 = 看着答案找规律
"监督"的意思是:每个例子里都有答案。
给你的材料(训练数据):
cycle = 100 → soh = 0.980
cycle = 500 → soh = 0.910
cycle = 900 → soh = 0.845
↑
这些"答案"就是监督信号
你要做的事:
找一条规律,让它对这三个例子都成立
然后用这条规律去回答【没见过的】cycle"监督"的反面是"无监督":没有答案,只有一堆数据,让你自己找结构 (比如聚类 —— 把相似的样本分到一组)。
点开:监督学习在机器学习里的位置
机器学习大致分三类:
机器学习
├─ 监督学习(有答案)
│ ├─ 回归 ← 预测一个【连续数值】 ← 这本书
│ │ 例:健康度 0.912、房价 350 万、温度 26.3℃
│ └─ 分类 ← 预测一个【类别】
│ 例:猫/狗、正常/异常、会流失/不会流失
│
├─ 无监督学习(没答案)
│ ├─ 聚类 把相似的样本归组
│ └─ 降维 把高维数据压到低维(PCA)
│
└─ 强化学习(有奖励,没有标准答案)
通过试错学策略(下棋、机器人控制)这本书全程是"回归"。
一个容易混的点:预测"概率"算分类还是回归?
"这只猫的概率是 0.87" → 输出是 [0,1] 的连续值,看起来像回归
但目标是"猫/狗"这个类别 → 属于【分类】
(叫"概率分类")
"明天的温度是 26.3℃" → 目标是连续数值 → 真正的【回归】判据不是"输出长什么样",而是"目标是什么":
| 目标 | 类型 |
|---|---|
| 是"哪个"(离散的选择) | 分类 |
| 是"多少"(连续的度量) | 回归 |
| 是"像不像"(概率/相似度) | 看它最终要回答哪个问题 |
为什么这个区分重要:
回归的评价指标:MAE / MSE / RMSE / R² ← 衡量"差多远"
分类的评价指标:准确率 / 精确率 / 召回率 / AUC ← 衡量"对了几成"
用错指标 = 用错的尺子量东西。细节:回归的四大指标在第 08 章;分类不在本书范围。
3.2 手动"学习"一条直线
"学"这个词听起来玄,其实就是一个过程:找一组参数,让规律尽量贴合已知的例子。
假设我们相信"健康度随循环次数线性下降",那规律就是一条直线:
soh ≈ k × cycle + b两个未知数(k 和 b),两个方程就能解出来。我们有三个例子,取首尾两个:
train_x = np.array([100.0, 500.0, 900.0])
train_y = np.array([0.980, 0.910, 0.845])
k = (train_y[-1] - train_y[0]) / (train_x[-1] - train_x[0]) # 斜率
b = train_y[0] - k * train_x[0] # 截距真实输出:
例子(特征 -> 标签):
cycle = 100 -> soh = 0.980
cycle = 500 -> soh = 0.910
cycle = 900 -> soh = 0.845
学到的规律:soh ≈ -0.00016875 × cycle + 0.996875
翻译成人话:每多循环 100 圈,健康度平均下降 1.69 个百分点★ "学"的结果就是两个数字:k = -0.00016875、b = 0.996875。
这个"模型"就是一个长度 2 的参数列表。
所有机器学习模型,本质都是这个结构的复杂版:
线性回归 :几万个参数(每个特征一个权重)
决策树 :一堆 if-else 的阈值
神经网络 :几百万个参数
大型语言模型 :几千亿个参数
↑
都是"一组数字",只是数量和形式不同★ 关键认知:
"训练模型" = 找到一组好的参数。 "模型" = 那组参数。
所以模型的"大小"和"能力"是可以量化比较的 —— 参数越多,"能表达的规律"越复杂(第 18 章讲这个)。
点开:斜率翻译成人话,为什么这么重要?
看这一行:
斜率 = -0.00016875这不是一个结论 —— 没人能从 -0.00016875 里读出任何业务含义。
翻译之后:
每多循环 100 圈,健康度平均下降 1.69 个百分点现在它才是结论。 因为它有:
- 量纲(百分点);
- 可感的尺度(1.69 个百分点 = 从 95% 掉到 93.3%);
- 可比较性(能对比另一个模型给出的 2.1 个百分点)。
代码里怎么翻译:
print(f"每多循环 100 圈,健康度平均下降 {abs(k) * 100 * 100:.2f} 个百分点")为什么 × 100 × 100:
k = -0.00016875 (每个 cycle 的 soh 变化)
× 100 → 每 100 圈的变化 = -0.016875
× 100 → 变成百分比:1.6875 个百分点★ 这条纪律会在全书反复出现:
任何从模型里取出的数字,都要问一句"它的量纲是什么、说成人话是什么"。
第 08 章讲评估指标时会再遇到一次(MSE = 0.0005368 为什么没法读)。
3.3 用这条规律做预测
for x in (50.0, 700.0, 1200.0, 1600.0):
pred = k * x + b真实输出:
cycle = 50 -> 预测 soh = 0.988
cycle = 700 -> 预测 soh = 0.879
cycle = 1200 -> 预测 soh = 0.794 ← 训练数据里没有这个范围!
cycle = 1600 -> 预测 soh = 0.727 ← 训练数据里没有这个范围!注意后两行:cycle = 1200 和 1600 都超出了训练数据的范围(100~900)。
这叫外推(extrapolation)。
点开:外推为什么危险?(这是模型最容易被滥用的地方)
直线的性质:它会无限延伸下去,永远不说"我不知道"。
在训练范围内(100~900):直线贴合数据 → 预测比较可信
在范围之外(>900):
直线继续往下走 → 预测 soh = 0.794、0.727……
但【没有任何数据支持这个趋势】⚠️ 危险在于:外推的结果看起来和正常预测一模一样。
model.predict(pd.DataFrame({"cycle": [700.0]})) # 0.879 ← 有依据
model.predict(pd.DataFrame({"cycle": [1600.0]})) # 0.727 ← 纯外推两个调用的写法完全相同,输出的形式也完全相同 —— 你从数字本身看不出哪个可信。
★ 现实中这个坑长什么样
"根据模型,这批设备在 1600 圈时健康度会到 0.727,
所以我建议把维护计划安排在第 1500 圈。"
↑
这个建议建立在一个没有数据支持的外推上而数据的真实情况可能是:设备在 1440 圈就停止采样了(已经到退役线); 1600 圈时它们根本不存在。
怎么防:
# ① 明确标注预测是否在训练范围内
x_min, x_max = train_x.min(), train_x.max()
in_range = x_min <= x <= x_max
print(f" 预测 soh = {pred:.3f}{'' if in_range else ' ← 外推!'}")
# ② 用能给"不确定性"的模型(第 24~25 章)
# 高斯过程在数据范围外会自动把置信带张开:
# cycle = 1440(数据边界)→ ±0.0101
# cycle = 1500(外推区) → ±0.1094 ← 涨了 11 倍★ 高斯过程那条路是这本书 E 部分的重点:
一个诚实的模型应该能说"这里我没见过,我不敢下结论"。
线性回归不会说这句话,它会给你一个 0.727,而且看起来很自信。
记住这个对比 —— 第 26 章选型时它会回来。
3.4 换成真实数据:一模一样的循环
到目前为止都是玩具例子。现在把它套到真实的表上:
FEATS = ["cycle"]
X, y = df[FEATS], df["soh"]真实输出:
训练数据规模:21,239 行 × 1 个特征
标签 soh:mean 0.9088 std 0.0548 min 0.8032 max 1.0017注意两个数字:
| 数字 | 含义 |
|---|---|
mean 0.9088 | 平均健康度 90.9% |
std 0.0548 | 标准差 5.5 个百分点 —— 个体差异不小 |
标准差 0.0548 这个数字很重要:
如果所有设备状态一模一样 → std ≈ 0
实际上 std = 0.0548 → 说明设备之间有真实差异这正是"单一特征不够用"的原因:
同一个 cycle 上,不同设备的 soh 可能差好几个百分点
↓
用 cycle 一个特征预测 → 只能给出"这一圈的平均水平"
↓
无法区分"这支设备衰退得快还是慢"第 13 章(多特征)会量化这个差距: 单特征 R² = 0.82070 → 七特征 R² = 0.94253。
点开:为什么标准差是"信息量"的度量?
一个思想实验:
情况 A:所有设备的 soh 都是 0.9088(std = 0)
→ 你想预测任何一支的 soh,答案永远是 0.9088
→ 不需要模型,"常数"就够了
→ R² 会是 0(因为分母也是 0,实际上 R² 无定义)
情况 B:soh 分布很宽(std = 0.0548)
→ 预测有难度,但"预测对了"才有价值
→ R² 的分母够大,模型有发挥空间R² 的定义:
模型的平方误差
R² = 1 - ──────────────────
用均值预测的平方误差分母就是标签的方差。
★ 由此得到一个重要认知:
标签的方差,决定了"这个问题有多少可预测的空间"。
推论(第 08、21 章会用到):
标签方差小 → R² 天然难做高(分母小,噪声占比大)
标签方差大 → R² 容易做高(分母大)
所以:【R² 不能跨数据集比较】
在不同数据集上比较 R²,就像比较"两个不同的考试谁更简单"3.5 ★ 一个必须先回答的问题
表里有 21,239 行。但它真的代表 21,239 个独立样本吗?
真实输出:
这张表有 21,239 行,但它真的代表 21,239 个【独立】样本吗?
表里其实只有 60 支电芯,每支有很多行:
每支电芯 235 ~ 464 行(中位 356 行)
→ 同一支电芯的相邻行【几乎一样】,它们不是独立的。
→ 这个认知会在第 09 章决定你的划分方式对不对。★ 这一节是全书最重要的一段"伏笔"。
21,239 行 ≠ 21,239 个独立样本
21,239 行 = 60 支设备 × 每支约 354 个时间窗口
↑
同一支设备的相邻窗口只差 2~4 圈
它们的 soh 只差 0.0003 量级为什么这件事致命:
你要评估"模型对【没见过】的设备准不准"
↓
但你随机划分数据时,同一支设备的相邻两行
可能一行进训练、一行进测试
↓
模型在测试时【见过这支设备的邻居】
↓
它不是"预测",是"回忆"
↓
分数虚高,上线就崩第 09~10 章会亲手量化这个虚高有多大:R² 从 0.964 虚高到 0.987。
现在你只需要记住这个警觉:
拿到一张表,先问:"这一行是什么?同一个实体出现了几次?"
点开:这个"行不独立"的问题有多少别名(认出它就够了)
同一个问题在不同领域有不同的名字,但本质完全一样:
| 领域 | 数据形态 | 别名的说法 |
|---|---|---|
| 设备监测 | 设备 × 时间窗口 | 时间序列自相关 |
| 医疗 | 患者 × 多次就诊 | 重复测量(repeated measures) |
| 电商 | 用户 × 多个月份 | 面板数据(panel data) |
| 教育 | 学生 × 多次考试 | 纵向数据(longitudinal) |
| 生物 | 同一窝的多只动物 | 巢效应(nesting) |
| 地理 | 同一区域的多个采样点 | 空间自相关 |
| 多人标注 | 同一样本被多个人标注 | 聚类/嵌套结构 |
在机器学习里,统一叫"分组结构(group structure)"。
★ 为什么要有这个表
因为你的场景可能不是"设备"。当你看到下面任何一种数据时, 脑子里要立刻亮灯:
"我有 50 万条用户行为记录"
↓
有多少个用户?(如果是 1 万用户 × 50 条,那就有分组结构)
↓
划分时能按用户切吗?★ 一个可操作的判断方法
# 问三个问题
print(f"总行数 : {len(df):,d}")
print(f"实体数 : {df['cell_id'].nunique():,d}")
print(f"平均每实体 : {len(df) / df['cell_id'].nunique():.1f} 行")如果"平均每实体 > 1 行",就有分组结构,必须按实体划分。
结论:
"这一行是什么"这个问题,比"该用什么模型"重要一百倍。
先回答它,再写代码。
3.6 常见坑
① 先学 API 再理解概念 → 会用 fit() 但不知道在优化什么
② 把外推结果当正常预测 → 模型不会告诉你"这里我没见过"
③ 拿 R² 跨数据集比较 → 分母不同,不可比
④ 把"行数"当"样本数" → 这是全书最大的坑,第 09 章量化
⑤ 忘记翻译系数的量纲 → -0.00016875 不是结论3.7 完整示例与输出
cd code
python demos/ch03-what-is-supervised.py真实输出(节选):
=== ① 先看一张极简的「学习」===
例子(特征 -> 标签):
cycle = 100 -> soh = 0.980
cycle = 500 -> soh = 0.910
cycle = 900 -> soh = 0.845
学到的规律:soh ≈ -0.00016875 × cycle + 0.996875
翻译成人话:每多循环 100 圈,健康度平均下降 1.69 个百分点
=== ② 用这条规律做预测 ===
cycle = 1200 -> 预测 soh = 0.794 ← 训练数据里没有这个范围!
=== ③ 换成真实数据 ===
训练数据规模:21,239 行 × 1 个特征
标签 soh:mean 0.9088 std 0.0548 min 0.8032 max 1.0017
=== ④ 一个必须先回答的问题 ===
→ 同一支电芯的相邻行【几乎一样】,它们不是独立的。
→ 这个认知会在第 09 章决定你的划分方式对不对。本章练习
练习 1:把斜率翻译成人话
用真实数据(soh_features.csv)拟合 soh ~ cycle:
- 拿到斜率和截距(用
np.polyfit或自己算); - 把斜率翻译成"每 100 圈下降几个百分点";
- 反过来问:照这个速度,从 1.00 掉到 0.80 需要多少圈?
要求:第 3 问的答案要有量纲和一段话解释。
参考答案
import numpy as np
import pandas as pd
df = pd.read_csv("data/battery/soh_features.csv")
x, y = df["cycle"].to_numpy(), df["soh"].to_numpy()
# ①② 最小二乘拟合(degree=1 就是直线)
k, b = np.polyfit(x, y, 1)
print(f"拟合结果:soh ≈ {k:.8f} × cycle + {b:.6f}")
print(f"每 100 圈下降 {abs(k) * 100 * 100:.3f} 个百分点")
# ③ 反解:k*x + b = 0.80
x_eol = (0.80 - b) / k
print(f"\n照此速度,SOH 降到 0.80 需要 {x_eol:.0f} 圈")期望输出:
拟合结果:soh ≈ -0.00014620 × cycle + 0.996891
每 100 圈下降 1.462 个百分点
照此速度,SOH 降到 0.80 需要 1347 圈第三问的完整回答:
按全表拟合的直线,健康度从出厂值降到退役线 0.80 大约需要 1347 圈。 但这个数字是"平均"意义下的,它把 60 支设备的差异全部平均掉了 —— 实际上各支设备的末次记录落在 752 ~ 1440 圈之间。 所以它回答的是"这批设备平均什么时候退役", 不能用来判断某一支设备什么时候退役。
★ 为什么第 3 问的"解释"比"数字"重要
只有数字:1349 圈
↓
别人会问:"那我的 3 号设备还有多久?"
↓
你答不出来 —— 因为 1349 从来不是为"某一支"算的加上限定词之后,这个数字的适用范围就清楚了。
这正是第 02 章说的"给数字加限定词"的第一个实战。
一个补充验证(想深入可以做):
last = df.groupby("cell_id").tail(1)
print(f"末次记录 cycle:min {last['cycle'].min()} / "
f"中位 {int(last['cycle'].median())} / max {last['cycle'].max()}")
# min 752 / 中位 1117 / max 1440
print(f"1347 落在第 {100 * (last['cycle'] < 1347).mean():.0f} 百分位")
# 约第 80 百分位 —— 说明这条直线【高估】了多数设备的寿命这个补充很重要:1347 落在分布的偏高一端, 意味着用这个数字给短寿命设备排计划会低估风险(第 07 章详讲)。
练习 2:亲眼看外推的错误
用上面的直线(k, b)预测 cycle = 3000 时的 SOH,然后回答:
- 数值是多少?
- 这个数字在物理上可能吗?
- 为什么模型"敢"给出这个答案?
参考答案
k, b = -0.00014620, 0.996891
for x in (1440, 2000, 3000):
print(f"cycle = {x:5d} -> 预测 soh = {k * x + b:.4f}")期望输出:
cycle = 1440 -> 预测 soh = 0.7864
cycle = 2000 -> 预测 soh = 0.7045
cycle = 3000 -> 预测 soh = 0.5583① 数值:0.5583
② 物理上可能吗:不太可能,但要分情况说。
soh = 0.5583 表示容量只剩额定值的 56%
↓
对很多电池体系来说,这个状态下设备早就无法正常工作了
↓
但它【不是数学上不可能】—— 0.56 是一个合法的小数⚠️ 关键点:模型给的是一个"数学上完全合法"的数, 它在物理上荒谬这件事,模型自己不知道。
③ 为什么模型"敢"给出这个答案
因为线性模型的假设就是"线性、无限延伸":
soh = k × cycle + b
↑
这个公式对任何 cycle 都有定义,包括 3000、30000模型没有"我不确定"这个输出。它只会算这个乘法。
★ 数据事实:
print(df["cycle"].max()) # 1440训练数据最多到 1440 圈 —— 3000 圈的地方一个样本都没有。
★ 怎么防(三条)
① 显式标注范围
x_max = df["cycle"].max()
pred = k * x + b
tag = "" if x <= x_max else " ← 外推,无数据支持"
print(f"cycle = {x:5d} -> 预测 soh = {pred:.4f}{tag}")② 用能给不确定性的模型(第 24 章)
# 高斯过程在数据区外的置信带会自动张开
# cycle = 1440(边界)→ ±0.0101
# cycle = 1500(外推)→ ±0.1094 ← 11 倍③ 直接拒绝预测
def safe_predict(x, k, b, x_max):
if x > x_max * 1.05: # 超出训练范围 5% 就不给答案
return None, "超出训练数据范围,拒绝预测"
return k * x + b, ""★ 最后这条在工程上很常见,叫"拒识(reject option)":
一个可靠的生产系统,应该能说"我不知道",而不是硬给一个数。
这也是第 26 章选型时,会用 GPR 而不是"更强的森林"的原因之一。
练习 3:你的数据有没有"分组结构"?
给定一张假设的表,判断它有没有分组结构,并说明划分时该按哪一列切:
| 场景 | 表的样子 |
|---|---|
| A | 房价预测:每套房一行,含面积/房龄/地段 |
| B | 销量预测:50 家门店 × 每家 104 周 |
| C | 用户流失预测:2 万用户 × 每人若干次登录记录 |
| D | 成绩预测:1000 名学生,每人一次期末考试,含平时作业分 |
| E | 设备故障预测:200 台设备 × 每台若干天的传感器读数 |
参考答案
| 场景 | 有分组结构吗 | 按什么划分 | 说明 |
|---|---|---|---|
| A | ❌ 没有 | 随便按行 | 每套房只出现一次,行之间独立 |
| B | ✅ 有 | 门店 | 同一门店的周数据高度相关(同店同季节性) |
| C | ✅ 有 | 用户 | 同一用户的登录记录相关 |
| D | ❌ 没有 | 按行 | 每名学生一行 |
| E | ✅ 有 | 设备 | 同一台设备的相邻天数相关(就是本书场景) |
★ 判据(一句话)
同一个实体在表里出现多次 → 有分组结构 → 按那个实体划分。
⚠️ 场景 D 的两个陷阱(虽然它没有分组结构)
陷阱一:如果"平时作业分"是在期末考试之后录入的 → 时间穿越泄漏。
陷阱二:如果每个学生有"多次作业成绩",而你把作业成绩汇总成了多行 → 那就变成有分组结构了。
"1000 名学生,每人一次期末考" → 无分组
"1000 名学生,每人 10 次作业记录" → 有分组(按学生切)★ 一个更细的层次:场景 B 可以按"门店"切,也可以按"时间"切
按门店切(留出几家门店):
问题 = "对新门店能不能预测" ← 真实上线场景
按时间切(前 80 周训练、后 24 周测试):
问题 = "对未来的周能不能预测" ← 另一个真实场景选哪个取决于你要回答什么问题:
| 业务问题 | 划分方式 |
|---|---|
| 开新店能不能预测销量? | 按门店切 |
| 下周的销量是多少? | 按时间切 |
| 两者都要? | 做两套评估 |
★ 这就是"划分方式要匹配业务问题" —— 划分不是技术细节,它定义了你在评估什么能力。
回到本书:我们按设备切,因为要回答 "对一支没见过的设备,能不能预测它的健康度"。
第 09 章会详细讲这个选择的理由。
面向工程实践:先问"这一行是什么"
这一章看起来在讲一件很简单的事(一条直线),但它埋下了全书最重要的伏笔。
① "行 ≠ 独立样本"是所有数据工作的第一道关
拿到任何一张表,先跑三行:
print(f"行数 : {len(df):,d}")
print(f"实体数 : {df[KEY].nunique():,d}")
print(f"每实体 : {len(df) / df[KEY].nunique():.1f} 行")如果"每实体 > 1",你就要开始小心了。
② 这个认知能预防的事故类型
| 事故 | 根源 |
|---|---|
| 离线 AUC 0.95,上线 0.70 | 划分时同一用户跨集 |
| 交叉验证分数异常稳定(±0.001) | 折之间泄漏了 |
| 模型在"新客户"上完全不可用 | 训练时见过所有客户的模式 |
| 特征重要性全是"用户 ID 类"特征 | 模型在背身份,不是在学规律 |
③ 一个可迁移的检查习惯
我的数据里,"一个样本"到底是什么?
├─ 一行 = 一个独立个体? → 可以按行划分
├─ 一行 = 一个个体的一次观测? → 必须按个体划分
└─ 一行 = 一个个体的一段时间? → 按个体划分,还要考虑时间顺序★ 这个检查只需要三行代码,但它能救掉整个项目。
这也解释了为什么第 01 章的"数据体检"里,我让你打印 df["cell_id"].nunique() 和 df.groupby("cell_id").size() —— 不是为了看热闹,是为了让你在写模型之前就发现这个问题。
下一章开始,我们就正式进入"把表切成 X/y" —— 而你已经知道该警惕什么了。