🧪 第17章 机器学习 API 的开发程序与实践
📄 一句话概括

能跑的脚本不等于能维护的代码:零个函数、十一个顶层变量,就是它的全部结构。 重构的标准是输出一个字不变——拆完还得跑出同一批数,才叫没改语义。 重构不修 bug,但它让 bug 变得看得见:一个没有随机的 split 函数,读者一眼就看出问题。

📝 本章和前面几章的关系

这一篇对应教材 17.5 到 17.5.2:编写分析脚本的产品程式码、程式码解说、函數劃分/模組劃分。 前两篇在选模型、定接口,这一篇处理最难的一块:把一份能跑的分析脚本,改成能上线的产品代码。

一、先看它长什么样

import csv
import numpy as np
from sklearn.linear_model import Ridge

X = []
y = []
with open("data.csv", encoding="utf-8") as f:
    rd = csv.reader(f)
    head = next(rd)
    for row in rd:
        X.append([float(v) for v in row[:-1]])
        y.append(float(row[-1]))
X = np.array(X)
y = np.array(y)

n = len(y)
cut = int(n * 0.7)
Xtr, ytr = X[:cut], y[:cut]
Xte, yte = X[cut:], y[cut:]

mu = Xtr.mean(axis=0)
sd = Xtr.std(axis=0)
Xtr = (Xtr - mu) / sd
Xte = (Xte - mu) / sd

m = Ridge(alpha=1.0)
m.fit(Xtr, ytr)
p = m.predict(Xte)
rmse = float(np.sqrt(np.mean((yte - p) ** 2)))

print("样本数", n)
print("训练", cut, "测试", n - cut)
print("测试 RMSE %.4f" % rmse)

三十行,跑得通,输出四个数:样本 442、训练 309、测试 133、RMSE 52.2940。 这就是「分析脚本」的典型样子:打开就跑,跑完看结果,结果贴进报告。做研究、做报表,这样够了。 但要做成一个每天都得跑、还得给别人调的服务,这三十行有四个地方会出事。 这一篇就是把它们一个个找出来。

二、数一数它到底有几个函数

指标 实测
顶层语句 21 条(其中赋值 15 条)
函数定义 0 个
顶层变量 11 个
变量名 X、y、Xtr、Xte、cut、m、mu、sd、n、p、rmse

零个函数、十一个顶层变量。这是「脚本」和「程序」的分界线:脚本从上往下执行,程序由函数拼起来。 这些数字不是看出来的,是用 ast 模块把源码解析成语法树之后数出来的。 代码长什么样,是可以量的。 顶层变量不是罪,问题在于它们是全局的:任何一段代码都能改 X,改完后面全受影响, 而且你从名字上看不出谁改过。

三、四个症结

症结 具体表现 后果
全局状态 X、y、m 都是顶层变量 没法同时跑两份数据
路径硬编码 open("data.csv") 换个目录就找不到文件
print 不是 return 结果只打到屏幕上 别的代码拿不到这些数
无法单测 没有函数可调 只能整体跑一遍看输出

第一条最致命:想同时训练两个模型?做不到。第二个模型会把第一个的 m 覆盖掉,而且不留痕迹。 第二条看着小事,实际上会把部署搞崩:容器里的工作目录不是你家目录, open("data.csv") 直接 FileNotFoundError——第 15 章那个镜像里根本没这个文件。 第三条是「脚本」和「库」的分水岭: print 是给人看的,return 是给代码用的。服务里没人看屏幕,它要的是一个能塞进 JSON 的字典。

第四条是前三条的后果。没有函数,就没有「一个单元」可测; 只能整体跑一遍,看输出对不对——这不叫测试,叫肉眼验收。

四、拆成六个函数

函数 参数 做什么
load_csv path 读文件,返回 X、y、列名
split X, y, ratio 切成训练与测试
scale Xtr, Xte 标准化,返回 mu、sd
train Xtr, ytr, alpha 拟合模型
evaluate model, Xte, yte 算误差,返回字典
main path 串起来,返回汇总字典

六个函数,参数都是显式传进来的。 这就是从「全局状态」到「参数传递」的转变:函数不知道自己被谁调、也不知道文件在哪, 它只知道「给我这些,我算给你那个」。 main() 返回一个字典,print 挪到了 if __name__ == "__main__" 那一段。 返回字典是为了给服务用,打印是为了给人看,两件事分开。 参数化不是把什么都做成参数。ratio=0.7、alpha=1.0 给了默认值,因为这两个确实会变; 而列名、文件编码这些不变的,就别往参数里塞。

判据是:这个值有没有可能在不同场景下取不同的值?有可能就做参数,没可能就写死。 全都做成参数,等于什么都没参数化。

五、行为一个字不改

拆完之后真跑一遍,输出和原来那三十行对比:

项 原脚本 拆完之后
样本数 442 442
训练/测试 309/133 309/133
测试 RMSE 52.2940 52.2940
前三个系数 -0.165、-12.663、25.321 同样的三个数

这就是重构的标准:输出一个字不变。拆成六个函数之后,结果和原来完全一致——说明拆的只是结构, 没动语义。 这一条比什么都重要。 重构的时候顺手改逻辑,是最容易出事的做法:改完输出变了, 你分不清是「拆坏了」还是「本来就想改」。 所以顺序永远是:先拆(输出必须一样),再改(输出可以不一样,但要单独说明)。

六、顺手发现一个 bug:它没打乱

拆完之后, split 这个函数的签名是 split(X, y, ratio=0.7)——里面没有随机。它按顺序切前 70% 和后 30%。 第 16 章讲过这个坑:不打乱就切分,训练集和测试集的分布可能不一样,评估结果会偏。 原脚本里这个坑一直存在,只是没人注意——因为它跑得出结果,只是结果偏。 这就是重构的第二个收益:它不修 bug,但它让 bug 变得看得见。一个叫 split 的函数里没有随机, 读者一眼就会问「为什么不打乱」。

实测补上 shuffle 之后:RMSE 从 52.2940 变成 56.7371,高了 8.5%。 方向是「顺序切更好看」。最可能的原因是:顺序切出来的训练集和测试集来自同一批相邻样本, 相似度天然比随机抽样高,模型在「和自己像的数据」上表现自然更好。 换句话说,原脚本那个数是虚的——不是模型强,是考题简单。 这种情况在真实项目里极常见,而且很难被发现,因为数字看着挺漂亮。

七、重构与修 bug 要分开

步骤 输出 说明
1 拆函数 必须完全一致 证明只改了结构
2 加 shuffle 变了(52.29 到 56.74) 这是行为变更,要单独提交

混在一起做,出了问题你不知道是拆函数拆坏的还是改逻辑改坏的。 分开做,每一步都能单独验证、单独回滚。 这也是版本管理的意义:一个提交只做一件事,提交信息一句话能说清。「拆成六个函数」是一件事; 「拆成六个函数并修正切分」是两件事。 教材 17.5.2 讲「函數劃分/模組劃分」,讲的是怎么拆; 这一篇多讲了一步:拆完之后怎么证明「只是拆」。

八、这一篇之后

脚本拆成函数,函数还是散在一个文件里。下一篇把它们搬进包,再把写死的路径赶出去。 那是「重新构建」,也是这一章最费神的活——难点不在写得出来,在忍住不乱改。

九、动手 Demo:读懂一份分析脚本:三十行里的四个症结

这个 Demo 先真跑一遍那份三十行的脚本,再用 ast 数它的结构,然后拆成六个函数, 最后补上 shuffle 看结果怎么变。 第五节那两行对比是这一篇的核心:拆完输出必须一模一样。

def load_csv(path):
    X, y = [], []
    with open(path, encoding="utf-8") as f:
        rd = csv.reader(f)
        next(rd)
        for row in rd:
            X.append([float(v) for v in row[:-1]])
            y.append(float(row[-1]))
    return np.array(X), np.array(y), head[:-1]


def split(X, y, ratio=0.7):
    cut = int(len(y) * ratio)
    return X[:cut], y[:cut], X[cut:], y[cut:]

实跑输出:

--------------------------------------------------------------------
读懂一份分析脚本:三十行里的四个症结
--------------------------------------------------------------------

  ① 先跑一遍:它确实能出结果
  --------------------------------------------------------------------
  退出码 0
    样本数 442
    训练 309 测试 133
    测试 RMSE 52.2940
    前三个系数 [ -0.165 -12.663  25.321]
  ! 三十行,跑得通,输出四个数。这就是「分析脚本」的典型样子:打开就跑,跑完看结果,结果贴进报告。
  ! 做研究、做报表,这样够了。
  但要做成一个每天都得跑、还得给别人调的服务,这三十行有四个地方会出事。

  ② 数一数它到底有几个「函数」
  --------------------------------------------------------------------
  顶层语句 21 条,其中赋值 15 条
  函数定义 0 个
  顶层变量 11 个 -> X、Xte、Xtr、cut、m、mu、n、p、rmse、sd、y
  ! 零个函数、11 个顶层变量。这是「脚本」和「程序」的分界线:脚本从上往下执行,程序由函数拼起来。
  ! 顶层变量不是罪,问题在于它们是全局的:任何一段代码都能改 X,改完后面全受影响,
  而且你从名字上看不出谁改过。

  ③ 四个症结
  --------------------------------------------------------------------

| 症结 | 具体表现 | 后果 |
|---|---|---|
| 全局状态 | `X`、`y`、`m` 都是顶层变量 | 没法同时跑两份数据 |
| 路径硬编码 | `open("data.csv")` | 换个目录就找不到文件 |
| print 不是 return | 结果只打到屏幕上 | 别的代码拿不到这些数 |
| 无法单测 | 没有函数可调 | 只能整体跑一遍看输出 |

  ! 第一条最致命:想同时训练两个模型?做不到。第二个模型会把第一个的 m 覆盖掉,而且不留痕迹。
  ! 第二条看着小事,实际上会把部署搞崩:容器里的工作目录不是你家目录,
  open("data.csv") 直接 FileNotFoundError——第 15 章那个镜像里根本没有这个文件。
  ! 第三条是「脚本」和「库」的分水岭:print 是给人看的,return 是给代码用的。
  服务里没人看屏幕,它要的是一个能塞进 JSON 的字典。

  ④ 拆成六个函数,行为一个字不改
  --------------------------------------------------------------------
  退出码 0
    样本数 442
    训练 309 测试 133
    测试 RMSE 52.2940
    前三个系数 [-0.165, -12.663, 25.321]
  前四行和原脚本一致 -> 是
  系数也一致(一边打印成 numpy 数组、一边打印成列表,数字相同)
  ! 这就是重构的标准:输出一个字不变。
  拆成六个函数之后,结果和原来那三十行完全一致——说明拆的只是结构,没动语义。
  ! main() 返回一个字典,print 挪到了 __main__ 那一段。
  返回字典是为了给服务用,打印是为了给人看,两件事分开。
  ! 函数签名里的 ratio=0.7、alpha=1.0 是默认值,调用方想改就传参。
  参数化不是把什么都做成参数,只把「确实会变」的那几个提出来。

  ⑤ 顺手发现一个 bug:它没打乱
  --------------------------------------------------------------------
  函数清单 -> load_csv、split、scale、train、evaluate、main
  load_csv   参数 1 个 -> path
  split      参数 3 个 -> X、y、ratio
  scale      参数 2 个 -> Xtr、Xte
  train      参数 3 个 -> Xtr、ytr、alpha
  evaluate   参数 3 个 -> model、Xte、yte
  main       参数 1 个 -> path
  ! 看 split:它按顺序切前 70% 和后 30%。第 16 章讲过,这就是那个「不打乱」的坑。
  ! 原脚本里这个坑一直存在,只是没人注意——因为它跑得出结果,只是结果偏。
  拆成函数之后它变得显眼了:一个叫 split 的函数里没有随机,读者一眼就会问「为什么不打乱」。
  ! 这就是重构的第二个收益:它不修 bug,但它让 bug 变得看得见。

  ⑥ 改 bug 要单独一步:加一行 shuffle
  --------------------------------------------------------------------
  退出码 0
    样本数 442
    训练 309 测试 133
    测试 RMSE 56.7371
    前三个系数 [-4.141, -11.361, 29.628]
  打乱前后 RMSE -> 52.2940 变成 56.7371
  相对变化 8.5%,方向是变差
  ! 同一个模型、同一份数据,只是切分方式变了,误差就变了——顺序切出来的那个数比打乱之后低 8.5%,
  也就是原脚本报的成绩比真实的好看。
  ! 最可能的原因是:顺序切出来的训练集和测试集来自同一批相邻样本,相似度天然比随机抽样高,
  模型在「和自己像的数据」上表现自然更好。这不是模型强,是考题简单。
  ! 关键在纪律上:重构和修 bug 要分成两次提交。
  混在一起,出了问题你不知道是拆函数拆坏的还是改逻辑改坏的。
  ! 这一节的做法就是标准流程:先重构(输出不变,证明没改语义),再单独改行为(输出变了,可对比、
  可回滚)。

第二节那三个数(21 条语句、0 个函数、11 个变量)是用 ast 数出来的,不是估的。 第五节四个对比项全部一致,只有系数那一行打印格式不同(数组和列表),数字一样。 第六节 52.2940 到 56.7371,方向是变差,也就是原脚本偏高看了自己。

30 秒实验

  1. 把 ratio 改成 0.8 再跑一次,看训练误差和测试误差怎么变。
  2. 用 ast 数一数你自己写过的一个脚本,看看有几个函数、几个顶层变量。
  3. 想一想:为什么「先重构、再修 bug」比「一起改」安全?

常见误区

  1. 重构的时候顺手改逻辑:输出变了,分不清是拆坏还是改对。
  2. 用 print 代替 return:服务里没人看屏幕,拿不到值。
  3. 路径写死在代码里:换个目录、进了容器就找不到文件。
  4. 什么参数都做成参数:等于什么都没参数化。
  5. 看见 bug 就混着重构一起改:出问题查不出来,也回滚不了。
自测三问(点开对答案) 1. 「脚本」和「程序」的分界线是什么? —— 脚本从上往下执行,程序由函数拼起来;实测那份脚本有 0 个函数、11 个顶层变量。 2. 怎么证明重构没改语义? —— 输出一个字不变。数字对不上就说明动了逻辑。 3. 为什么重构和修 bug 要分开? —— 混在一起出问题查不出原因,也没法单独回滚。

函数拆好了,下一篇把它们搬进包,把路径从代码里赶出去。

相关笔记