上一节我们把网页数据解析出来了,但解析出来不等于能用。我印象特别深,早期带项目的时候,有位同学把商品页爬了两千多条,兴冲冲拿去做分析,结果发现“平均价格”比市场价高出一大截。查了半天才反应过来,是爬虫在不同页面把“促销价”和“划线价”给混着抓了——这种问题,靠肉眼根本扫不出来。所以今天要聊的是数据质量三件套:缺失率、重复率、异常值。很多零基础教程喜欢把这几个概念放到分析阶段再讲,我反而建议在爬虫阶段就开始做,越早做,后面越省力。
1. 为什么爬下来的数据必须先做“体检”
1.1 爬虫数据天生就是“脏”的,这不是你的错
先说个让人安心的事实:爬虫数据不干净,很多时候不是代码写得不好,而是网页本身就“不配合”。我举个例子,你爬一个商品评论页,有的用户没填评分,那个评分字段就空着;有的商品在列表页显示价格,但在详情页价格区块是动态加载的,你没等到接口返回就抓了,拿到一个空字符串;还有的页面在不同地区返回的字段名都不一样,比如“手机号”有的字段叫mobile,有的叫phone。
这些情况叠加起来,你最终拼出的DataFrame里必然有缺失值、重复行、异常数值。新手最容易犯的错,是拿到数据后直接df.to_csv()存下来,然后就开始df.mean()、df.groupby()做分析。你分析的是两千条,但里面可能有三百条重复、四百条缺了关键字段、五十条数值是乱码。这种数据算出来的任何指标,都是在“垃圾进,垃圾出”。
所以,爬到数据之后的第一件事,不是存盘,而是先给数据做一次“体检”。体检要查的就是三项:缺失率、重复率、异常值。这三项过关了,你的数据才敢拿去画图、建模、做报告。
1.2 数据质量检查到底在查什么
简单拆解一下这三项的含义:
- 缺失率:某个字段(列)中,有多少比例的值是空的。空包括
None、NaN、空字符串"",甚至包括看起来像没填的"N/A"。缺失率直接影响你能不能用这一列做分析。 - 重复率:整个数据集里,有多少行是完全一样或者关键字段完全一样的。重复数据会让统计结果“虚胖”,比如计数翻倍、平均值被错误加权。
- 异常值:偏离正常范围太远的数值。比如商品价格出现
-1,年龄出现999,销售额出现1e10。异常值可能是爬虫解析错误,也可能是网页本身有脏数据,它们会严重干扰均值、方差等统计量。
那为什么把这三项放在“解析与清洗”这一章里?因为爬虫的终点不是“拿到数据”,而是“拿到能用的数据”。解析只是把网页结构拆开,清洗才是让数据真正可用的开始,而清洗的第一步,就是量化“脏的程度”。只有知道了缺失率多高、哪里重复、哪些值异常,你才能决定下一步怎么处理。
2. 缺失率:第一个要查的指标
2.1 怎么看缺失率,而不是靠“感觉”
很多新手喜欢用df.info()来看有没有空值,这个方法本身没问题,但df.info()只告诉你每列有多少个非空值,它不会告诉你“缺失率到底有多严重”。比如总共爬了5000条,某一列有200个空值,看起来不多对吧,但如果你只关心其中一条子集数据,可能影响就大了。所以第一步,我建议先算一个缺失率报告。
用 pandas 实现非常简洁:
import pandas as pd def missing_report(df): report = pd.DataFrame({ '列名': df.columns, '缺失数量': df.isnull().sum().values, '缺失率': (df.isnull().sum() / len(df) * 100).values }) report['缺失率'] = report['缺失率'].round(2).astype(str) + '%' return report[report['缺失数量'] > 0].sort_values('缺失数量', ascending=False) # 假设 df 是你爬完拼好的 DataFrame print(missing_report(df))这里核心就是df.isnull().sum(),它统计每一列有多少个空值。注意isnull()把NaN和None都识别为缺失,但空字符串""它不会识别。所以我会再加一步统一处理:
# 把空字符串、'N/A'、'null' 等统一转为 NaN df = df.replace({'': None, 'N/A': None, 'null': None, 'None': None})这一步很重要,因为网页解析时拿到的缺失值,很多时候是""而不是NaN。如果你不替换,后面dropna()根本不会生效,因为""在 pandas 眼里不是缺失值,只是一个普通字符串。
2.2 缺失率多高才算高,高了怎么处理
这个没有绝对标准,我个人的参考线是这样的:
- 缺失率在5% 以下:基本不影响大局,可以直接忽略,或者简单填充即可。
- 缺失率在5%~20%:需要处理。如果这一列对后续分析很关键,考虑填充;如果不关键,也可以直接丢弃。
- 缺失率在20% 以上:这列数据基本没法用了。如果是个非核心字段,直接删列;如果是核心字段,你需要回到爬虫阶段,看看是不是解析规则漏掉了某些页面结构。
处理方式就三种,我按优先级排:
- 直接删除:数据量足够大,缺失比例不高,删掉缺失行最省事。
- 填充:数值型数据可以用均值、中位数填充;分类型数据可以用众数填充;时间序列可以用前向填充。
- 作为独立信息保留:有些场景下,缺失本身有业务含义。比如用户没填年龄,那“年龄缺失”本身就是一个特征,可以单独建一列
age_missing存 0/1。
在爬虫场景里,我特别想提醒一点:如果你发现某一个字段缺失率异常高(比如超过50%),不要闷头去填,先回头看一眼你的解析代码。大概率是你的xpath或CSS选择器在部分页面没选中元素。我当时爬详情页时,有一个字段在列表页和详情页之间字段名不一致,导致详情页全部抓空。后来用了一个容错逻辑:先找detail_price,找不到再找list_price,缺失率才降下来。
提示:缺失率是“指示器”而不只是“麻烦”。它告诉你网站结构可能比你想象中更不统一。看见缺失率,先想“为什么缺”,再想“怎么补”。
3. 重复率:数据里有多少“水分”
3.1 重复率的计算和它背后的坑
重复率是个很有意思的指标,特别是想到大家写论文时被“查重率”支配的恐惧,数据里也有“查重”。爬虫数据里的重复,通常来自几个原因:网站列表页和详情页内容重叠、爬虫重复请求同一URL、分页逻辑写错导致同一页被抓多次。
计算重复率一行代码就够了:
# 全行完全重复 dup_rate = df.duplicated().sum() / len(df) print(f'完全重复率: {dup_rate:.2%}') # 按关键字段重复(比如商品ID、订单号、链接URL) dup_rate_key = df.duplicated(subset=['product_id']).sum() / len(df) print(f'按商品ID重复率: {dup_rate_key:.2%}')很多教程只教你全行去重,但实际爬虫场景里,全行重复很少见,部分字段重复才常见。比如你爬同一件商品的在架信息,价格可能每次不同(今天99明天109),但商品ID是完全一样的。这种情况下全行duplicated()返回False,但subset=['product_id']就能筛出来。
我踩过一个很典型的坑:爬某电商平台评论时,同一个用户对同一商品只能评论一次,但爬虫在翻页过程中因为页码错乱,把同一页重复抓了3次。评论内容是一模一样的,但评论时间差了几秒,所以全行看起来“不完全重复”。后来我用df.drop_duplicates(subset=['user_id', 'product_id', 'comment_content'])才把真正的重复抓出来。
3.2 去重实操:哪些列可以“安全”去重
去重的代码大家都知道是drop_duplicates(),但让我认真提醒三个细节:
keep参数默认是'first',也就是保留第一次出现的行,删掉后续重复的。如果你希望保留最后一次的数据(比如价格最新),要写成keep='last'。inplace参数很多人容易漏。df.drop_duplicates()不会原地修改,你必须赋值给新变量,或者加inplace=True。- 去重的“粒度”要想清楚。是按整行去重,还是按几个核心字段去重?这取决于业务。比如爬招聘信息,同一公司和同一职位名称重复出现两次,可能是两个不同岗位;但如果你按“公司名+职位名”去重,就会误删。
我个人写爬虫脚本时的习惯是这样的:每次翻页前,先记录一下当前页第一条数据的唯一标识(比如商品ID),下一页开头检查一下,如果跟上一页的重复,说明翻页逻辑有bug,立刻停止。这样可以在源头控制重复,而不是一直抓到几千条再去重。
seen_ids = set() for page in range(1, max_page + 1): items = fetch_page(page) for item in items: pid = item['product_id'] if pid in seen_ids: print(f'检测到重复ID: {pid}, 第{page}页可能重复抓取') continue seen_ids.add(pid) data_list.append(item)这种方式,比你抓到两万条之后再用drop_duplicates()更高效,也更容易发现爬虫逻辑本身的问题。
4. 异常值:藏在数据里的小偷
4.1 异常值是怎么混进来的
异常值在爬虫数据里的来源主要有三种:
- 解析错误:正则表达式或xpath选中了错误的节点。比如本应提取价格,却提取到了“已售罄”这种文字,转成数值时变成乱码。
- 网站自身的脏数据:有些页面里的商品划线价是
¥999999,用来凸显“打折力度大”,这就成了异常值。 - 单位不一致:同一字段下有的值是“元”,有的是“千元”,有的是字符串拼接的
"¥299.00",你转float时没处理好。
判断异常值的核心思路,是看“这个值是否超出了合理的业务范围”。比如你爬房价数据,正常单价范围可能在8000到50000之间,突然冒出一个9999999,这肯定是异常。但具体怎么科学地找出这些值,不能只靠肉眼看df.describe()。下面说三种我常用的方法。
4.2 三种最实用的异常值检测方法
方法一:描述统计法(适合快速排查)
print(df.describe())describe()会给出每列数值的 count、mean、std、min、25%、50%、75%、max。你就看两件事:min 和 max 是否离谱,mean 是否被某个极值拉偏。比如max=9999999而75%只有200,那基本可以断定 max 是异常值。
方法二:3σ 原则(适合近似正态分布的数据)
如果你确认某个字段的数据大致符合正态分布,可以用“均值 ± 3倍标准差”作为正常范围,超出就视为异常。
import numpy as np def find_outliers_3sigma(series): mean = series.mean() std = series.std() lower = mean - 3 * std upper = mean + 3 * std mask = (series < lower) | (series > upper) return mask outlier_mask = find_outliers_3sigma(df['price']) print(f'异常值数量: {outlier_mask.sum()}')方法三:IQR 四分位距法(更稳健,适合偏态分布)
但现实里爬虫数据大多不是正态分布,价格、销量这种数据往往右偏严重,3σ 会把很多正常的高值误判为异常。这时用 IQR 法更稳。所谓 IQR 就是75%分位数 - 25%分位数,正常范围是[Q1 - 1.5*IQR, Q3 + 1.5*IQR]。这个“1.5”是统计学里箱线图默认的系数,适合大多数场景。
def find_outliers_iqr(series): q1 = series.quantile(0.25) q3 = series.quantile(0.75) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr mask = (series < lower) | (series > upper) return mask outlier_mask = find_outliers_iqr(df['price']) print(f'IQR法异常值数量: {outlier_mask.sum()}')我个人的实操经验是:能用 IQR 就尽量用 IQR,它对少量极端值不敏感,比 3σ 更稳。当然,你确定数据是正态分布的时候,3σ 也能用,两者可以都跑一下对比。
4.3 异常值处理:先理解业务,再动手删
这是我特别想强调的一点。异常值不是“坏值”的代名词,先问一句“这个异常是真的异常,还是业务本身的特殊情况”。
举个例子:你爬王者荣耀的战绩数据,某位玩家一局打出50杀0死,在普通玩家眼里像异常值,但如果是职业选手打低端局,这是完全真实的战绩。你如果机械地用 IQR 一刀切删掉,反而删掉了真实的极端个体。
所以异常值的处理策略我按优先级排序:
- 核实来源:去网页上看一眼这个值是否真实存在。如果网页上就这么显示的,那不是爬虫的问题,是业务数据本身如此,不要轻易删。
- 视为缺失值:如果这个值是解析乱码(比如字符串混入了数字),我会把它先转为 NaN,然后按缺失值处理。
- 截尾处理:比如价格字段,最大值 99% 分位数是 5000,超出 10000 的都替换成 5000,这种叫“截尾”,在抗噪声建模时常用。
- 直接删除:只有当你确认这个异常值是纯错误数据,且量很少(比如千分之一),才直接删。
注意:处理异常值前,建议先把异常行单独存成一个
outliers.csv,留个底。后面万一分析结果不对劲,你还能翻旧账排查,不至于删完就无迹可寻。
5. 完整实操:把三项检查串成一个脚本
5.1 真实的爬虫后处理场景
前面说了那么多概念,这里我放一个可以直接“抄作业”的完整示例。假设我们用爬虫抓了一个电商网站的手机商品列表,字段包括:title(标题)、price(价格)、sales(月销量)、shop_name(店铺名)、rating(评分)。数据已经解析成 DataFrame 了,现在要做质量体检。
import pandas as pd import numpy as np # 模拟一份脏数据 data = { 'title': ['手机A', '手机B', '手机C', '手机A', '手机D', None, '手机E', '手机F'], 'price': [1999, 2999, 9999, 1999, 3500, 4999, -5, 1899], 'sales': [1200, 800, 30000, 1200, 500, None, 900, 100], 'shop_name': ['旗舰店', '专卖店', '旗舰店', '旗舰店', '专卖店', '旗舰店', None, '专卖店'], 'rating': [4.8, 4.5, 2.0, 4.8, 4.2, 5.0, 4.9, 4.3] } df = pd.DataFrame(data)这份数据里铺了三个坑:
- 第0行和第3行
title/price/sales/shop_name/rating完全一样,是重复数据。 - 第5行
title/sales/shop_name有缺失。 - 第2行价格
9999、第6行价格-5是异常值,需要判断。
5.2 我写的简易“质量报告”函数
我会把前面讲到的检查集中到一个函数里,每次爬到数据直接跑一遍。
def quality_report(df): print('=' * 50) print('一、缺失率检查') missing = df.isnull().sum() missing_rate = missing / len(df) for col in df.columns: if missing_rate[col] > 0: print(f'字段 [{col}] 缺失 {missing[col]} 条,缺失率 {missing_rate[col]:.2%}') print('=' * 50) print('二、重复率检查') full_dup = df.duplicated().sum() print(f'全行重复: {full_dup} 条,重复率 {full_dup / len(df):.2%}') print('=' * 50) print('三、异常值检查(IQR法)') for col in ['price', 'sales', 'rating']: if df[col].dtype in ['int64', 'float64']: q1 = df[col].quantile(0.25) q3 = df[col].quantile(0.75) iqr = q3 - q1 lower = q1 - 1.5 * iqr upper = q3 + 1.5 * iqr outliers = df[(df[col] < lower) | (df[col] > upper)] if len(outliers) > 0: print(f'字段 [{col}] 异常值 {len(outliers)} 条,正常范围参考 ({lower:.2f}, {upper:.2f})') quality_report(df)运行结果大概是:
一、缺失率检查 字段 [title] 缺失 1 条,缺失率 12.50% 字段 [sales] 缺失 1 条,缺失率 12.50% 字段 [shop_name] 缺失 2 条,缺失率 25.00% 二、重复率检查 全行重复: 1 条,重复率 12.50% 三、异常值检查(IQR法) 字段 [price] 异常值 2 条,正常范围参考 (-2000.00, 9000.00) 字段 [sales] 异常值 1 条,正常范围参考 (-4500.00, 8500.00)注意,这里price的 IQR 正常范围下限出现负数,因为这份数据本身分布比较特殊。这也说明一个点:看正常范围时要结合业务实际,价格正常范围不能是负数,所以销售价低于0的-5肯定有问题;但9999在 IQR 范围内,它没有超出报警线。对9999这种值,你反而要去业务层面判断:一个手机卖 9999,可能是高端旗舰机,也可能就是划线价的脏数据,只靠统计学方法不一定逮得住。
5.3 综合处理流程的顺序建议
我建议你在爬虫脚本里,按下面的顺序走完清洗流程:
- 第一步:统一空值格式,把空字符串等转成 NaN。
- 第二步:算缺失率,决定哪些列要丢、哪些列要填。
- 第三步:按关键字段去重,留存一个去重后的副本。
- 第四步:跑异常值检测,把异常行先另存,不要急着删。
- 第五步:再次跑一次
quality_report()确认,三项指标都降到了可接受范围,再存最终结果。
这样走完,你最终存下来的df_clean.csv才是敢放心拿去做分析的数据。我之前接过一个爬小说的项目,解析出来的章节内容里夹杂着大量文案广告,靠的就是这套“先体检再清洗”的流程,把几百个异常行揪出来过滤掉了,否则导出的电子书会惨不忍睹。
6. 新手最容易踩的坑:常见问题与排查技巧
6.1 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
isnull().sum()显示 0,但明明有空值 | 空值是空字符串""或"null"文本 | 先执行df.replace({'': None, 'null': None}) |
重复率很高但drop_duplicates()没用 | 重复行在某些字段有细微差异(如时间戳、空格) | 先用subset指定核心字段,必要时先strip()去空格 |
drop_duplicates()删完行数没变 | 忘记赋值,或inplace没生效 | 检查是否写成df = df.drop_duplicates() |
price 列里有-5这种异常值但 IQR 没检出 | 数据整体偏态,且样本量太小 | 补充业务规则判断,比如price < 0直接视为异常 |
| 缺失字段集中在某几页的数据 | 这些页面结构不同,解析规则没覆盖 | 回炉检查 xpath,打印那几页的HTML结构 |
| 去重后数据量少得离谱 | subset选错了字段,把不唯一的字段当唯一键 | 确认唯一键,比如商品ID才对,标题不完全唯一 |
这个速查表是我自己在项目里反复遇到过的,不是抽象的理论总结。新手看到第3条可能会觉得离谱,但drop_duplicates()忘记赋值真的是最高频错误之一,因为 pandas 默认返回新对象,不是原地修改。
6.2 误区提醒:数据清洗不是“能跑就行”
我见过不少学习者,跑完drop_duplicates()就觉得自己清洗完了。这里我想认真提醒三点:
- 清洗要有记录:哪怕只是加几行注释,也建议把“删了多少行、为什么删、判断标准是什么”写下来。不然一周后你再看这份清洗代码,根本想不起当时的判断逻辑。
- 不是所有重复都该删:爬招聘网站时,同一家公司发布两个相似职位,职位名称一样但招聘编号不同,这种情况重复吗?从业务角度不算。所以去重之前,先确认唯一键。
- 异常值不能闭眼删:我个人强烈建议,把异常值单独存一个文件,分析的时候如果有需要再回去翻。删掉的异常值永远找不回来,但留着它们又会影响统计结果,折中方案就是“隔离”而不是“销毁”。
还有一个经验,数据质量检查不是一锤子买卖。你爬第一遍和第二遍,网页结构可能变了,数据质量可能完全不一样。所以我现在的习惯是:每次爬完批量任务,都会把quality_report()的输出存成一个文本日志,放在数据文件夹里。下次重新爬的时候,可以先看一眼上次的日志,对比这次的质量有没有异常波动,如果缺失率突然暴涨,大概率是网站改版了。
说到网站改版,我想起之前爬某网站时遇到过一次:前一天缺失率还在3%以下,第二天直接飙到40%。我当时第一反应是网络问题,但其实是对面的 HTML 结构调整了,原来的选择器失效了。还好质量报告跑得勤,第一时间发现,赶紧去检查解析规则,没有把错误数据积压到分析阶段,算是用“体检”救回了整个项目。
之后你可以顺手把quality_report()这个函数保存成一个独立的data_quality.py文件,以后每个爬虫脚本都from data_quality import quality_report调一下。这样既不用反复复制粘贴代码,也能保证每个项目的清洗标准一致。别小看这个习惯,我后期做爬虫项目时,数据清洗的时间占比大约在40%,其中质量检查又占了清洗的一半。前期养成这习惯,你后面做数据分析和可视化会非常省心,至少不会像当初那位同学一样,拿着有问题的数据算了半天均值,最后才发现结论全偏了。