起因#
写训练代码的时候,经常从这里开始:
1dataset = load_dataset(...)
2model = build_model(...)
3train(model, dataset)dataset 像是一个天然存在的东西。打开一个 benchmark,下载一个压缩包,解压,加载,开始训练。ML 课程、论文、demo 大多也是从这一步开始。
最近整理旧项目记录,翻到 2018 年刚毕业时做的一套图片和视频采集系统。当时我把它理解成一个爬虫项目——写了 40 多个爬虫,对接各种网站,把图片和视频抓回来给标注团队用。
多年以后再看,才觉得当年那个项目真正在解决的问题是:
这个 dataset 到底从哪里来。
当时在做什么#
2018 年,刚毕业,在一家计算机视觉公司。
公司和有关部门有合作,提供内容审核相关的 CV 能力:鉴黄、鉴恐、暴力内容识别,以及其他图片和视频审核任务。
这些任务都是监督学习。标注团队需要持续获得大量尚未标注的原始图片和视频,进行人工标注,最终供模型训练使用。
原始数据从哪来?从互联网采集。Google 等搜索引擎、社交网站、图片站、视频站,各种数据源。
我当时的角色是这个采集项目的负责人。技术栈是 React + Django + Scrapy + Splash + Selenium + Headless Chrome。最终累计实现了 40 多类爬虫,覆盖搜索引擎、社交网站等不同类型的数据源。
当时我对这件事的理解,更多停留在"怎么把数据抓回来"。
训练数据不是天然存在的#
多年以后再看,我更倾向于把这类项目理解成一个更大的生产链中的一环。
一个工业级的训练数据集,在进入 dataset = ... 这一行之前,可能已经走过了这样一条链路:
flowchart TD
Internet["互联网 / 真实世界"]
Discovery["数据发现"]
Acquisition["数据采集"]
Filter["筛选 / 去重"]
Annotation["人工标注"]
QA["标注质量控制"]
Dataset["Dataset"]
Training["模型训练"]
Eval["评估 / 线上 Error Analysis"]
Internet --> Discovery
Discovery --> Acquisition
Acquisition --> Filter
Filter --> Annotation
Annotation --> QA
QA --> Dataset
Dataset --> Training
Training --> Eval
Eval -->|"补充数据"| Discovery
style Acquisition stroke-width:3px
我当时做的采集系统,就在这条链路非常靠前的位置。
它的直接下游是标注团队。没有原材料,标注团队无法生产新的训练数据。没有训练数据,模型迭代无从谈起。
用一句更直接的话说:Dataset is produced, not given。
这不是什么 insight——做过工业 ML 的人大概都知道。但如果一直只在 training 和 inference 这一段工作,确实容易忽略这条链路上游到底发生了什么。
不只是"数据越多越好"#
没有数据的模型,是无水之源、无本之木。采集系统最直觉的价值也确实是这个:提供足够多的样本。
但样本量只是第一层。
真正决定模型在真实业务中表现的,往往是训练数据是否覆盖了足够多的模式。图片和视频数据的多样性维度很多:
- 不同来源(搜索引擎、社交平台、图片站、视频站)
- 不同画质和压缩方式
- 不同分辨率
- 不同拍摄角度和光照条件
- 不同内容风格和场景
- 边界案例和长尾案例
一个模型即使在已有测试集上效果很好,如果训练数据和测试数据没有覆盖真实业务的分布,上线后仍然会不断暴露问题。
所以爬更多网站的意义,并不只是把数据量从 N 变成 10N。
更重要的是扩大样本空间,补充模型原来没有看到的数据分布。
这是采集系统在整条数据生产链中的第二层价值——不仅提供 quantity,还提供 diversity 和 coverage。
再往下一层是 label quality。对于监督学习,标签本身就是训练信号。所谓 Garbage In, Garbage Out,这里的 garbage 不只是明显标错的样本,还包括:
- 标注标准模糊,不同标注员理解不一致
- 正负样本边界不清晰
- 数据存在系统性 bias
- 某些关键分布严重缺失
- 测试集不能代表线上真实分布
模型不会自动修复训练数据里的系统性缺陷。模型效果不仅取决于 architecture,还取决于它到底看到了什么样的数据。
效果提升不总是来自更 fancy 的模型#
模型结构和训练方法当然重要。但工业 ML 中,效果提升并不等价于"换一个更复杂的模型"。
很多时候更有效的路径是这样的:
flowchart TD
Deploy["模型上线"]
Error["发现线上 failure case"]
Analyze["分析模型在哪种分布上表现差"]
Collect["采集对应类型的数据"]
Annotate["人工标注"]
Retrain["重新训练"]
Evaluate["评估"]
Deploy --> Error
Error --> Analyze
Analyze --> Collect
Collect --> Annotate
Annotate --> Retrain
Retrain --> Evaluate
Evaluate --> Deploy
也就是说,模型迭代本身应该是一个 data feedback loop。
在这个循环里,采集系统承担的角色不是"一次性把数据抓完",而是持续地、有针对性地补充新数据。线上 error analysis 发现某类图片识别率低,就去找这类图片的数据源,抓回来给标注团队,标注完重新训练,再上线观察。
这里面涉及的思想——sample diversity、distribution coverage、error-driven data collection——今天会用更成熟的术语来描述,比如 hard case mining。但需要说清楚:这是多年以后回头总结出的认识,不是 2018 年当时已经形成的完整理论体系。当时并没有什么系统化的数据补充策略,更多是凭业务直觉在做:哪里效果不好,就去找对应类型的数据源,写个爬虫抓一批回来。
从 1 个爬虫到 40+ 个#
如果只需要抓 Google Images,写一个专用脚本可能就够了。
但业务真正需要的不是"把 Google 爬下来",而是持续给标注团队提供足够丰富的数据。这意味着要不断增加新的数据源。
当数据源从 1 个增长到 5 个、10 个、20 个、40 个以上,工程问题发生了变化。
核心问题从:
这个网站应该怎么爬?
变成了:
怎样让下一个网站尽可能容易接入?
这时候才自然产生了 crawler framework 的需求。
一些能力开始在多个爬虫中反复出现:request scheduling、retry、downloading、image/video downloading、JavaScript rendering、browser automation、logging、task management、output normalization。
而单个网站的实现应该只描述自己的差异:URL 从哪里来、如何分页、是否需要 scroll、是否需要 click、图片/视频链接怎么解析、metadata 怎么提取。
这其实是一个通用的 engineering insight:当同一种复杂度开始在多个业务实现中反复出现时,它往往应该从业务代码中下沉到公共层。
Scrapy 的真正价值#
Scrapy 是这个项目的 crawling foundation。
它的经典组件大致是这样的:
1Scheduler
2 ↓
3Downloader(+ Downloader Middleware)
4 ↓
5Spider
6 ↓
7Item Pipeline不打算在这里写 Scrapy 教程。想讲的是它背后的 abstraction。
Scrapy 真正有价值的地方在于:把 crawl lifecycle 和 site-specific logic 分开了。
Spider 关心的是:这个站点如何发现和解析数据。从哪个 URL 开始,怎么翻页,怎么从页面里提取目标信息。
Framework 关心的是:request 怎么调度,失败怎么重试,并发怎么控制,数据怎么进入 pipeline。
这种分离,使得接入一个新的数据源时,开发者只需要写一个新的 Spider,描述这个站点的差异逻辑。Scheduling、retry、downloading、pipeline 这些公共能力由 framework 统一处理。
当站点数量从几个增长到几十个时,这种 abstraction 的价值就变得非常明显。每个 Spider 只关心自己的 parsing logic,不需要重新处理 HTTP 连接管理、错误恢复、数据输出这些重复的事情。
为什么需要真正的浏览器#
传统 web crawler 的 mental model 是这样的:
1URL → HTTP Request → HTML → XPath / CSS Selector → Data在这个模型里,拿到 HTTP response 就等于拿到了用户看到的网页内容。
但到了 2017-2018 年,很多网站已经大量使用 JavaScript 动态渲染。页面里的内容不在 HTML source 里,而是通过 JavaScript 执行之后才出现在 DOM 中。AJAX 加载、lazy loading、infinite scroll、动态 DOM、click 后才出现的数据、scroll 后才发送的请求——这些在当时已经非常普遍。
于是出现了一个关键变化:
HTTP response 已经不再等于用户真正看到的网页。
甚至可以说:网页已经不仅是一份 document,而逐渐变成了一段需要执行的 application。
要拿到用户真正看到的内容,crawler 需要有能力执行 JavaScript,甚至模拟用户行为。
这就是为什么当时会同时用到 Scrapy、Splash、Selenium 和 Headless Chrome。它们不是随意堆砌的技术栈,每一层解决的是不同层级的问题。
Splash、Selenium、Headless Chrome 各自解决什么#
Splash#
Splash 可以理解为 JavaScript Rendering as a Service。
1Scrapy
2 ↓
3Splash(执行 JavaScript)
4 ↓
5渲染后的页面
6 ↓
7Scrapy 继续 parseScrapy 把请求发给 Splash,Splash 在内部执行 JavaScript,把渲染后的 HTML 返回给 Scrapy。对 Spider 来说,拿到的就是一份已经执行过 JS 的完整页面。
它适合的场景是:页面内容需要执行 JavaScript 之后才出现,但不需要复杂的用户交互。
Selenium#
Selenium 解决的已经不只是 rendering,而是 browser automation。
有些网站的数据获取流程是这样的:
1scroll → wait → find button → click → wait → open detail → continue scrolling当数据获取行为越来越接近"模拟一个真实用户在浏览器中操作"时,仅仅拿到渲染后的 HTML 已经不够。需要能够控制浏览器执行一系列动作:滚动、等待、点击、输入、切换页面。
Selenium 提供的就是这种 programmable 的浏览器控制能力。
Headless Chrome#
Headless Chrome 和 Selenium 不在同一个层级。
1Selenium
2 ↓
3WebDriver Protocol
4 ↓
5Chrome / Headless ChromeSelenium 负责发出控制指令。Headless Chrome 是实际执行网页的 browser runtime——它是一个完整的 Chrome 浏览器,只是没有可见的 GUI 窗口。
从 HTTP Client 到 Programmable Browser#
把这几层放在一起看,能看到一条演化路径:
flowchart LR
subgraph layer1 ["静态 HTML"]
HTTP["HTTP Client
(Scrapy Downloader)"]
end
subgraph layer2 ["需要执行 JS"]
Splash["JS Rendering Service
(Splash)"]
end
subgraph layer3 ["需要模拟用户行为"]
Selenium["Browser Automation
(Selenium + Headless Chrome)"]
end
layer1 --> layer2
layer2 --> layer3
爬虫系统的 execution model,从 HTTP client 开始,逐渐扩展到了 programmable browser。
这不是因为技术选型的偏好,而是因为 web 本身在变化。当网页从 document 变成 application,crawler 也不得不从 HTTP client 升级到浏览器。
从 Script 到 Platform#
当 Scrapy + Splash + Selenium + Headless Chrome 构成了 crawling 的底层能力,Django + React 开始承载另一层需求:管理和运营。
Django 和 React 在这个系统里更可能承载的是:任务创建、配置管理、爬虫状态查看、数据源管理、采集结果查看、操作入口。
这里需要区分一件事:用了 React + Django 不等于它就是"平台"。
真正的平台化是:底层复杂度逐渐从使用者的 mental model 里消失。
使用者想要的是"我想增加一个数据源",而不应该每次都去想 Splash worker 怎么启动、Chrome 怎么拉起、Scrapy engine 如何运行。
整个演进可以概括为:
flowchart TD
Script["Script
针对单个网站的专用脚本"]
Framework["Reusable Crawling Framework
公共能力下沉,Spider 只描述差异"]
Platform["Data Collection Platform
任务管理、配置、监控、操作入口"]
Script --> Framework
Framework --> Platform
Script -.- S1["关注点:这个网站怎么爬"]
Framework -.- S2["关注点:怎么让下一个站点容易接入"]
Platform -.- S3["关注点:怎么让非开发者也能使用"]
需求复杂度推动抽象升级。 这条演化路径不是一开始就规划好的,而是随着数据源不断增加、使用者不断变多,自然被推着走到的。
多年以后再看#
当时我并不会把这套系统称为 ML Infra。它首先就是一个服务于计算机视觉业务的数据采集项目。
但多年以后再看,它让我很早就接触到了机器学习系统里一个经常被忽略的事实:模型只是整条生产链中的一部分。
一个完整的 ML 系统,从数据到模型到线上,大致是这样一条链路:
1Data Acquisition → Annotation → Dataset → Training → Model Management → Inference → Online Feedback我后来做的工作更多集中在 training、inference、model management、feature/data pipeline、ML platform。而 2018 年那个项目恰好处在整条链路的另一端:训练数据是怎么产生的。
这种联系只是事后看到的。当时更多是在解决眼前的工程问题:这个网站怎么爬、JavaScript 怎么渲染、40 个爬虫怎么管理。并没有一个宏大叙事在指导当时的决策。
结论#
具体的 Selenium API、Splash 的某个参数、某个 Spider 当初怎么翻页——这些技术细节会随时间失效。我已经记不清大部分了。
但留下来的认识有几条:
Dataset 不是天然存在的。它是一套持续运行的数据生产系统的产物。
数据的价值不仅在 quantity,还在 diversity、coverage 和 label quality。 爬更多网站的意义不是把 N 变成 10N,而是扩大样本空间,补充模型没有看到的数据分布。
工业 ML 的效果优化不等价于 architecture 优化。 很多时候,找到缺失的样本、扩大数据分布、提高标注质量,比换一个更 fancy 的模型更直接地改善业务表现。
当数据源从 1 变成 40+,问题从"写一个爬虫"变成"设计一个扩展机制"。 这是这套系统在工程层面最值得保留的经验。
模型看到的是 tensor。模型工程师看到的是 dataset。但 dataset 再往前,还有采集系统、筛选规则、标注人员、质量控制——远比训练代码复杂得多的一套系统和一群人。
这些年我的工作从数据链路的上游转到了中下游。但回头看,对"训练数据到底从哪里来"这件事最直接的体感,反而来自 2018 年那半年。那 40 多个爬虫各自怎么写的我已经记不清了,但它们让我一直记得一件事:dataset 在进入模型之前,有人在互联网上找到了它们,有人把它们标注出来,有一整套系统把这些环节串在了一起。训练代码里那个不起眼的变量名,从来都不是凭空出现的。
参考资料#
- Scrapy Documentation
- Splash - A javascript rendering service
- Selenium with Python
- Chrome DevTools Protocol
- Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015)