Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

导读:用一个预测任务认识机器学习

你把一份 24 页的文档交给打印机,屏幕上弹出一句话:“预计还需 92 秒。”

这个数字是怎么来的?

如果知道打印机每页需要几秒、启动又需要多久,写一个计算程序并不难。可现实中的设备没那么整齐:传输、预热、进纸都可能带来一些波动。我们手上往往只有过去的记录——打印了多少页,最后用了多少秒。

不妨试试从这些记录里找一条规律。找到以后,再拿几次新任务检验:它预测得准不准?这就是我们走近机器学习的第一步。

这一章里,我们会亲手做完这件事。只要你还记得一次函数和平均数,能看懂简单的 Python 循环与函数,就可以跟上。读到最后,你会知道模型到底“学”到了什么,也能让它在自己的电脑上作出预测。

1. 先说清楚:我们要预测什么

我们的目标是:在提交打印任务时,根据文档的页数,估计从提交到最后一页输出要花多少秒。

先把场景缩小一点:始终使用同一台打印机,采用单面黑白打印;开始时没有其他任务排队,纸张和设备也都正常。这样,我们就能先看清页数与耗时的关系。

这些条件很有用。想想看,同样是 10 页文档,前面排着十几个任务时,等候时间可能比打印时间还长。如果把这样的记录混在一起,却只告诉模型页数,它自然很难猜准。

下面的数据是为方便学习而编出的例子,并非真实测量结果。我们把数字和数据量都控制得很小,好让每一步都能亲手核对。

我们要预测的是“多少秒”这样的数值,这类任务叫回归。如果把问题改成“能否在 90 秒内完成”,答案就变成“能”或“不能”,这类任务则叫分类。先记住这两个名字,后面还会遇见它们。

2. 从一张小表格开始

一条记录里有什么

先来看 6 次打印任务:

记录页数 xx(页)实际耗时 yy(秒)
1537
21047
31566
42081
52592
630112

每一行描述一次任务,我们称它为一条样本。用来作预测的页数叫特征,希望预测的实际耗时叫标签。把这些样本放在一起,就得到一个数据集。

“标签”听起来像一个类别名称,但这里的 37 秒、47 秒同样是标签。你可以把它理解成历史记录中已经知道的答案。模型学习时能看到这些答案;等一份新文档刚提交时,真正的耗时还没有发生,只能先作预测。

这里用 xx 表示页数,yy 表示实际耗时。以后也可以给模型更多信息,例如是否双面、是否彩色。眼下先只用页数,把问题看清楚。

如果亲自收集数据,还要留意单位和缺失记录。例如,某次忘了计时,应该标明缺失,不能填成 0 秒——“没记下来”和“瞬间打印完”可不是一回事。

留一些记录,等会儿再看

假如一个程序背下了上面这张表,遇到 5 页就回答 37 秒,遇到 10 页就回答 47 秒,它在这 6 条记录上可以一题不错。但来了 12 页的文档,它该怎么回答?

所以,我们不能只用模型看过的记录来评价它。开始训练之前,就把本例的 12 条记录分成三份:

名称数量用来做什么
训练集6 条,就是上表让模型从中寻找规律
验证集3 条,稍后再看比较不同方案,帮助我们作选择
测试集3 条,留到最后方案确定后,再检验一次效果

本例直接给出固定划分,三份数据没有重复记录。6∶3∶3 只是为了方便演示,实际项目不必照搬这个比例。

可以把测试集想成一份暂时封存的考卷。如果每改一次模型就翻开答案看一看,再根据成绩继续修改,它就不再是一场独立的考试了。即使文件还叫“测试集”,它也已经影响了我们的选择。

真实数据还多一层讲究。例如,要预测未来的任务,就可以用较早的记录训练、较晚的记录测试;同一次任务的重复副本也不能分散到训练集和测试集中,否则模型相当于提前见过了考题。

3. 让一条直线试试看

先选一个简单的模型

回头看看表格:页数越多,耗时大致也越长。一个很自然的猜法是:

预计耗时 = 每页增加的时间 × 页数 + 一段固定开销。

写成你熟悉的一次函数,就是:

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

这里,xx 仍然是页数;y^\hat{y} 读作“yy 帽”,表示预测耗时,用来和实际耗时 yy 区分。aa 表示每多一页预计增加多少秒,bb 则表示模型中的固定耗时,单位是秒。

这条从输入算出预测值的规则,就是我们的模型。其中,aa 和 bb 是还需要确定的两个参数。我们先选了一次函数的形式,接下来让数据帮助确定它们的数值。

不妨先试 a=3a=3、b=20b=20。打印 5 页,模型会预测:

y^=3×5+20=35 \hat{y}=3\times5+20=35

也就是 35 秒。实际记录是 37 秒,少估了 2 秒,看上去还不错。但只看一条记录可不够,我们得看看它在全部训练记录上的表现。

顺便提醒一句:虽然 bb 可以表示某种固定开销,却不能仅凭拟合结果就认定“这台打印机预热恰好用了 20 秒”。参数描述的是数据中的关系,具体的设备机制还需要另外测量。

给预测打个分

怎样把 6 次预测的好坏合成一个分数?

最直接的想法是,逐条计算“预测值减去实际值”,再取平均。但这样有个问题:一次多估 2 秒、另一次少估 2 秒,加起来就变成了 0。两个错误竟然互相抵消了。

我们换个办法:把每次误差先平方,再求平均。这样得到的分数叫均方误差,英文缩写是 MSE。

对于 a=3a=3、b=20b=20,6 次预测依次为 35、50、65、80、95、110 秒。减去对应的实际耗时,误差就是 −2-2、33、−1-1、−1-1、33、−2-2 秒。因此:

L=4+9+1+1+9+46=286≈4.67 \begin{aligned} L &= \frac{4+9+1+1+9+4}{6} \\ &= \frac{28}{6}\approx 4.67 \end{aligned}

我们用 LL 表示这个分数,也叫损失。在同一份训练集上,损失越小,说明按这个标准衡量的预测误差越小。平方还有一个效果:大误差会被罚得更重。

注意,误差平方后,单位也变成了“秒的平方”。所以这里的 4.67 不能理解成“平均差了 4.67 秒”。稍后我们会换一个更方便用秒解释的指标。

训练:让程序替我们找参数

现在可以让程序帮忙了:尝试一组参数,算出损失,再尝试下一组,最后保留损失最小的那组。

先固定 b=20b=20,只看三个候选值:

aabb训练 MSE(秒²)
220383.83
3204.67
420383.83

这三组里,a=3a=3 明显更合适。后面的代码会多试一些:aa 从 0 到 5,每次增加 0.5;bb 从 0 到 40,每次增加 1。一共是 11×41=45111\times41=451 组参数。

程序逐一比较后,选出的仍然是 a=3a=3、b=20b=20。利用训练数据,按照损失寻找合适参数的过程,就叫训练。 在这个小例子里,所谓“学会了”,最终落实为两个数。

当然,程序只找到了这 451 组中的最佳选择。我们给了它多大的搜索范围、每一步迈多远,都会影响结果;真正更好的数值可能落在两步之间。换了数据,选出的参数也可能变化。

这种逐个尝试的办法叫枚举,优点是容易看懂。等模型有成千上万甚至更多参数时,就不能这样试遍所有组合了。到那时,我们会用梯度下降等更高效的方法。

到这里,模型和训练算法就各司其职了:模型按 y^=ax+b\hat{y}=ax+b 作预测,训练算法负责找到 aa 和 bb。

4. 预测得好不好,总得有个比较

先和一个简单办法比一比

训练损失看起来很小,但这个模型值得用吗?先找一个简单的办法当作比较对象,也就是基线。

我们的基线干脆不看页数,每次都猜训练集中耗时的平均值:

37+47+66+81+92+1126=72.5 \frac{37+47+66+81+92+112}{6}=72.5

也就是每次都猜 72.5 秒。这个办法很粗糙,却能帮我们回答一个实在的问题:加入页数、再训练两个参数,究竟带来了多少改善?

这里的平均值也只能从训练集计算。验证集和测试集里的答案,还要留着做各自的工作。

用验证集选方案

现在打开预留的验证集。两个方案都已从训练集准备好,我们只让它们作预测,不用这些新记录重新拟合参数。

这次用平均绝对误差来比较,英文缩写是 MAE。做法很简单:每条记录先算“差了多少秒”,不管偏大还是偏小,再取平均。它的单位仍然是秒,读起来更直观。

下表除页数外,其余数值都以秒计。最后一列是线性模型的绝对误差。

页数实际耗时基线预测模型预测绝对误差
84372.5441
187672.5742
2810372.51041

算一算,两个方案的验证 MAE 是:

  • 基线:(29.5+3.5+30.5)/3≈21.17(29.5+3.5+30.5)/3\approx21.17 秒。
  • 线性模型:(1+2+1)/3≈1.33(1+2+1)/3\approx1.33 秒。

在这三条记录上,线性模型好得多。于是,我们选它,并固定 a=3a=3、b=20b=20。

你可能已经注意到:训练时用 MSE,这里却用了 MAE。这是可以的。前者让大误差受到更重的惩罚,后者方便我们用秒来理解结果。训练和评估也可以使用同一个指标,关键是知道每个数字代表什么。

最后再看测试集

方案选好了,才轮到此前没有参与训练和选择的测试集。下表仍然以页数作输入,其余三列的单位都是秒:

页数实际耗时预测绝对误差
1258562
2285861
26101983

测试 MAE 为:

2+1+33=2 \frac{2+1+3}{3}=2

也就是说,这个固定下来的模型在这 3 次测试中,平均相差 2 秒。

能据此保证“以后每次最多差 2 秒”吗?不能——表格里就有一次差了 3 秒。平均值并不是上限,也不能保证未来的平均误差恰好还是 2 秒。何况我们只有几条教学数据,足够演示方法,还不足以说明真实打印机上的效果。

真实应用需要更多能代表未来任务的记录。至于差多少秒才算够好,还要看用途:给用户一个大概的等候时间,和精确安排一条生产线,对误差的要求显然不同。

测试成绩也不该变成反复修改模型的提示。如果看过测试结果后继续调整方案,下一轮就需要新的独立数据,才能作出可信的最终评估。

5. 回到那份 24 页的文档

还记得开头的问题吗?现在终于可以回答了。把 x=24x=24 代入已经选好的模型:

y^=3×24+20=92 \hat{y}=3\times24+20=92

预计需要 92 秒。屏幕上那句话,就可以这样产生。

这个使用已训练模型计算输出的过程叫推理,在这个例子里也就是预测。“推理”在这里不一定指人的逻辑推导,它首先是运行模型的计算过程。

这一次计算不会自动改变 aa 和 bb。我们换一个页数,模型就给出另一个预测,参数仍然是原来的两个数。要继续学习新记录,需要另行安排训练。至于这次预测究竟差多少,还得等文档真正打印完才知道。

图 1:训练时,我们用已知答案寻找参数;推理时,把新输入交给选好的模型。

再把这些数字画出来:

图 2:不同形状的点代表已有记录,直线表示模型的预测,星号是 24 页文档的预测值。直线只用训练集拟合;验证点和测试点画在这里,是为了方便比较。

如果希望明天还能运行这个模型,就需要把它保存下来。在本例中,要记住一次函数的形式、两个参数,以及输入输出的单位;适用设备、数据版本和评估结果也值得一并保存。以后遇到神经网络,参数会多得多,但保存模型及其配套配置这件事仍然少不了。

6. 用起来之后,还有什么可能出错

从提出问题到给出预测,我们已经走完了一次小实验。把前面的步骤连起来,就是下面这张图:

图 3:开发过程中可以根据验证结果改进方案。测试集留到方案固定后使用,投入使用后还要继续观察实际效果。

图的最后一步容易被忽略。假如办公室换了打印机,或大家开始大量打印彩色文档,旧模型还合适吗?我们需要把新任务的预测和实际耗时对照起来,看看误差有没有变化。条件变了,就可能需要重新收集数据、训练和评估。

此外,还有三个常见的坑,值得提前认识。

只会做见过的题。 一个模型可能把训练记录里的细节记得非常牢,在训练集上一题不错,换了新数据却表现很差。这可能是过拟合。因此,训练损失越低,不一定意味着模型越好用。

偷偷看到了答案。 假设有人把“最后一页输出的时间”也放进输入,离线实验的分数可能一下子变得漂亮。可提交任务时,根本不可能知道这个时间!这就是一种数据泄漏:训练或评估用到了实际预测时不该知道的信息。前面反复利用测试答案调模型,也会让评估失去独立性。

走到了没见过的范围。 我们的训练记录只有 5~30 页,公式却照样能算 300 页。这个结果可信吗?设备可能中途散热、补纸,耗时不再按同样的规律增长。把预测伸到已有数据范围之外,叫外推。公式算得出来,不代表现有证据足够支持它。

我们希望模型在没参与训练、又符合实际使用场景的新数据上仍然表现良好。这种能力叫泛化能力。留出数据来验证和测试,正是为了了解它学到的规律能走多远。

7. 在自己的电脑上试一遍

先把程序跑起来

这个案例只用 Python 标准库。准备好 Python 3.9 或更高版本,一台普通电脑就能运行。

如果你已经下载了本书仓库,在仓库根目录执行:

python3 src/examples/printing_time.py

也可以下载完整脚本,进入文件所在目录后执行:

python3 printing_time.py

部分环境中的命令叫 python 或 py,使用自己电脑上可用的那个即可。

对着代码,认出刚才的步骤

TRAIN、VALIDATION 和 TEST 保存三份数据。predict 负责预测,mse 和 mae 分别计算两种误差,train 搜索参数,main 则把训练、选择和测试串起来。

读到 range(11) 时,留意它产生的是 0 到 10 的整数;再除以 2,就得到间隔为 0.5 的候选值。float("inf") 是正无穷,用来给“目前最小的损失”设一个初值。** 2 表示平方,abs 取绝对值。

"""导读案例:用页数预测打印耗时。仅使用 Python 标准库。"""

# 每个二元组都是(页数,实际耗时/秒)。数据为教学构造,并非真实测量。
TRAIN = [(5, 37), (10, 47), (15, 66), (20, 81), (25, 92), (30, 112)]
VALIDATION = [(8, 43), (18, 76), (28, 103)]
TEST = [(12, 58), (22, 85), (26, 101)]


def predict(pages, a, b):
    return a * pages + b


def mse(data, a, b):
    """均方误差:逐条计算误差的平方,再取平均。"""
    total = 0.0
    for pages, actual in data:
        error = predict(pages, a, b) - actual
        total += error ** 2
    return total / len(data)


def mae(data, a, b):
    """平均绝对误差:逐条计算误差的绝对值,再取平均。"""
    total = 0.0
    for pages, actual in data:
        total += abs(predict(pages, a, b) - actual)
    return total / len(data)


def train(data):
    # 只用训练集,在 11 × 41 = 451 组候选参数中选择。
    best_loss = float("inf")
    best_parameters = None
    for step in range(11):
        a = step / 2  # 0、0.5、1、……、5
        for b in range(41):  # 0、1、2、……、40
            loss = mse(data, a, b)
            if loss < best_loss:
                best_loss = loss
                best_parameters = (a, b)
    return best_parameters


def main():
    # 基线也只用训练集:无论页数多少,都预测训练耗时的平均值。
    mean_time = sum(actual for _, actual in TRAIN) / len(TRAIN)
    a, b = train(TRAIN)
    print(f"训练得到:a={a:.1f}, b={b:.1f}")
    print(f"训练 MSE:{mse(TRAIN, a, b):.2f} 秒²")

    # 用验证集选择模型方案,不用测试集调参。
    candidates = {"均值基线": (0, mean_time), "线性模型": (a, b)}
    best_name = None
    best_score = float("inf")
    for name, (candidate_a, candidate_b) in candidates.items():
        score = mae(VALIDATION, candidate_a, candidate_b)
        print(f"{name}的验证 MAE:{score:.2f} 秒")
        if score < best_score:
            best_score = score
            best_name = name

    # 方案到此固定,测试结果不再用于修改它。
    a, b = candidates[best_name]
    print(f"选定方案:{best_name}")
    print(f"测试 MAE:{mae(TEST, a, b):.2f} 秒")

    # 对一个尚未实际打印的新任务进行预测。
    print(f"24 页的预测耗时:{predict(24, a, b):.0f} 秒")


if __name__ == "__main__":
    main()

看看结果是否和手算的一样

运行后,你应该看到:

训练得到:a=3.0, b=20.0
训练 MSE:4.67 秒²
均值基线的验证 MAE:21.17 秒
线性模型的验证 MAE:1.33 秒
选定方案:线性模型
测试 MAE:2.00 秒
24 页的预测耗时:92 秒

这份程序没有随机步骤,只要数据和代码相同,结果就相同。如果数字对不上,先回头检查数据、搜索范围,以及平方和绝对值是否写对。

跑通以后,先别急着关掉窗口。试着改一条训练记录,猜猜参数会不会变,再重新运行。把改动前后的结果记下来,你就开始做自己的小实验了。

8. 从两个参数,走向大模型

我们刚才做的事情属于机器学习:利用已有数据寻找一条能用于新任务的规则。它是人工智能中的一类方法;人工智能还包括依靠人工编写规则来完成任务的方法。

如果把一次函数换成多层神经网络,就走进了深度学习。你暂时可以把神经网络理解为许多带有可调参数的计算步骤连接在一起,“深”表示这样的计算有多层。模型复杂了许多,但我们依然需要准备数据、定义学习目标、训练和评估。

再把输入换成文本,把网络和数据规模扩大,就能逐步接近现代大语言模型。当然,仅仅“变大”还不够,后面还有模型结构、数据处理和训练方法等许多问题要学。处理图像、音频等信息的大模型也会在后续章节登场。

不同模型从哪里得到学习的线索呢?本章中,每条训练样本都同时给出了页数和实际耗时,这叫监督学习。另外几种常见方式是:

  • 自监督学习:从数据自身构造答案。例如,给出一句话的前半段,让模型预测后面的文字,用原文中的后续内容检查预测。
  • 无监督学习:没有事先指定的目标标签,尝试从数据里找结构,例如把相似的记录分成几组。
  • 强化学习:采取行动后,根据获得的奖励反馈,学习怎样作出更好的选择。

它们说的是学习线索从哪里来;前面的“回归”和“分类”说的是要完成什么任务,两组名字回答的问题不同。

以逐步生成文字的自回归语言模型为例,文本会先被切成 Token,也就是模型处理的文字片段。一个 Token 可能对应一个字、一个词,也可能只是词的一部分,并不总和日常说的“单词”一致。

训练时,模型根据前面的 Token 预测下一个 Token,再拿原文中的实际后续内容计算损失。生成时,则根据模型给出的概率选择下一个 Token,把它接到已有文本后面,继续下一步。

看起来和打印耗时相距很远,骨子里却仍能认出熟悉的步骤:用输入作预测,拿答案计算损失,再调整参数。不过,接出一句流畅的话,与核实一句话是否真实,是两回事。后面学习大模型应用时,我们还会专门讨论怎样检验事实、借助外部资料,并识别不可靠的回答。

9. 停一下,自己试试

先想一想,或在纸上算一算,再展开答案。

  1. 按刚才的模型,16 页文档预计需要多久?这个数是标签还是预测值?
  2. 训练 MSE 约为 4.67,能说“平均差 4.67 秒”吗?测试 MAE 为 2 秒,能说“每次最多差 2 秒”吗?
  3. 训练得到 a=3a=3、b=20b=20 后,再预测一份新文档,这两个参数会自动变化吗?
  4. 如果一个模型把 6 条训练记录全部记住,训练误差为 0,还需要验证和测试吗?
  5. 把实际打印结束时间也交给模型,能帮助它在提交任务时预测吗?
  6. 修改代码,输出每条测试记录的预测值和绝对误差,再算一次平均值。你是在检查计算,还是在重新训练?
展开参考答案与代码提示
  1. 3×16+20=683\times16+20=68,预计 68 秒。这是预测值,真正打印完后测得的耗时才是这次任务的标签。
  2. 都不能。MSE 的单位是秒²;MAE 虽然保留了秒的单位,但平均值不是上限。本例就有一次测试相差 3 秒。
  3. 不会。本例预测时参数保持不变,要更新参数需要另行训练。
  4. 需要。记住旧记录不代表能预测新记录,还要看它在未参与拟合的数据上的表现,并保留独立测试。
  5. 不能。提交任务时还不知道结束时间,把它放进输入会造成数据泄漏。
  6. 这是在检查固定模型的预测和误差计算。可以在 main 中选定方案后加入:
for pages, actual in TEST:
    predicted = predict(pages, a, b)
    print(pages, predicted, actual, abs(predicted - actual))

你会得到 2、1、3 秒的绝对误差,平均值为 2 秒。

如果还想多试一步:固定 b=20b=20,让 aa 在更细的范围内变化,画出训练损失的曲线。先猜最低点在哪里,再运行程序看看。这次只需要训练集,让测试集继续留作独立评估。

10. 带着这个例子继续往前

到这里,你已经让一条直线从数据中“学”出了两个参数,也亲手检查了它的预测。后面遇到神经网络、注意力机制和 Transformer 时,可以时常回头想想这个小例子:输入是什么?参数在哪里?它在用什么答案学习?我们又凭什么相信它的输出?

接下来,可以继续读函数、向量与矩阵,把一份文档的预测扩展到多个特征和一批任务。学过数学基础后,线性回归会带你用梯度重新训练这个模型;怎样读这本书中有全书的阅读路线。

想看看另一种讲解,可以阅读 Google 机器学习速成课程中的线性回归和训练集、验证集与测试集。带着已经跑通的小例子去读,许多术语会熟悉得多。