Skip to content

第 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 手动"学习"一条直线 ​

"学"这个词听起来玄,其实就是一个过程:找一组参数,让规律尽量贴合已知的例子。

假设我们相信"健康度随循环次数线性下降",那规律就是一条直线:

text
soh ≈ k × cycle + b

两个未知数(k 和 b),两个方程就能解出来。我们有三个例子,取首尾两个:

python
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]                                # 截距

真实输出:

text
  例子(特征 -> 标签):
    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 章讲这个)。

点开:斜率翻译成人话,为什么这么重要?

看这一行:

python
斜率 = -0.00016875

这不是一个结论 —— 没人能从 -0.00016875 里读出任何业务含义。

翻译之后:

每多循环 100 圈,健康度平均下降 1.69 个百分点

现在它才是结论。 因为它有:

  • 量纲(百分点);
  • 可感的尺度(1.69 个百分点 = 从 95% 掉到 93.3%);
  • 可比较性(能对比另一个模型给出的 2.1 个百分点)。

代码里怎么翻译:

python
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 用这条规律做预测 ​

python
for x in (50.0, 700.0, 1200.0, 1600.0):
    pred = k * x + b

真实输出:

text
  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……
  但【没有任何数据支持这个趋势】

⚠️ 危险在于:外推的结果看起来和正常预测一模一样。

python
model.predict(pd.DataFrame({"cycle": [700.0]}))    # 0.879   ← 有依据
model.predict(pd.DataFrame({"cycle": [1600.0]}))   # 0.727   ← 纯外推

两个调用的写法完全相同,输出的形式也完全相同 —— 你从数字本身看不出哪个可信。

★ 现实中这个坑长什么样

"根据模型,这批设备在 1600 圈时健康度会到 0.727,
 所以我建议把维护计划安排在第 1500 圈。"
                        ↑
        这个建议建立在一个没有数据支持的外推上

而数据的真实情况可能是:设备在 1440 圈就停止采样了(已经到退役线); 1600 圈时它们根本不存在。

怎么防:

python
# ① 明确标注预测是否在训练范围内
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 换成真实数据:一模一样的循环 ​

到目前为止都是玩具例子。现在把它套到真实的表上:

python
FEATS = ["cycle"]
X, y = df[FEATS], df["soh"]

真实输出:

text
  训练数据规模: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² 的定义:

text
           模型的平方误差
R² = 1 - ──────────────────
         用均值预测的平方误差

分母就是标签的方差。

★ 由此得到一个重要认知:

标签的方差,决定了"这个问题有多少可预测的空间"。

推论(第 08、21 章会用到):

标签方差小 → R² 天然难做高(分母小,噪声占比大)
标签方差大 → R² 容易做高(分母大)

所以:【R² 不能跨数据集比较】
      在不同数据集上比较 R²,就像比较"两个不同的考试谁更简单"

3.5 ★ 一个必须先回答的问题 ​

表里有 21,239 行。但它真的代表 21,239 个独立样本吗?

真实输出:

text
  这张表有 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 条,那就有分组结构)
   ↓
划分时能按用户切吗?

★ 一个可操作的判断方法

python
# 问三个问题
print(f"总行数     : {len(df):,d}")
print(f"实体数     : {df['cell_id'].nunique():,d}")
print(f"平均每实体 : {len(df) / df['cell_id'].nunique():.1f} 行")

如果"平均每实体 > 1 行",就有分组结构,必须按实体划分。

结论:

"这一行是什么"这个问题,比"该用什么模型"重要一百倍。

先回答它,再写代码。

3.6 常见坑 ​

text
① 先学 API 再理解概念 → 会用 fit() 但不知道在优化什么
② 把外推结果当正常预测 → 模型不会告诉你"这里我没见过"
③ 拿 R² 跨数据集比较 → 分母不同,不可比
④ 把"行数"当"样本数" → 这是全书最大的坑,第 09 章量化
⑤ 忘记翻译系数的量纲 → -0.00016875 不是结论

3.7 完整示例与输出 ​

bash
cd code
python demos/ch03-what-is-supervised.py

真实输出(节选):

text
=== ① 先看一张极简的「学习」===
  例子(特征 -> 标签):
    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:

  1. 拿到斜率和截距(用 np.polyfit 或自己算);
  2. 把斜率翻译成"每 100 圈下降几个百分点";
  3. 反过来问:照这个速度,从 1.00 掉到 0.80 需要多少圈?

要求:第 3 问的答案要有量纲和一段话解释。

参考答案
python
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} 圈")

期望输出:

text
拟合结果:soh ≈ -0.00014620 × cycle + 0.996891
每 100 圈下降 1.462 个百分点

照此速度,SOH 降到 0.80 需要 1347 圈

第三问的完整回答:

按全表拟合的直线,健康度从出厂值降到退役线 0.80 大约需要 1347 圈。 但这个数字是"平均"意义下的,它把 60 支设备的差异全部平均掉了 —— 实际上各支设备的末次记录落在 752 ~ 1440 圈之间。 所以它回答的是"这批设备平均什么时候退役", 不能用来判断某一支设备什么时候退役。

★ 为什么第 3 问的"解释"比"数字"重要

只有数字:1349 圈
   ↓
别人会问:"那我的 3 号设备还有多久?"
   ↓
你答不出来 —— 因为 1349 从来不是为"某一支"算的

加上限定词之后,这个数字的适用范围就清楚了。

这正是第 02 章说的"给数字加限定词"的第一个实战。

一个补充验证(想深入可以做):

python
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,然后回答:

  1. 数值是多少?
  2. 这个数字在物理上可能吗?
  3. 为什么模型"敢"给出这个答案?
参考答案
python
k, b = -0.00014620, 0.996891
for x in (1440, 2000, 3000):
    print(f"cycle = {x:5d}  ->  预测 soh = {k * x + b:.4f}")

期望输出:

text
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

模型没有"我不确定"这个输出。它只会算这个乘法。

★ 数据事实:

python
print(df["cycle"].max())        # 1440

训练数据最多到 1440 圈 —— 3000 圈的地方一个样本都没有。

★ 怎么防(三条)

① 显式标注范围

python
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 章)

python
# 高斯过程在数据区外的置信带会自动张开
# cycle = 1440(边界)→ ±0.0101
# cycle = 1500(外推)→ ±0.1094   ← 11 倍

③ 直接拒绝预测

python
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" —— 而你已经知道该警惕什么了。


下一章:第 04 章 · 特征与标签:把一张表切成 X / y