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

配置对象把所有「会变的东西」集中到一处,路径是第一号。 依赖注入听起来像术语,其实就一句:要用的东西从参数进来,不要从外面抓。 scaler 必须跟模型一起存——只存模型不存 mu、sd,是这类项目最常见的静默错误。

📝 本章和前面几章的关系

这一篇对应教材 17.5.3:重新構建。 上一篇把脚本拆成了六个函数,这一篇把它们搬进包,并解决一个会直接导致部署失败的问题: 写死的路径。

一、包的形状

mlapi/
    __init__.py      create_app
    config.py        配置对象
    data.py          读文件、切分
    features.py      标准化
    model.py         训练、存盘、加载、预测
    views/predict.py 路由
wsgi.py              入口

和 17.2 那张目录图一样,只是这次真造出来了。包的形状定了,填肉的时候才不会乱。 data.py 和 features.py 这一篇先没拆开——读文件和标准化加起来不到三十行, 拆成两个文件反而要来回跳。拆不拆看体量,不看「应该有这个文件」。

二、配置对象:会变的东西集中到一处

from dataclasses import dataclass


@dataclass
class Config:
    data_path: str = "data.csv"
    model_path: str = "model.pkl"
    test_ratio: float = 0.7
    alpha: float = 1.0
    n_features: int = 10

五个配置项:data_path、model_path、test_ratio、alpha、n_features。 data_path 这条是关键。上一篇那个脚本把 data.csv 写死在代码里, 这里改成「从一个东西里取」——那个东西可以是默认值、环境变量、或者测试时传进来的字典。 用 dataclass 而不是字典,好处是拼错字段名会立刻报错:cfg.datp_path 会在启动时炸, 而 cfg["datp_path"] 要到运行时才 KeyError。

再加一个 from_env() 类方法,就能让「线上改配置」变成改环境变量,不用改代码、不用重新构建镜像。 第 15 章那个 Dockerfile 里的 ENV 就是干这个的。

三、依赖注入:从参数进来,不要从外面抓

def train(cfg):
    X, y = load_csv(cfg.data_path)
    Xtr, ytr, Xte, yte = split(X, y, cfg.test_ratio)
    Xtr, Xte, mu, sd = scale(Xtr, Xte)
    model = Ridge(alpha=cfg.alpha).fit(Xtr, ytr)
    ...
    with open(cfg.model_path, "wb") as f:
        pickle.dump(bundle, f)

注意 train(cfg):路径从 cfg 里取,函数自己不知道文件在哪。 这叫依赖注入。 听起来像术语,其实就一句话:要用的东西从参数进来,不要从外面抓。「从外面抓」指的是 global、 模块级变量、或者直接读 os.environ。 好处有三个,都不抽象:测试时能传临时路径、同一进程能跑两份配置、换个环境不用改代码。 第三条就是能上线的门槛。

还有一个细节值得看:predict 里把 mu、sd 跟模型一起存进 bundle。

def predict(bundle, feats):
    z = (np.asarray(feats, dtype=float) - bundle["mu"]) / bundle["sd"]
    return float(bundle["model"].predict(z.reshape(1, -1))[0])

只存模型不存 scaler,是这类项目最常见的一处静默错误。预测的时候忘了标准化,或者用了另一套 mu、 sd,结果是错的,但一个错都不报——这正是最难查的那类 bug。

四、真跑一条链:训练、存盘、加载、预测

步骤 实测
训练 训练 309 条、测试 133 条,RMSE 56.7371
存盘 模型文件 0.8 KB
加载 读回来 RMSE 字段还是 56.7371
预测第一条 205.30(真值 151.0)

这条链就是「重新构建」要的东西:训练和预测是两个入口,共用一份配置和一份模型文件。 服务启动时只 load 不 train——训练是离线的事,几秒钟能跑完;预测是线上每秒几十次的事。 这两件事的触发时机完全不同,代码也就该分开。 预测第一条差得挺远(205 对 151),这是正常的: RMSE 56 意味着单条预测浮动几十很常见。模型给的是「平均来看偏多少」,不是「这一条错多少」。

部署之后如果发现某一条预测离谱,先别怀疑模型——看它的特征是不是离群。 下一篇那个「数值超范围」的校验就是为这个准备的。

五、换目录、换文件,代码零改动

场景 数据量 结果
原来的目录 442 条 训练 309 条,RMSE 56.7371
另一个目录 + 新文件 300 条 训练 210 条,RMSE 56.0250
代码改动行数 — 0

上一篇那个脚本做不到这件事:它 open("data.csv"),换目录就找不到文件。 「换个环境就跑不了」的原因,十次里有八次是硬编码的路径。剩下两次通常是写死的端口和写死的密钥。 配置从外面给还有个副作用:测试变容易了。测的时候传一个临时的 Config, 不用去动真实的配置文件——第 11 章那些 fixture 就是这个套路。

六、什么变了,什么没变

东西 变了没有 为什么
算法 没变 还是 Ridge,alpha 也一样
函数体 基本没变 拆出来的逻辑原样搬过来
路径 变了 从写死改成从 cfg 取
输出 变了 print 改成 return 字典
入口 变了 train 与 predict 分开

教材 17.5.3 讲「重新構建」,难点不在写得出来,在忍住不乱改。 看到一段代码觉得别扭,顺手改了,重构就变成了「重构加改需求」,出了问题查不出来。 这一条上一节刚说过,因为它是这一章最常见的翻车方式。 判断标准很朴素:这一次改动,能不能用一句话说清?「把路径参数化」是一句话; 「把路径参数化顺便把算法换成随机森林」就不是了。

七、这一篇之后

现在的 mlapi 能训练、能预测、能换配置,但外面还调不到。 下一篇补上最后两块:schema.py 和 views/predict.py,把 17.2 那张契约变成真接口。 那条线到这儿已经很清楚:脚本、函数、包、服务。每一步只做一件事,每一步都能单独验证。

八、动手 Demo:重新构建:把函数搬进包,把路径从代码里赶出去

这个 Demo 真造一个 mlapi 包,用配置对象把路径参数化,然后跑完整的训练、存盘、加载、预测。 第五节换个目录、换个文件再跑一次,代码零改动——这是这一篇最想证明的一件事。

@dataclass
class Config:
    data_path: str = "data.csv"
    model_path: str = "model.pkl"
    test_ratio: float = 0.7
    alpha: float = 1.0
    n_features: int = 10

    @classmethod
    def from_env(cls):
        return cls(data_path=os.environ.get("DATA_PATH", "data.csv"))

实跑输出:

--------------------------------------------------------------------
重新构建:把函数搬进包,把路径从代码里赶出去
--------------------------------------------------------------------

  ① 重建后的样子
  --------------------------------------------------------------------
  mlapi/
  ├── __init__.py
  ├── config.py
  ├── data.py
  ├── features.py
  ├── model.py
  └── views/
      ├── __init__.py
      └── predict.py
  ! 和上一篇那张目录图一样,只是这次真造出来了。包的形状定了,填肉的时候才不会乱。

  ② 配置对象:所有「会变的东西」集中到一处
  --------------------------------------------------------------------
  配置项 5 个:data_path、model_path、test_ratio、alpha、rows_expected
  ! data_path 这条是关键。
  上一篇那个脚本把 data.csv 写死在代码里,这里改成「从一个东西里取」——那个东西可以是默认值、
  环境变量、或者测试时传进来的字典。
  ! from_env 这个方法让「线上改配置」变成改环境变量,不用改代码、不用重新构建镜像。
  第 15 章那个 Dockerfile 里的 ENV 就是干这个的。

  ③ 依赖注入:把 config 传进去,不 import 全局
  --------------------------------------------------------------------
  ! 注意 train(cfg):路径从 cfg 里取,函数自己不知道文件在哪。
  这叫依赖注入,听起来像术语,其实就一句话:要用的东西从参数进来,不要从外面抓。
  ! predict 里把 mu、sd 跟模型一起存下来,是为了保证训练和预测用同一套缩放参数。
  只存模型不存 scaler,是这类项目最常见的一处静默错误——预测结果不对,但一个错都不报。

  ④ 真跑一条链:训练、存盘、加载、预测
  --------------------------------------------------------------------
  训练完 -> 训练 309 条,测试 133 条,RMSE 56.7371
  存盘 -> 0.8 KB
  读回来 -> RMSE 字段还是 56.7371
  预测第一条 -> 205.30(真值 151.0)
  ! 这条链就是第 17.5.3 讲「重新构建」要的东西:训练和预测是两个入口,共用一份配置和一份模型文件。
  ! 服务启动时只 load 不 train——训练是离线的事,几秒钟能跑完;预测是线上每秒几十次的事。
  这两件事的触发时机完全不同,代码也就该分开。

  ⑤ 换个目录、换个文件,代码一个字不改
  --------------------------------------------------------------------
  另一个目录、300 条数据 -> 训练 210 条,RMSE 56.0250
  代码改动行数 -> 0
  ! 上一篇那个脚本做不到这件事:它 open("data.csv"),换目录就找不到文件。
  「换个环境就跑不了」的原因,十次里有八次是硬编码的路径。
  ! 配置从外面给,还有个副作用是测试变容易了:测的时候传一个临时的 Config,
  不用去动真实的配置文件。第 11 章那些 fixture 就是这个套路。

  ⑥ 重新构建:什么变了,什么没变
  --------------------------------------------------------------------

| 东西 | 变了没有 | 为什么 |
|---|---|---|
| 算法 | 没变 | 还是 Ridge,alpha 也一样 |
| 函数体 | 基本没变 | 拆出来的逻辑原样搬过来 |
| 路径 | 变了 | 从写死改成从 `cfg` 取 |
| 输出 | 变了 | print 改成 return 字典 |
| 入口 | 变了 | `train` 与 `predict` 分开 |

  ! 教材 17.5.3 讲「重新构建」,难点不在写得出来,在忍住不乱改。
  看到一段代码觉得别扭,顺手改了,重构就变成了「重构加改需求」,出了问题查不出来。
  ! 判断标准很朴素:这一次改动,能不能用一句话说清?「把路径参数化」是一句话;
  「把路径参数化顺便把算法换成随机森林」就不是了。

  ⑦ 还剩最后一块:把它接成 API
  --------------------------------------------------------------------
  现在的 mlapi 能训练、能预测,但外面调不到
  下一篇补上 schema.py 和 views/predict.py,把契约变成真接口
  ! 三篇下来,这条线是清楚的:脚本 -> 函数 -> 包 -> 服务。每一步都只做一件事,每一步都能单独验证。
  ! 这也是第 17 章想说的:机器学习项目里,模型训练那部分代码最少,剩下全是为了让它能被人用。

第四节那个 205.30 对 151.0 单条差得远,正常——RMSE 56 说的是整体平均。 第五节换了目录和文件,训练 210 条、RMSE 56.0250,代码一行没改。 第六节那张两列对照,是这一篇的验收标准:只该变该变的。

30 秒实验

  1. 给 Config 加一个 from_env(),用环境变量改 DATA_PATH 再跑一次。
  2. 把 mu、sd 从 bundle 里删掉,看预测结果会错成什么样。
  3. 想一想:为什么依赖注入能让测试变容易?

常见误区

  1. 路径写死在代码里:换目录、进容器立刻 FileNotFoundError。
  2. 只存模型不存 scaler:预测结果错,但一个错都不报。
  3. 重构时顺手改算法:出问题分不清是哪一步引起的。
  4. 启动时训练模型:冷启动从几十毫秒变成几秒。
  5. 配置用字典:拼错键名要到运行时才炸。
自测三问(点开对答案) 1. 依赖注入一句话是什么? —— 要用的东西从参数进来,不要从外面抓(全局变量、模块级变量、环境变量)。 2. 为什么 mu、sd 要跟模型一起存? —— 保证训练和预测用同一套缩放参数;只存模型会导致预测错但不报错。 3. 「重新构建」最容易犯的错是什么? —— 顺手改需求。判断标准是这次改动能不能用一句话说清。

最后一步:把这些接成真接口,加上校验、日志,然后回头看整本书。

相关笔记