数据飞轮转起来后,我的合成数据版本比代码还乱——DVC+MLflow给我装上了可审计刹车

30秒速览

  • 合成数据飞轮跑起来后,版本乱得跟微服务配置一样,靠脑子记生成参数就是玩火。我用DVC把原始数据和合成数据的生成流水线、参数都管成依赖图,每个中间产物都有哈希标签。再配合MLflow把训练实验和数据版本哈希绑死,出问题时能按tag一键定位、三分钟内回滚到安全版本。

那个周五晚上,我盯着训练loss曲线愣是没看懂为什么加了新合成数据效果反而退步了

说实话,当时我特别想骂人。我们团队在做一个小语种的对话意图识别模型,真实标注数据拢共不到2000条,硬是靠着一套合成数据流水线撑到了85%的准确率。流水线的逻辑是这样的:每次模型上线后收集用户真实query,做规则+人工修正生成一批新的合成数据,再和旧数据混合重新训练下一个版本的模型。听起来就是教科书式的数据飞轮,但问题在于——我们从来没给数据做过版本管理。每次合成数据生成脚本一跑,新的CSV直接覆盖旧的;生成参数写在注释里,有时候忘了改注释,下次同事看到还以为用的是上次那套temperature;训练时随手从某个目录拖个文件,文件名长这样:synth_v3_final_2_clean.csv,至于它对应的是哪次模型产出的query、用了什么prompt模板、有没有用back translation增强,全凭脑子记。那个周五,我们把最新一批合成数据加进去重新训练,准确率反跌了1.8%。我翻代码、翻聊天记录、翻文件夹修改时间,折腾了两个小时才定位到问题:这次合成数据生成脚本用了上一版模型的logits输出做了置信度过滤,但那个过滤逻辑有bug,把大量正确的高置信样本也筛掉了。如果我当时能直接找到这批数据的“出生证明”——它来自哪个模型、生成参数是什么、父数据是哪个版本——这个bug可能十分钟就解决了。更让我后怕的是,这还只是我一个人折腾,要是哪天我不在,组里其他人想复现某个模型、或者定位类似问题,估计就只能重新跑一遍全流程了。那次之后我下了个决心:数据飞轮必须要有审计能力,而且不能靠人脑,得靠基础设施。

你可能觉得奇怪,为什么不用Git管数据?我们确实试过。把合成数据CSV放进Git仓库,前几次还行,后来数据量涨到几GB,每次git clone要几分钟,push经常冲突,而且Git只看文件变了,根本不知道“数据A和数据B的生成关系是什么”。更糟的是,训练模型时用的数据集往往是从多个CSV拼出来的,可能是70%上周的合成数据混30%原始标注数据,这个组合关系根本没地方记录。当时我就意识到,数据飞轮带来的版本混乱,不是文件版本的问题,而是谱系的问题。我需要的不只是“这个文件是什么时候改的”,而是“这批数据是怎么来的,它从哪个模型产出,用了什么参数,和哪些其他数据混在一起训练”。换句话说,我需要一个数据审计账本,能回答三件事:每个数据版本的血统是什么、对应的模型实验用了哪些数据、如果新版本出问题能不能一键回滚到上一版。后来选型的时候,我在DVC和Git LFS之间犹豫过,最终选了DVC,原因是DVC不光是存大文件,它天生就是为了数据流水线设计的,而且能跟MLflow的实验追踪打通。这里顺便说一句,我也考虑过Weights & Biases,但因为我们团队已经在用MLflow做实验管理,再引入一个新工具学习成本太高。DVC+MLflow的组合,刚好一个管数据血统,一个管实验血统,配合起来就能把数据飞轮的每一步都变成可追溯的。

我把原始数据和合成数据全塞进DVC流水线,结果发现DVC真正好用的不是大文件管理,而是依赖图

刚开始搭DVC的时候,我犯了个错误:只把它当成大号Git来用。就是dvc init之后,dvc add synth_data_v4.csv,然后git commitdvc push到远程OSS。这样确实能存历史版本了,但依然解决不了血统问题——因为我只存了终产物,并不知道它是怎么来的。直到我开始看DVC的pipeline(即dvc.yaml),才彻底想明白。DVC的pipeline允许你把整个数据生成流程定义成有向无环图(DAG),每个stage有明确的输入、输出和命令,而且DVC会自动计算这些stage的依赖哈希值,只要某个stage的输入、命令或代码变了,对应的输出会被标记为“过期”。这太关键了,因为合成数据的生成过程本身就充满了各种容易变的环节:清洗脚本的逻辑、使用的prompt模板、模型版本、甚至随机种子。把它们全部变成pipeline里的参数,DVC就能帮我追踪每一次变化。举个例子,我定义了一个合成数据生成的stage:


stages:
  generate_synth:
    cmd: python scripts/generate_synth.py --seed 42 --temperature 0.8
    deps:
      - scripts/generate_synth.py
      - data/raw/labeled.csv
      - prompts/v3.txt
    params:
      - synth_params.seed
      - synth_params.temperature
    outs:
      - data/synth/v1.csv:
          persist: true

这个stage把脚本、依赖的原始标注数据、prompt模板都列为依赖,还声明了参数来自params.yaml文件。这样一来,每次我改prompt或者调temperature,DVC都知道这次生成的数据版本会因为参数变化而产生新的哈希值。我后来还加上了输入数据的哈希作为tag,比如dvc commit后我会手动打标签git tag -a "synth_v1.3_model-v2.1",这样一眼就能看出这批合成数据是从model-v2.1产出的。当然,这还只是第一步,因为真实场景里合成数据不是孤立的。我们经常会拿多个版本的合成数据混在一起做实验,比如把上周的合成数据v1.3和本周的v1.4按7:3合并,再和1/3的原始标注数据拼接成最终训练集。如果只靠单独stage的输出来管理,这个组合关系还是会丢失。所以我建了一个更上层的“训练数据组装”stage:


stages:
  build_train_set:
    cmd: python scripts/assemble.py --synth-versions synth_v1.3,synth_v1.4 --ratio 0.7,0.3
    deps:
      - scripts/assemble.py
      - data/synth/v1.3.csv
      - data/synth/v1.4.csv
      - data/raw/labeled.csv
    outs:
      - data/train/v1.3_v1.4_train.csv

这个stage的输入直接依赖前一步生成的两个合成数据文件,所以它的哈希值会自动反映上游的变更,完全不用我手动记。而且每次执行dvc repro时,DVC只会重新运行那些依赖发生变化的stage,如果合成数据没变,就不会重新生成,速度极快。这个设计让我在团队里推广的时候省了很多口舌——别的同事只需要改params.yaml里合成数据的版本号,然后跑dvc repro build_train_set,就能得到可追溯的训练集。更重要的是,每个中间产物的哈希值都记录在dvc.lock文件里,而这个文件我们提交到Git,这样任何人都能通过Git历史找到某个模型训练时所用的确切数据版本。我现在回头看,当初如果用Git LFS单独管理文件,这些依赖关系只能靠文档或者Wiki来维护,迟早会乱。DVC的pipeline给了数据飞轮一个“自动化记账”的能力,这是我推荐它的核心理由。

MLflow把实验跟训练数据版本挂上钩,我才真正敢说“这个模型我可以随时复现”

有了DVC管数据版本,下一个要解决的问题是:训练模型的时候,我用的到底是哪个版本的训练集?之前我们的做法是在代码里写死一个文件路径,每次改数据集,就改代码或者传命令行参数,然后在训练日志里打印一下文件名。这太原始了,而且查找历史实验时你得去翻TensorBoard的文本log,运气不好还可能被滚动覆盖掉。于是我把MLflow接进来,一开始只用来记录超参和指标,后来发现MLflow的log_artifact功能可以直接把DVC的输出文件路径或哈希记录成实验参数。我的做法是:训练脚本启动时,先读当前dvc.lock中生成的训练集文件的md5哈希,然后把这个哈希作为MLflow的一个参数记录下来,同时把训练集CSV本身通过mlflow.log_artifact上传到MLflow的artifact仓库(可以是S3或者本地目录)。这样一来,每个MLflow实验的run里就明确关联了一个数据版本标识符。例如:


with mlflow.start_run() as run:
    # 从dvc.lock中获取训练集哈希
    train_hash = get_dvc_file_hash("data/train/v1.3_v1.4_train.csv")
    mlflow.log_param("train_data_hash", train_hash)
    # 记录训练集artifact
    mlflow.log_artifact("data/train/v1.3_v1.4_train.csv")
    # 训练...
    mlflow.log_metric("accuracy", 0.89)

这个简单的绑定,让实验搜索变得极其高效。后面我们团队想看“哪个模型用了合成数据v1.3”时,直接在MLflow UI里按train_data_hash参数过滤就行。当然,仅仅记录哈希还不够,因为哈希不直观,别人不知道v1.3到底是个啥。所以我后来在DVC里打了个git tag,然后把这个tag也作为MLflow参数记录下来,比如mlflow.log_param("train_data_tag", "synth_v1.3_v1.4_20240510")。这还不够,我还把当时dvc.yamlparams.yaml原样打包上传成MLflow artifact,等于把整个数据生成环境的配置也快照了。这样将来即使代码仓库有过多次commit,我也能基于这个artifact重建出当时的数据流水线环境。

这里有个坑我得提一下。一开始我没注意MLflow artifact和DVC远程存储的分工,导致数据集文件被存了两次——一次在DVC的远程(OSS),一次在MLflow的artifact仓库(S3)。后来做了个优化:只在MLflow里记录DVC的远程存储路径和文件哈希,并不真的上传文件本身,因为那个CSV动辄几百兆,上传两次既慢又浪费存储。我们改成训练脚本里通过DVC Python API获取文件的远程存储URL,然后只记录这个URL到MLflow的参数里。这样需要复现时,可以通过dvc get命令直接从远程拉取对应版本。当然,这需要团队内部约定好DVC远程的地址是稳定的。另一个头疼的问题是多个人并行跑实验时,dvc.lock文件容易冲突。我们的解决办法是规定每个实验使用独立分支,在分支上可以随意dvc repro,合并时只合并Git提交,DVC的缓存通过共享文件系统(NFS)解决,冲突概率就小了很多。这些细节虽然繁琐,但都是让审计基础设施真正落地必须面对的。

那次回滚只用了三分钟,我才意识到之前浪费了多少时间在“找对版本”上

前面聊的都是怎么搭基础设施,接下来讲一个真实案例,你会理解这套东西为什么值钱。某天业务方反馈线上模型出现了严重的badcase:对某类问句的意图识别完全跑偏。我们紧急拉数据下来分析,发现是上周新加的一批合成数据引入了噪声,噪声来源于一个生成prompt的改动——那个prompt里的few-shot示例不小心把两个相似意图搞反了,结果产出了大约1200条有误导性的样本。在没有版本控制的情况下,要定位并回滚到出问题之前的数据版本,基本靠猜和翻备份。但这次我们只用了三分钟。首先在MLflow UI里过滤出最近几个带有train_data_tag的实验,根据发布时间锁定一个候选版本synth_blend_v2.1;然后查看那个实验记录的训练集artifact里的dvc.yamlparams.yaml,发现当时用的合成数据版本是synth_v1.5synth_v1.6,而当前出问题的模型用的是synth_v1.7。很明显,问题就出在v1.7。接下来就是回滚:我直接用dvc checkoutdata/synth/v1.7.csv切回上个版本(其实不需要,只是演示DVC的操作),然后修改params.yaml,把v1.7替换为v1.6,跑dvc repro build_train_set,新训练集就在三秒内重新拼接好了(因为v1.6已经是缓存的,不需要重新生成)。然后重新训练模型,MLflow自动记录新的train_data_hash和tag。业务方拿到回滚后的模型部署,badcase消失了。

如果没有这套东西,我估计又得翻半天聊天记录和代码注释,最后可能直接粗暴地把上周整个数据文件夹恢复过来,但那样会把其他正常的合成数据也退回去,得不偿失。这个案例让我彻底确信,数据飞轮不是把数据闭环跑起来就行,你还得能随时刹车和倒车。更进一步说,这套审计能力对团队协作的价值更大。后来有新同事入职,我让他复现一个月前的一个实验,他只需要两步:git checkout <experiment-tag>,然后dvc pull && dvc repro,所有数据和代码环境就都对齐了。他当时惊叹说“这比我在学校做实验还省事”,我心里暗笑,这都是当初被坑出来的。不过也坦白讲,DVC+MLflow的组合不是没有缺点。DVC的pipeline调试有时候挺反直觉的,比如dvc repro有时候因为缓存命中而跳过执行,但我的代码确实改了,最后发现是cmd里没包含正确的参数引用;MLflow的UI搜索参数如果字段太多会有点慢。但这些小毛病相比它带来的可审计性,我觉得完全值得。现在我在团队里推行的一个原则是:任何一个进入数据飞轮的合成数据版本,都必须有对应的DVC git tag和MLflow实验记录,否则不允许用于模型训练。这听起来严格,但实际上就是多打一行tag和加几行log代码的事情,收益却是能救命的。如果你也在做合成数据或数据飞轮相关的项目,我真心建议你现在就去检查一下:你的数据版本,能追溯到生成它的模型和参数吗?你的同事能在一分钟内复现你三个月前的训练数据吗?如果答案是模糊的,那该给飞轮装刹车了。

本文由 AI 辅助生成,经人工审核后发布。内容由 林默 基于实战经验指导完成。

觉得有用?

林默

全栈开发者,写了8年代码,从jQuery时代一路写到AI Copilot。目前专注AI编程工具链的深度使用和评测,相信好的工具能让开发者事半功倍。喜欢用实际项目验证技术方案,不写没踩过坑的教程。

发表评论