OpenResearch 学习曲线与训练曲线绘制指南:从 x 轴选择到种子聚合的完整规范
【免费下载链接】OpenResearchTurn your coding agents into research agents项目地址: https://gitcode.com/GitHub_Trending/op/OpenResearch
本文是 OpenResearch 项目orx-figures技能中关于学习曲线(learning / training curves)绘制的技术指南。它回答两个核心问题:指标随训练如何变化,以及两条运行曲线之间的差距是否大于种子间噪声。文中给出 x 轴公平性判断表、种子聚合与置信带规范、平滑处理的证据边界、常见陷阱清单,并附一份可直接复用的figs/loss_curve.py模板。读完本文,你将能绘制出经得起评审的验证损失曲线,并理解 OpenResearch 仓库中orx_figstyle.py样式模块与orx logs证据链路的底层实现。
学习曲线是论文中被画错频率最高的图,因为它误导人的两种方式在成图之后是看不见的:错误的 x 轴,以及单一种子。本文档(agent-skills/orx-figures/references/curves.md)正是围绕这两条主线展开的完整规范,本文结合仓库源码与真实 demo 数据对其进行深入展开。
先选 x 轴,再谈其他
x 轴决定了比较的含义。优化器steps只有在每条曲线每步做相同工作量的前提下才是公平的轴。一旦批量大小、序列长度、模型规模或硬件在曲线之间发生变化,steps 就不再可比,必须改画真正发生差异的量:
| 比较场景 | 绘制轴 |
|---|---|
| 相同配置、不同种子或代码改动 | Optimizer steps |
| 不同批量大小或序列长度 | 看到的 Tokens(或 examples) |
| 不同模型规模 | FLOPs,或在每条曲线上标注规模后再用 tokens |
| 速度或效率声明 | 墙钟时间,并注明硬件 |
一个"用一半步数收敛"的方法如果每步耗时两倍,它并不更快——而 steps 轴的图恰好掩盖了这一点。因此在 demo/nanochat/evidence/training-metrics.csv 这类真实记录中,除了step外还会同步记录tokens_per_second与total_minutes,正是为了让"按 tokens 还是按时间比较"成为可选项,而不是事后无法回答的问题。
在模板中,x 轴最终被设置为"Tokens seen"(见下文模板ax.set_xlabel("Tokens seen")),并配合si_ticks(ax, "x")把刻度格式化为1.2k / 340M / 7B而非科学计数法——这是 agent-skills/orx-figures/assets/orx_figstyle.py 中si_ticks()函数的行为,tokens 量级动辄百万、十亿,直接打印1e8对读者没有直觉意义。
聚合种子,绝不挑一个
- 至少跑3 个种子;如果效应较小则跑5 个。
- 均值画线,95% 区间以同色低透明度色带表示(样式模块中的
band())。 - 在题注中写明 n(种子数)。
两个关键提醒:
- ±1 标准差带回答的是"种子有多分散",而不是"均值估计得有多准"。必须用区间。这正是
orx_figstyle.py中mean_ci()的实现逻辑:它返回均值与 95% t 区间,而不是标准差带。 - 如果种子在定性上分歧——一个发散、其余没有——那本身就是结果。应把各次运行画成细线展示分歧,而不是把分歧藏进一条宽色带里。
公共网格插值:平均前的必要步骤
种子几乎不会在完全相同的 step 网格上记录日志。平均之前必须插值到公共网格;直接平均参差不齐的数组会静默截断到最短的那条。
mean_ci()的实现揭示了区间背后的细节:
- 使用t 分布临界值而非正态近似:种子数量通常很小,正态近似会低估区间。
_T95字典按自由度表列了双侧 95% 临界值,_t95(df)对自由度向下取整(而不是取最近的键),因为更小的 df 给出更大的 t,区间宁可偏宽也不能高估少数几个种子对均值的解析能力。 - 单种子时返回
(mean, mean, mean)并发出警告 "one seed: the band is a line, not an interval — say 'single seed' in the caption",即要求你如实声明而非暗示更多。 band()内部调用mean_ci()后用fill_between以alpha=0.18绘制同色带。
而模板中的load()函数则处理了插值的边界问题:
first = max(curve[0][0] for curve in curves) last = min(curve[-1][0] for curve in curves) if first > last: raise ValueError(f"{variant}: seeds cover no common range of steps") grid = grid[(grid >= first) & (grid <= last)]只在整个种子共同达到的 step 范围内插值,原因注释里写得很清楚:np.interp对输入范围外会钳制(clamp),如果一条运行提前停止,钳制会为它伪造一条平坦尾巴,并人为收窄色带。若种子覆盖的步数毫无交集,直接raise ValueError,而不是默默画一条无意义的曲线。
平滑是对数据的断言
训练损失有噪声,不平滑的曲线可能难以阅读,但平滑是对证据的一种变换:
- 在题注中始终声明平滑方式,例如 "EMA, α=0.1"。
- 把原始序列以浅色画在平滑线之下。单独一条平滑线会让读者误以为该运行真的那么稳定。
- 绝不平滑已经稀疏的评估指标——那是在真实测量点之间凭空发明数据点。
陷阱速查表
| 陷阱 | 修复 |
|---|---|
| Y 轴截断,让小差距显得决定性强 | 包含真正重要的范围;若缩放了就在题注中说明 |
| 对数刻度轴而不声明 | 在标签上声明。指标跨多个数量级或声明涉及比率时用 log-y;从 4.7 到 1.6 的有界损失更适合线性刻度 |
| 图例遮挡了感兴趣区域 | 在线的右端直接标注(label_ends);图例是查找表,不是默认选项 |
| 单个面板塞太多曲线 | 只展示支撑论断的 4 条;其余放进附录表格 |
| 基线用了调色板颜色 | 基线与 chance level 一律灰色(BASELINE)——颜色只属于被比较的对象 |
| 曲线 x 范围不同却不加说明 | 说明某条为何提前停止(发散、预算耗尽) |
其中"基线与 chance level 用灰色"直接对应orx_figstyle.py中的定义:BASELINE = "#7F7F7F"与MUTED = "#CFCFCF",注释明确指出"颜色属于被比较的事物"。调色板采用 Okabe-Ito 八色(blue/orange/green/red/purple/cyan/yellow/black),对常见色盲与灰度打印都可区分,CYCLE取前六色作为 matplotlib 的 prop_cycle。
label_ends()的实现也印证了"图例是查找表"的立场:它直接在每条线右端用线的颜色标注文本,并按标签自身高度动态计算间距(gap_px = max(min_gap * dpi / 72, max(heights) * 1.15)),标签汇聚时自动推开;线右端的数据点被用于锚定标注,且右 x 限会扩展出恰好容纳标签的宽度——所以模板中si_ticks(ax, "x")必须放在label_ends()之后调用(label_ends会移动右限)。
完整模板:figs/loss_curve.py
模板读取一份由orx logs导出的整洁 CSV(列为variant,seed,step,value)。orx logs是 OpenResearch 的证据读取通道(见 agent-skills/orx-evidence/SKILL.md):orx logs <runId>尾部读取,支持--head、--bytes 200000(默认 64 KB,最大 1 MB)与--range 4096:8192精确字节窗口。仓库中真实存在的 demo/nanochat/evidence/training-metrics.csv 展示了这类 CSV 的形态:phase,step,loss,validation_bpb,tokens_per_second,total_minutes,base 与 SFT 两个 phase 各按 step 记录 loss,与模板要求的 tidy 格式一脉相承。
"""Validation loss over training. Regenerate: python figs/loss_curve.py""" import csv from collections import defaultdict import numpy as np from orx_figstyle import BASELINE, COLUMN, band, figure, label_ends, save, si_ticks, use_style DATA = "figs/loss_curve.csv" def load(path): """-> {variant: (steps, runs)} with runs shaped (n_seeds, n_steps).""" raw = defaultdict(lambda: defaultdict(list)) with open(path) as handle: for row in csv.DictReader(handle): raw[row["variant"]][row["seed"]].append((float(row["step"]), float(row["value"]))) series = {} for variant, seeds in raw.items(): curves = [sorted(points) for points in seeds.values()] grid = np.array([step for step, _ in curves[0]]) # Seeds log on slightly different grids, so interpolate before # averaging — but only across the range every seed actually reached. # np.interp clamps outside its input, which would invent a flat tail # for a run that stopped early and narrow the band around it. first = max(curve[0][0] for curve in curves) last = min(curve[-1][0] for curve in curves) if first > last: raise ValueError(f"{variant}: seeds cover no common range of steps") grid = grid[(grid >= first) & (grid <= last)] runs = np.stack([ np.interp(grid, [s for s, _ in curve], [v for _, v in curve]) for curve in curves ]) series[variant] = (grid, runs) return series def main(): use_style() series = load(DATA) fig, ax = figure(width=COLUMN, ratio=0.72) lines, labels = [], [] for variant, (steps, runs) in series.items(): color = BASELINE if variant == "baseline" else None lines.append(band(ax, steps, runs, color=color)) labels.append(f"{variant} (n={runs.shape[0]})") ax.set_xlabel("Tokens seen") ax.set_ylabel("Validation loss") label_ends(ax, lines, labels) si_ticks(ax, "x") # after label_ends, which moves the right limit save(fig, "figs/loss_curve") if __name__ == "__main__": main()几个需要展开的调用点:
use_style():一次性应用项目的 rcParams(见orx_figstyle.py)。默认无衬线字体栈(Helvetica 指标系列,按机器可用性回退到 TeX Gyre Heros / Liberation Sans / DejaVu Sans),mathtext.fontset默认stixsans与文本字体配对;pdf.fonttype: 42与ps.fonttype: 42嵌入 TrueType 轮廓(arXiv 拒绝 Type 3 字体);字体基准 8pt、刻度标签 7pt、savefig.dpi600。需要衬线时传use_style(family="serif")。figure(width=COLUMN, ratio=0.72):按最终印刷尺寸构建画布。COLUMN = 3.25英寸(双栏论文的一栏,ICML/CVPR/IEEE),TEXT = 5.5(单栏论文文本宽,NeurIPS/article),WIDE = 6.75(双栏论文的figure*两栏宽)。模板用ratio=0.72控制高度。band(ax, steps, runs, color=color):绘制种子均值线与 95% 区间带,基线变体自动用BASELINE灰色。save(fig, "figs/loss_curve"):同时写 PDF(供\includegraphics)与 SVG(供预览),不使用bbox_inches="tight"——裁切会改变物理宽度,破坏按最终尺寸构建的原则。保存后执行审计:检查印刷宽度是否为已知列宽、Type 42 字体嵌入、多余的 axes 标题、缺失轴标签、低于 5pt 的文本、文本互相重叠或跑出画布,结果打印为figure audit <stem>: clean或列出问题清单。
运行模板
根据 agent-skills/orx-figures/SKILL.md 的说明:
mkdir -p figs && orx skill figures/assets/orx_figstyle.py > figs/orx_figstyle.py把样式模块 vendored 到图脚本旁边,保证会话结束后图仍可复现(报告脚本放在 artifacts 目录下时,那里需要自己的副本,否则 import 会失败;重定向会先创建目标文件再执行查找,导入前请确认文件非空)。独立绘图(不导入项目代码)用 uv 运行:
uv run --no-project --with matplotlib --with numpy python figs/loss_curve.py若脚本内联了依赖元数据,可用uv run --no-project figs/loss_curve.py让 uv 读取元数据。导入项目代码的绘图必须在项目环境中运行,而不是上面的隔离环境。
题注与图的位置
模板的save()输出到figs/,这是论文目的地。在 LaTeX 中以width=\linewidth引入:
\begin{figure}[t] % figure* for a WIDE figure in a two-column paper \centering \includegraphics[width=\linewidth]{figs/loss_curve.pdf} \caption{Validation loss over training, mean of 5 seeds with 95\% intervals.} \label{fig:loss} \end{figure}按列宽构建并且写width=\linewidth,两者缺一不可:尺寸正确时\linewidth的缩放系数恰为 1.0,不会改变任何东西,但在目标期刊栏宽比预期窄时仍能适配;它救不了按错误尺寸构建的图——15 英寸画布塞进 5.5 英寸栏会被缩到 0.35,11pt 刻度标签落到 4pt。
题注要"随图一起写":以一句加粗的论断开头,再补上读者信任它所需的细节——种子数、色带含义、任何平滑、归一化、拟合了哪些点/排除了哪些点、前沿线是实测还是示意。单面板图约 25–40 词。曲线类图尤其要在题注中写清"均值 ± 95% 区间、n=5、EMA α=0.1"这类方法事实。
交付前清单
- x 轴对参与比较的运行是公平的,且带单位标注
- ≥3 个种子的均值 + 95% 色带,n 写在题注里
- 任何平滑都在题注中声明,且原始序列可见
- 基线是灰色;彩色曲线最多 4 条
- y 范围诚实,或缩放已在题注中声明
对照orx_figstyle.py的审计逻辑,还有两条额外约束值得记住:axes 标题(ax.set_title)在论文图上会被审计标记为"与题注重复"而判为缺陷;标签若压在数据点或参考线上导致与刻度标签重叠,应移动它或给它backgroundcolor="white"。最后,打开生成的 PDF 按印刷尺寸阅读——屏幕上 100% 都看不清的刻度标签,印在纸上同样看不清。
【免费下载链接】OpenResearchTurn your coding agents into research agents项目地址: https://gitcode.com/GitHub_Trending/op/OpenResearch
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考