原始网络抓取数据几乎从来不会以模型可直接使用的状态出现。抓取数千个产品页面、列表或文章,你得到的是重复行、存储为字符串的价格、五种格式的日期、空白单元格,以及充斥着 HTML 实体和杂乱空白的文本。将这些数据直接馈送给训练任务,模型学习噪声的速度与学习信号一样快。将抓取结果转化为数据集的工作就是数据清洗与结构化,而 AI 或 ML 流程的绝大部分准确性,正是在这里赢得或失去的。
本指南是针对 AI 场景的网络数据结构化与清洗实战演练:加载原始抓取结果、去重、规范化类型和格式、处理缺失值、进行基本的文本清洗和分词、设计下游代码可以依赖的模式,并在数据到达模型之前完成验证。每个步骤都附有可粘贴到 notebook 中运行的 Python 代码。最后我们会说明 Crawlbase 如何提前返回干净的结构化输出,减少你手动完成这些工作的量。
为何清洗和结构化决定模型质量
模型不像你一样推理数据。重复行会为某个样本增加额外权重。存储为 "$1,299.00" 的价格是一个字符串,模型无法将其与 1299.0 进行比较。在某些行写成 "03/04/2025"、在另一些行写成 "2025-04-03" 的日期会变成两个不相关的 token。这些问题没有一个会报错,这正是它们危险的原因:流程照常运行,指标看起来合理,而模型却在悄悄地从被破坏的世界观中学习。
清洗修复明显的损伤(重复、缺失值、格式不一致),结构化则强制执行一个契约:每一列有且只有一种类型、一个单位和一个含义。这个契约让同一份数据集既能今天馈送给分类器,明年又能馈送给另一个模型,没有任何意外。无论你是在数千行上做 用于机器学习的网络抓取,还是在数百万行上运行 大规模网络抓取任务,同样的原则都适用。
从真实的原始抓取数据开始
为使步骤具体可操作,假设你抓取了一批电商产品列表并将其写入 raw_products.csv。真实的抓取结果是杂乱的,所以下面的文件也是如此:重复行、price 中带货币符号和千位分隔符、混合日期格式、空白单元格,以及包含 HTML 实体和参差不齐空白的评论文本。
import pandas as pd df = pd.read_csv("raw_products.csv") # A first look at the damage before touching anything print(df.shape) print(df.dtypes) print(df.isna().sum()) print(df.head())
dtypes 的输出是关键线索。如果 price 返回的是 object 而不是数字类型,说明 pandas 无法解析它,意味着该列中存在非数字的垃圾内容。提前运行 isna().sum() 可以告诉你哪些列有缺失值以及严重程度,这样你就能在编写任何转换之前决定要修复什么。
首先去重
去重应在所有其他操作之前进行,因为重复项会使后续的每个统计值都虚高。精确重复是最简单的情况:爬虫重复访问同一 URL 或在重叠窗口中分页时产生的完全相同的行。
# Drop fully identical rows df = df.drop_duplicates() # Deduplicate on a business key, keeping the most recent capture df = (df.sort_values("scraped_at") .drop_duplicates(subset=["product_id"], keep="last"))
实践中第二种模式更重要。一旦价格发生变化,同一 product_id 的两次抓取就不再是完全相同的行,但你通常只需要每个产品一条记录,而不是两条。按抓取时间排序并保留 "last",可以获得最新版本。选择能真正标识你领域中实体的键(产品 ID、URL、SKU),而不是依赖全行相等。
这里的顺序不是装饰性的。如果你先填充缺失值再去重,你的填充均值是基于虚高的计数计算的,并且偏向于重复最多的那些实体。请务必在后续步骤依赖的任何统计量(均值、中位数、众数)之前先删除重复项。
规范化类型和格式
每个实体一行之后,让每一列都变成单一的、可预期的类型。这一步将 "$1,299.00" 转换为 1299.0,将五种日期格式统一为一种。货币字符串需要去掉符号和分隔符才能解析为数字;日期需要使用带 errors="coerce" 的单一解析器,使无法解析的乱码变成 NaT 而不是导致运行崩溃。
# Strip currency symbols and separators, then parse to float df["price"] = (df["price"] .astype("string") .str.replace(r"[^\d.]", "", regex=True)) df["price"] = pd.to_numeric(df["price"], errors="coerce") # Parse mixed date formats into one datetime type df["listed_on"] = pd.to_datetime( df["listed_on"], errors="coerce" ) # Normalize a categorical column: trim and lowercase df["category"] = df["category"].str.strip().str.lower()
规范化分类变量是这里悄然取得的收益。抓取数据常常将 "Electronics"、"electronics " 和 "ELECTRONICS" 作为同一类别的三个不同值。去除首尾空格并转为小写,就将它们合并为一个,从而得到更干净的分组结果,编码后也只有一个特征而不是三个。对单位也做同样的标准化:如果某些价格用美元,另一些用美分,现在转换为统一单位,趁你还记得哪个是哪个的时候。
有意识地处理缺失值
缺失数据没有通用规则,只有权衡。当缺口很少且该行没有该字段就毫无意义时,删除行是安全的。填充保留了行但创造了一个值,所以只有在下游需要该列且存在合理估计的情况下才有意义。按列逐一决策,而不是对整个数据框应用一个笼统的调用。
# Drop rows missing a field the record cannot exist without df = df.dropna(subset=["product_id", "price"]) # Impute a numeric column with its median (robust to outliers) df["rating"] = df["rating"].fillna(df["rating"].median()) # Fill a categorical with an explicit sentinel, not a guess df["brand"] = df["brand"].fillna("unknown")
对于填充数值列,中位数优于均值,因为少数极端离群值(错误抓取的价格 99999)会拉动均值,但几乎不会移动中位数。对于分类变量,明确的 "unknown" 哨兵值是诚实的:它告诉模型该值缺失,而不是假装它属于最常见的那个类别。永远不要让下游编码器悄悄地将 NaN 当作真实类别处理。
清洗文本并分词
如果你的数据集包含自由文本(产品描述、评论、文章正文),它需要单独处理。抓取的文本包含 HTML 实体(&、')、残留标签、URL 和不一致的空白。在分词之前先清洗它,否则 token 中会充满垃圾。此处的分词是指将文本拆分为模型消费的单元;下面的示例采用空白分词,足以说明必须在此之前进行的清洗工作。
import re import html def clean_text(value): if pd.isna(value): return "" text = html.unescape(str(value)) # & -> & text = re.sub(r"<[^>]+>", " ", text) # strip tags text = re.sub(r"http\S+", " ", text) # strip URLs text = re.sub(r"\s+", " ", text) # collapse whitespace return text.strip().lower() df["review_clean"] = df["review"].apply(clean_text) df["tokens"] = df["review_clean"].str.split()
clean_text 中的顺序是刻意设计的:先反转义实体,这样 &amp; 就变成 & 再执行任何正则表达式;接着去除标签和 URL;最后合并空白,使前几步留下的间隙得以闭合。最后的小写转换使 token 计数保持准确("Fast" 和 "fast" 变成同一个 token)。对于真实的 NLP 工作,你会将最后的 split 替换为专业的分词器,但上面的清洗是抓取数据始终需要的部分。
上面大部分实体去除操作的存在,是因为原始 HTML 抓取结果噪声很大。Crawling API 可以返回干净的结构化输出(包括页面的 Markdown 视图),使文本无需标签和样板文字即可到达,将清洗步骤缩减为类型规范化。在免费套餐对公开页面测试,与原始抓取结果进行对比。
设计模式并加以执行
到目前为止,清洗都是被动的。模式让它变成一个契约:一组声明的列,每列有一种类型,每个批次都必须满足。将模式编码为你要转换的类型(以及你要检查的断言),意味着下一次抓取要么符合要求,要么会大声失败,而不是悄悄漂移回你刚刚清洗过的那种混乱状态。
# A schema is just a column -> dtype contract schema = { "product_id": "string", "category": "category", "price": "float64", "rating": "float64", "brand": "string", "listed_on": "datetime64[ns]", } # Keep only schema columns, in order, and cast each one df = df[list(schema.keys())].astype(schema)
选取 list(schema.keys()) 可以丢弃爬虫添加的任何多余列,并固定列顺序,使每次导出具有相同的结构。astype(schema) 调用会转换每一列,如果某个值无法强制转换则会抛出异常,这正是你希望的行为:现在大声失败胜过在训练运行后才发现损坏的列。对 category 等低基数字段使用 category dtype 还可以缩小内存占用,并加快对大型数据框的分组操作。
导出前验证
验证是"看起来干净"与"确实干净"之间的关卡。少量断言可以捕获那些悄无声息地毒害模型的失败:残留重复项、超出范围的数字、本应完整的列中的空值。在每个批次上运行它们,并在任何一项失败时停止流程。
def validate(frame): assert frame["product_id"].is_unique, "duplicate product_id" assert frame["price"].between(0, 100000).all(), "price out of range" assert frame["rating"].between(0, 5).all(), "rating out of range" assert frame[["product_id", "price"]].notna().all().all(), "unexpected nulls" return frame df = validate(df)
范围检查很有价值:0 到 5 分制下评分为 50,或者负价格,几乎都是前面某个步骤的解析错误,而断言会在数据到达模型之前将其暴露出来。如果你对手写断言的需求超出范围,像 Pandera 或 Great Expectations 这样的模式验证库可以声明式地表达相同的规则,但上面的断言已经足以使流程变得可信赖。
导出干净的数据集
数据框经过去重、规范化、填充、模式转换和验证后,以保留类型的格式写出。CSV 具有可移植性,但类型全是字符串;Parquet 保留 dtype、压缩效果好、加载速度快,当你在结果上进行 AI 模型训练时,这一点尤为重要。
# Parquet preserves dtypes and is fast to reload df.to_parquet("clean_products.parquet", index=False) # CSV if you need maximum portability df.to_csv("clean_products.csv", index=False)
这个文件现在是一个数据集,而不是一次抓取结果:每个实体一行,每列一种类型,没有残留重复项,缺失值经过有意处理,每个值都在其声明的范围内。从这里到模型,是熟悉的特征工程和训练/测试集划分的路径,其底层数据不会再给你惊喜。
让数据源返回更干净的数据
最快的清洗步骤是因为数据一开始就干净而跳过的那一步。上面大量工作(实体反转义、标签去除、空白合并)的存在,仅仅是因为你抓取了原始 HTML。Crawling API 可以返回页面的干净 Markdown 视图,使文本无需标签和样板文字即可到达;Crawling API 可自动将许多热门网站解析为结构化 JSON 字段,在 pandas 看到数据之前就消除了选择器编写和大部分类型猜测。当你需要轮换住宅 IP 而不需要自己管理代理池时,Smart AI Proxy 覆盖了这一侧面。
这些都不能消除去重、验证和执行模式的需求(这些是你的数据集的属性,而不是页面的属性),但它确实缩减了工作中噪声较多的前半部分。关于这个数据集的下一步去向,请参阅 AI 数据提取的工作原理,以及针对高吞吐量采集的 电商网络抓取模式。
核心要点
- 首先去重。在任何统计量之前删除重复项,否则填充的均值会基于虚高、偏斜的计数计算。
-
规范化类型和格式。将货币字符串转为浮点数,用
errors="coerce"解析混合日期,并对分类变量进行去空格和小写处理。 -
按列处理缺失值。删除缺少必要字段的行,用中位数填充数值列,用明确的
"unknown"填充分类列。 - 分词前先清洗文本。按顺序反转义实体、去除标签和 URL,然后合并空白。
- 定义模式并验证。将每一列转换为声明的单一类型,并对每个批次断言唯一性、范围和非空,使坏数据大声失败。
- 输入越干净,工作越少。Crawlbase 可以返回干净的或 Markdown 输出以及自动解析的 JSON,在 pandas 运行之前缩减清洗步骤。
常见问题
为什么在训练 AI 模型之前数据清洗如此重要?
因为模型学习数据中的一切,包括错误。重复项过度加权某些样本,字符串类型的价格无法比较,混合日期格式碎片化为不相关的值,空单元格会被编码器误读。这些都不会报错,所以流程照常运行,模型悄悄地在被破坏的视图上训练。清洗消除这些损伤,使模型学习信号而不是噪声。
应该先去重还是先处理缺失值?
先去重。如果在删除重复项之前填充缺失值,你用来填充的每个均值都是基于虚高的计数计算的,并且偏向于重复最多的那些实体。先删除精确重复项并基于业务键折叠(保留最新的抓取),然后再计算用于填充的中位数和众数。
如何决定是删除行还是填充缺失值?
按列决策。当缺失字段是记录无法缺少的字段时(如主键或目标值),且缺口很少时,删除行。当列在下游是必需的且存在合理估计时,进行填充:数值用中位数(因为它对离群值有鲁棒性),分类变量用明确的 "unknown" 哨兵值,这样缺失情况被记录而不是被猜测。
抓取数据最少需要做哪些文本清洗?
按顺序反转义 HTML 实体、去除残留标签和 URL,并将连续空白合并为单个空格,然后小写化。抓取的文本通常包含 &、残留标记和参差不齐的间距,这些内容否则会变成噪声 token。这一步在空白分词之前已经足够;对于生产 NLP,之后你会换用专用分词器。
数据已经干净了,为什么还要强制执行模式?
因为除非有某种机制强制要求,下一个批次不会是干净的。模式为每列声明一种类型,并将每个批次转换为该类型,所以漂移的抓取结果(新列、突然无法解析的价格)会大声失败,而不是悄悄地重新引入你刚刚清除的混乱。它将清洗从一次性工作变成可重复的契约。
Crawlbase 能减少我需要做的清洗工作吗?
可以,对于噪声较多的前半部分。Crawling API 可以返回页面的干净 Markdown 视图,使文本无需标签或样板文字即可到达;Scraper API 可自动将许多热门网站解析为结构化 JSON,消除了选择器编写和大部分类型猜测。你仍然需要自己去重、验证和执行模式,因为这些是你的数据集的属性,而非来源页面的属性。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
