能跑的脚本不等于能维护的代码:零个函数、十一个顶层变量,就是它的全部结构。 重构的标准是输出一个字不变——拆完还得跑出同一批数,才叫没改语义。 重构不修 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 秒实验
- 把
ratio改成 0.8 再跑一次,看训练误差和测试误差怎么变。 - 用
ast数一数你自己写过的一个脚本,看看有几个函数、几个顶层变量。 - 想一想:为什么「先重构、再修 bug」比「一起改」安全?
常见误区
- 重构的时候顺手改逻辑:输出变了,分不清是拆坏还是改对。
- 用 print 代替 return:服务里没人看屏幕,拿不到值。
- 路径写死在代码里:换个目录、进了容器就找不到文件。
- 什么参数都做成参数:等于什么都没参数化。
- 看见 bug 就混着重构一起改:出问题查不出来,也回滚不了。
自测三问(点开对答案)
1. 「脚本」和「程序」的分界线是什么? —— 脚本从上往下执行,程序由函数拼起来;实测那份脚本有 0 个函数、11 个顶层变量。 2. 怎么证明重构没改语义? —— 输出一个字不变。数字对不上就说明动了逻辑。 3. 为什么重构和修 bug 要分开? —— 混在一起出问题查不出原因,也没法单独回滚。函数拆好了,下一篇把它们搬进包,把路径从代码里赶出去。
- ☐ (本篇完,下一篇 17.4 分析脚本产品化(下):重新构建)