Pylearn2 的规矩:训练不写在代码里,写在配置文件里。
它只提供一个 train.py 入口,参数全从一份 YAML 读。
实测真拿 yaml.safe_load 解析:epochs: 1500 认成整数、lr: 0.1 认成浮点、
layers: [64, 32, 16, 10] 认成列表。
按这份配置训练 400 张图、2778 个参数,测试 0.9287。
只改 lr 从 0.02 到 2.0,测试从 0.8775 到 0.9487。
上一节的 Theano 解决「怎么算」,这一节解决「怎么描述一次实验」。 两者当年经常配着用:Theano 当引擎,Pylearn2 当外壳。 这一节的重点不是 Pylearn2 本身(它 2017 年就停了), 而是「配置驱动」这个思路为什么能活到今天。
一、训练写在配置里
先看一份真实的配置长什么样:
dataset:
name: digits8x8
n_per_class: 40
jitter: 1
noise: 0
model:
layers: [64, 32, 16, 10]
act: tanh
train:
epochs: 1500
lr: 0.1
seed: 0
三块内容:数据从哪来、网络长什么样、怎么训练。 这三块恰好就是任何一次实验都绕不开的三件事。 配置驱动的好处不在「代码更短」,在「实验管得住」: 十几个人的实验室共用一个 YAML 目录,谁跑了什么、用了什么超参数,看文件名和内容就知道, 不用去翻 git 历史。
二、解析出来是什么
YAML 不是字符串拼接,它自己认类型:
| 配置路径 | 解析出的值 | 类型 |
|---|---|---|
| dataset.name | digits8x8 | str |
| dataset.n_per_class | 40 | int |
| model.layers | [64, 32, 16, 10] | list |
| model.act | tanh | str |
| train.epochs | 1500 | int |
| train.lr | 0.1 | float |
800 是整数不是字符串,0.1 是浮点不是字符串——这一点很重要。 |
||
因为它意味着写配置的人不用关心类型转换,写 lr: 0.1 就够了。 |
||
| 但也正是这一点埋下了坑,下一段说。 |
三、改参数的成本
配置驱动真正的价值,是把「改一个超参数」的成本压到了最低。
下面同一份数据、同一个网络,只改 train.lr:
| 学习率 | 测试准确率 | 训练准确率 |
|---|---|---|
| 0.02 | 0.8775 | 0.9975 |
| 0.1 | 0.9287 | 1.0000 |
| 0.5 | 0.9275 | 1.0000 |
| 2.0 | 0.9487 | 1.0000 |
| 四行代码一个字没改,只是换了配置文件里的一个数。 | ||
| 幅度不算惊人(七个点),但要注意最好的不是中间那一档,是学习率最大的 2.0。 | ||
| 这跟「自己写代码」有什么不同?不同在扫参的成本。 | ||
| 换代码要改文件、要记得自己改过什么;换配置只改一个数,还能把配置存档。 | ||
| 所以配置驱动不是为了效果好,是为了实验管得住。 |
四、配置的通病:报错往后拖
YAML 只认语法,不认你的意图。写错了,往往要等跑到那一步才炸。三种典型:
| 写错的方式 | 报什么 | 什么时候炸 |
|---|---|---|
| 缺 model.act | KeyError: 'act' | 取这个字段时 |
| lr 写成 "abc" | TypeError | 拿它去乘一个数时 |
| layers 写成 "64-32-16" | ValueError | 逐字符转整数时 |
| 第一条还算好查,KeyError 直接把键名甩给你。 | ||
第二条最磨人:配置解析得好好的,一直到 lr 乘梯度 那一行才报错,报错信息指着梯度那一行, |
||
| 跟真正的原因隔了十万八千里。 | ||
第三条最阴:字符串 "64-32-16" 是能迭代的,于是代码把它一个字符一个字符地转成整数, |
||
转到 - 时抛 ValueError。 |
||
| 报错信息会指着一个跟真实原因毫不相干的字符。 | ||
| 对策是加一层校验:读进来之后立刻检查类型和取值范围,让错误在最早的地方暴露。 | ||
| 现在流行的 hydra 就是在 YAML 外面套了一层结构定义,专门治这个。 |
五、动手 Demo:真解析 YAML、按配置训练、以及三种写错的下场
这一段用的是真的 yaml 库,不是自己写的解析器。 配置里的每个字段都会真的被用到训练里,不是摆样子。 最后一段故意写三份错配置,看它们各自怎么炸——这三条都是真实报错。
import yaml
CFG = u"""
dataset:
name: digits8x8
n_per_class: 40
jitter: 1
noise: 0
model:
layers: [64, 32, 16, 10]
act: tanh
train:
epochs: 1500
lr: 0.1
seed: 0
"""
cfg = yaml.safe_load(CFG)
print(type(cfg['train']['epochs']), type(cfg['train']['lr']))
# <class 'int'> <class 'float'> YAML 自己认类型
# 按配置建模型、训练,一行代码都不改
Xtr, ytr = make_data(cfg['dataset']['n_per_class'],
cfg['dataset']['jitter'],
cfg['dataset']['noise'], seed=0)
m = fit(Xtr, ytr, cfg['model']['layers'][1:-1],
act=cfg['model']['act'],
steps=cfg['train']['epochs'],
lr=cfg['train']['lr'])
# 只改一个数,重跑
for lr in ('0.02', '0.1', '0.5', '2.0'):
c = yaml.safe_load(BASE % lr)
mm = fit(Xtr, ytr, c['model']['layers'][1:-1], lr=c['train']['lr'])
print(lr, acc(Xte, yte, mm))
实跑输出:
--------------------------------------------------------------------
Pylearn2:配置驱动训练,改配置不改代码
--------------------------------------------------------------------
[1] Pylearn2 的规矩:训练不写在代码里,写在配置里
它提供的是 train.py 这一个入口,参数全从一份 YAML 里读。
下面这份配置是真的 YAML,等一下要拿 yaml.safe_load 解析它:
# 数据从哪来
dataset:
name: digits8x8
n_per_class: 40
jitter: 1
noise: 0
# 网络长什么样
model:
layers: [64, 32, 16, 10]
act: tanh
# 怎么训练
train:
epochs: 1500
lr: 0.1
seed: 0
[2] 解析结果就是普通的字典,没有任何魔法
路径 值 类型
dataset.name digits8x8 str
dataset.n_per_class 40 int
model.layers [64, 32, 16, 10] list
model.act tanh str
train.epochs 1500 int
train.lr 0.1 float
注意 train.epochs 解析出来是整数 800,不是字符串。
YAML 会自动认类型:`800` 是数,`0.1` 是浮点,`tanh` 是字符串。
[3] 按配置建模型、训练
配置项 实际用到的值
数据量 400 张
层结构 [64, 32, 16, 10]
参数量 2778
训练轮数 1500
测试准确率 0.9287
[4] 配置驱动的价值:改参数不改代码
学习率 测试准确率 训练准确率
0.02 0.8775 0.9975
0.1 0.9287 1.0000
0.5 0.9275 1.0000
2.0 0.9487 1.0000
四行代码一个字没改,只是换了配置文件里的一个数,测试就从 0.8775 到 0.9487。
幅度不算惊人(七个点),但要注意最好的不是中间那一档,是学习率最大的 2.0。
这跟自己写代码有什么不同?不同在「扫参的成本」:
换代码要改文件、要记得自己改过什么;换配置只改一个数,还能把配置存档。
这就是配置驱动真正的价值——不是为了效果好,是为了实验管得住。
[5] 代价:配置写错了,报错要往后拖很久
缺 model.act KeyError: 'act'
lr 写成字符串 TypeError: can't multiply sequence by non-int of type 'fl
层写成字符串 ValueError: invalid literal for int() with base 10: '-'
三种错法,三种报错,而且都要等跑到那一步才炸:
第一个是缺字段,KeyError 直接把键名甩给你,还算好查。
第二个 lr 是字符串,一直不报错,直到拿它去乘一个数才 TypeError。
第三个最阴:字符串 "64-32-16" 是能迭代的,
于是代码把它一个字符一个字符地转成整数,转到 "-" 时 ValueError。
报错信息会指着一个跟真实原因毫不相干的字符。
YAML 只认语法,不认你的意图——这是配置驱动的通病。
[6] 落盘验证:它真的能当文件用
写到 C:\Users\admin\AppData\Local\Temp\workbuddy-update-x64\_pl2_cfg.yaml
读回来 equal 原字典:True
文件大小 231 字节——比等价的 Python 代码短得多。
Pylearn2 当年就是靠这个:实验室里十几个人共享一个 YAML 目录,
谁跑了什么实验、用了什么超参数,看文件名和内容就知道。
2017 年它停止维护,但它这套「配置即实验记录」的做法被后来的
各种实验管理工具(比如 hydra、wandb 的 sweep)继承了下来。
yaml.safe_load 里的 safe 不是装饰。
yaml.load 能构造任意 Python 对象,等于让配置文件执行代码;
safe_load 只认基本类型,是读取别人给的配置时该用的那个。
训练那一段有个细节:cfg['model']['layers'][1:-1]。
配置里写的是完整结构 [64, 32, 16, 10](含输入输出维),
而 fit 要的是中间的隐层 [32, 16],所以要切掉头尾。
这种「配置的写法和代码的用法不一致」的地方,是配置驱动最容易出错的地方。
最后那张表里,lr 那一行最好玩:看起来四行差别不大,但训练准确率全是 1.0000——
说明这个网络容量足够,学习率影响的只是「收敛到多好」,不是「能不能学会」。
30 秒实验
- 把
model.layers改成[64, 8, 10]再跑。 瓶颈太窄,测试准确率会掉到 0.8 以下——配置改结构也很方便。 - 把
dataset.jitter从 1 改成 0。 训练集没有平移,测试集有,准确率会掉十几个点:这就是数据增强的价值。 - 给配置加一个
train.momentum字段,然后在fit里用上它。 感受一下配置驱动的代价:加一个参数要改两个地方,配置和代码必须同步。
常见误区
- 「配置文件只是把命令行参数挪了个地方。」 不一样。配置文件能存档、能进版本库、能对比两次实验的差异。 命令行参数敲完就没了,三个月后没人记得当时用了什么。
- 「YAML 比 JSON 好写,所以用它。」
好写是真的,但 YAML 的坑也多:缩进错一格就是另一个结构,
yes会被解析成布尔值,1.0和1类型不同。 选它是因为可读性,不是因为它安全。 - 「配置驱动能提升效果。」 它一点都不提升效果。它提升的是「试错效率」——同样的时间能试更多组参数,仅此而已。
自测三问(点开对答案)
1. **为什么 `yaml.safe_load` 比 `yaml.load` 安全?** —— **因为 `yaml.load` 支持标签构造,配置里写一行特殊标记就能构造出任意对象,** **等于让配置文件执行代码。** **`safe_load` 只认字典、列表、字符串、数字、布尔这几个基本类型,** **读别人给的配置时必须用它。** 2. **配置写错了,为什么要尽早校验?** —— **因为错误信息和真实原因隔得越远,排查成本越高。** **`lr` 是字符串这件事,拖到乘法那一行才报错,你会去查梯度计算,** **而真正的问题在配置文件第一行。** 3. **Pylearn2 停了,为什么这套做法还在?** —— **因为「实验要能复现」这个需求没变。** **今天的 hydra、wandb sweep、各种实验管理平台,做的都是同一件事:** **把一次实验的全部参数固化下来,可查、可比、可复现。**配置驱动解决了「参数怎么记」。 下一节的 Caffe 走得更远:连网络结构本身都写成文件,用一份 prototxt 描述每一层怎么连。
- ☐ (本节完,下一节 7.4 Caffe)