☰
Python爬虫数据清洗:缺失率、重复率、异常值检查指南
2026/9/30 3:43:32 网站建设 项目流程

写爬虫的人可能都经历过这种时刻:代码跑得挺顺,数据也抓下来了,但一进分析环节整个人就麻了——价格列冒出一堆 NaN,同一款商品出现了八遍,某条评论的字数显示成 -20000。这不是爬虫没写完,而是你忽略了数据质量这一关。在 Python 爬虫入门阶段,解析与清洗里最容易被跳过但又最关键的,就是数据质量检查:缺失率、重复率、异常值。这三个指标就像给数据做体检,体检不过关,后面统计、画图、建模全都白搭。

这篇文章适合刚学会用 requests + BeautifulSoup 或 Scrapy 把数据抓下来、但不清楚下一步该怎么办的新手,也适合那些已经写了几个爬虫、却总是被“脏数据”折磨的老朋友。我会从三个核心指标的定义讲起,给出一套可以直接复制的检查代码和处理策略,最后分享几个我实际踩过坑的排查经验。全套内容不依赖复杂框架,只要你会一点点 pandas,就能跟着做。

1. 为什么要先谈数据质量:不是你眼花了,是数据“病”了

1.1 一次真实的“翻车”现场

我之前帮朋友抓过一个电商平台的商品信息,字段包括标题、价格、库存、销量。代码跑完,HTTP 状态码全部 200,解析过程一个异常都没报,我还挺得意。结果一算平均价格,发现数值高得离谱,再一查,价格列里有 30% 的 NaN,还有几个 99999 和 -1 这种明显不对劲的“哨兵值”。更离谱的是,因为当时页面做了分页,每个商品在不同页码里重复出现了 3 次,直接导致销量合计膨胀了 3 倍。

那一次我印象特别深:爬虫返回 200 只代表服务器“接客”了,并不代表给你的数据是干净、可用的。真正的战场在解析之后的数据清洗,而清洗的第一步,就是先量化这份数据到底有多脏。这就是为什么我强烈建议你把“数据质量检查”当成爬虫流水线上的固定环节——就像做饭前先看看菜有没有烂叶子,而不是炒完再说。

1.2 缺失率、重复率、异常值到底在体检什么

这三个指标,其实是三个不同层面的体检项:

  • 缺失率衡量的是数据的完整性,也就是说,多少该有的信息是空的。
  • 重复率衡量的是数据的冗余度,同一件事被记录了多少遍。
  • 异常值衡量的是数据的可信度,有没有数值跑到了正常范围之外。

打个比方,你从网上抓了 1000 条招聘信息,想要分析平均薪资。如果薪资字段缺失了 200 条,那么缺失率就是 20%,你算出来的均值可能严重偏低;如果同一家公司同一个职位被抓了 3 遍,重复率会让你误以为这个岗位很缺人;如果某个岗位薪资写成 99999,异常值会直接拉高均价,让结论彻底失真。

所以“解析与清洗”这一章真正要做的事,不是把 DataFrame 打印出来看一眼就觉得“差不多”,而是用几个量化指标把数据的问题摊开放在桌面上。下面我就逐一把这三个指标讲清楚,并且给出新手上手就能用的代码。

2. 缺失率:数值里那些“未知”到底有多少

2.1 缺失率怎么算:一行代码看全貌

先明确概念:缺失率 = 某列缺失值数量 / 总行数。在 pandas 里,最直观的写法是这样的:

import pandas as pd # df 是你已经解析好的 DataFrame missing_count = df.isnull().sum() missing_rate = missing_count / len(df) print(missing_count) print(missing_rate)

df.isnull()会把 DataFrame 里每个单元格变成布尔值,是缺失就是 True,不是就是 False。紧接着.sum()是按列求和,得到每一列缺失的数量。除以len(df)(总行数),就是缺失率。

我建议你写一个函数,以后每次爬完数据先跑一遍:

def missing_report(df): missing_count = df.isnull().sum() missing_rate = missing_count / len(df) result = pd.DataFrame({ "缺失数量": missing_count, "缺失率": missing_rate }) return result[result["缺失数量"] > 0]

拿到的结果会类似下面这样:

列名缺失数量缺失率
stock2000.20
sales1500.15

看到缺失率之后,先不要急着处理,还要搞清楚缺失是怎么来的。

2.2 缺失不一定是真缺失:先分清三种情况

我发现新手最容易犯的错,是见到 NaN 就直接用dropna()删掉,或者用 0 填满。实际上,爬虫数据里的缺失通常有三种来源:

第一种,网页上这个字段根本没有值。比如有些商品没有“副标题”,那解析出来自然就是空。第二种,字段是动态加载的。你通过 requests 拿到的 HTML 里只有骨架,价格、评论数等内容是 JavaScript 异步加载出来的,所以这些列会成片缺失。第三种,字段被反爬手段替换成空字符串或者占位符了,比如网站返回了一个""而不是正常的商品介绍。

判断方法很简单:回到原始响应文本,搜一下这个字段对应的位置。先看 HTML 里到底有没有内容,再用浏览器开发者工具搜索关键词。如果 HTML 里没有,但浏览器里能看到,那说明是动态渲染,需要换成渲染工具而不是盲目补数。

另外要特别注意:空字符串""不是 pandas 默认识别的缺失值。isnull()对空字符串返回 False,这会导致你算出来的缺失率比实际情况低。所以拿到数据后,我习惯先把所有空字符串转成真正的缺失值:

df = df.replace("", pd.NA)

这样后面所有缺失值逻辑都用同一套规则处理,不会出现“我明明看到了空值,可 isnull() 却检测不到”的诡异情况。

2.3 处理缺失的策略:别一上来就补0

处理缺失值没有万能解,只有业务解。我常用的策略分三种:删除、填充、标记。

删除适合缺失率极高、而且这个字段对你后续分析没用的列。比如缺失率超过 80% 的“商品评分”,留着反而是噪声。代码里可以用thresh参数做阈值控制:

# 每一列至少要有 60% 的非空值,否则删除该列 df.dropna(axis=1, thresh=int(len(df) * 0.6), inplace=True)

填充适合缺失率不高、并且字段本身有业务含义的情况。数值列一般用中位数或均值填充,分类列用“未知”填充:

# 用中位数填充价格,受极端值影响更小 df["price"] = df["price"].fillna(df["price"].median()) # 分类列填充“未知” df["category"] = df["category"].fillna("未知")

标记是说,我不想丢失“这个值原本缺失”的信息,于是单独加一列记录是否为缺失:

df["price_was_missing"] = df["price"].isnull().astype(int)

很多小白一遇到缺失就全都填 0,这是我在数据清洗中最不推荐的做法。你可以想象一下:你抓了 100 条商品价格,有 50 条是因为动态加载没抓到,如果全部填 0,最后的均价直接砍半,整个统计就没有意义了。所以请先分清楚情况,再决定用什么策略。

3. 重复率:数据到底重没重,不能只靠肉眼

3.1 重复率的计算口径:全行重复和关键字段重复是两个数

重复率听着简单,但很多人在第一步就栽了。因为“重复”要看口径:是全行完全一样,还是只有某几个关键字段一样?

在 pandas 里,df.duplicated()默认判断所有列是否完全一致。如果一行和之前某一行完全一样,就标记为重复:

dup_count = df.duplicated().sum() dup_rate = dup_count / len(df) print(f"全行重复率: {dup_rate:.2%}")

但是爬虫数据最常见的重复,往往是“关键字段重复,其他字段却不同”。比如同一件商品,今天抓了一次,明天又抓了一次,价格已经变了,但商品 ID 是一样的。如果你只做全行去重,就会漏掉这些“半重复”的情况。这时候要用subset参数指定关键字段:

key_dup_count = df.duplicated(subset=["item_id"]).sum() key_dup_rate = key_dup_count / len(df) print(f"按商品ID重复率: {key_dup_rate:.2%}")

所以你在报告里写重复率的时候,一定要说明是哪种口径。按商品 ID 的重复率可能高达 30%,但全行重复率可能只有 2%,这并不矛盾。做数据分析前,先想清楚你要以哪个字段作为唯一标识。

3.2 drop_duplicates去重:参数比你想的重要

去重操作很简单,但细节很磨人。最基础的写法是:

df.drop_duplicates(inplace=True)

实际项目中我几乎不会直接这么用,而是会指定subset和keep:

df = df.drop_duplicates(subset=["item_id"], keep="first").reset_index(drop=True)

参数拆开解释:

  • subset:一个列表,按哪些字段判断重复。
  • keep="first":保留第一次出现的行,删除后面重复的;keep="last"保留最后一次;keep=False把重复的全删掉。
  • reset_index(drop=True):去重后行数减少,DataFrame 索引会留下空档。后续如果按索引合并数据,容易莫名报错,所以我习惯顺手重置索引。

如果你想保留最后一次抓到的数据,比如商品价格每天在变,而你希望留下最新值,可以先按采集时间排序,再执行drop_duplicates:

df.sort_values("crawl_time", ascending=False, inplace=True) df = df.drop_duplicates(subset=["item_id"], keep="first")

先排倒序,再保留第一个,就相当于保留了最后抓到的记录。这个组合用法在很多生产脚本里非常常见。

3.3 去重前先“洗脸”:大小写、空格会让重复率失真

这是我在实际项目中碰到最隐蔽的坑。两个商品标题,一个是“Python入门”,一个是“python入门 ”,看起来很像,但用 pandas 默认去重会被判定为不重复,因为 Python 里字符串是区分大小写的,而且末尾空格也参与比较。

如果你确实需要按标题这种文本字段去重,先做标准化:

df["title"] = df["title"].str.strip().str.lower()

另外,如果某一列是列表类型,比如标签字段是["Python", "爬虫"],直接df.duplicated()会报 TypeError,因为列表是不可哈希的。我的办法是先把列表列转成字符串或者元组:

df["tags_str"] = df["tags"].apply(lambda x: tuple(x) if isinstance(x, list) else x)

然后对tags_str做去重判断。但要注意,某个字段当初存的是列表、现在转成字符串,会给后续分析和存储带来额外麻烦,所以能转成元组就转成元组,别贪图简单直接str()完事。

4. 异常值:藏在数据集里的“显眼包”

4.1 异常值不是错误值:先问业务再动手

异常值,通俗说就是那些和整体分布差距极大的数值,比如一条评论的字数是 -20000,商品价格是 999999,用户年龄是 200。

很多新手看到异常值就觉得“这数据坏了,删掉”。但我吃过亏,有一次把价格列里所有小于 0 的值全删了,结果发现漏掉了一批“补差价”场景下的负数订单。还有一次遇到 0 元商品,以为是异常,其实是商家设置的“赠品”链接,删除后导致赠品商品全部消失。

所以在处理异常值之前,一定要先回答一个问题:这个数在业务里到底有没有可能合法存在?比如一个商品打 1 折卖 1 元,这是完全合理的;但同一商品卖 999999 且只有这一条,那就很可疑。不要看到数字不正常就直接动手,这是数据清洗里最重要的原则。

4.2 用describe和箱线图找出“不和谐音”

先跑一个最简单的描述性统计:

df["price"].describe()

输出里重点看min、max、mean和四分位数。如果max比 75% 分位数高出几千倍,说明数据很可能有异常尾巴。我通常还会画箱线图:

import matplotlib.pyplot as plt df.boxplot(column=["price"]) plt.show()

箱线图是最直观的异常值探测器。箱子上边或下边伸出老长尾巴的那些点,基本就是潜在异常值。如果数据里有明显的极端值,图上一眼就能看到,而且还能看出异常值的分布范围。

如果你想把异常值变成一个可计算的列,可以用两种常见方法。

第一种是 Z-Score 法,适合数据近似正态分布的场景:

from scipy import stats df["price_zscore"] = stats.zscore(df["price"]) df["is_outlier"] = df["price_zscore"].abs() > 3

第二种是 IQR 法,不依赖正态分布,对很多爬虫拿到的长尾数据更稳:

Q1 = df["price"].quantile(0.25) Q3 = df["price"].quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR df["is_outlier"] = (df["price"] < lower_bound) | (df["price"] > upper_bound)

对零基础选手,我不建议一上来就背公式。你只要理解一点:IQR 方法用 25% 和 75% 分位数的间隔推算正常范围,如果某个值离这个范围太远,就认为是异常。这在大部分爬虫数据场景里够用了。

4.3 异常值处理:不是只有“删掉”一条路

识别出异常值后,处理方式至少有三条路:截断、替换、标记。

截断是把超出合理边界的值压到边界上。比如价格不可能为负数,且平台正常售价不应该超过 9999,那么可以直接:

df["price"] = df["price"].clip(lower=0, upper=9999)

clip的意思就是:低于 0 的变成 0,高于 9999 的变成 9999。这样做能保留数据行,又避免了极端值对均值的影响。

替换是把异常值换成统计量,比如中位数:

df.loc[df["is_outlier"], "price"] = df["price"].median()

标记是最“保守”的方法,保留原始值,额外加一列说明它被判定为异常:

df["price_outlier_flag"] = df["is_outlier"].astype(int)

后面做分析时,你可以选择只看price_outlier_flag == 0的数据,也可以把标记列作为机器学习特征。这种方式最适合你还不确定业务含义的阶段,避免删错数据导致无法挽回。

5. 实操流水线:爬虫结果到干净数据只要这几步

5.1 模拟一份“脏数据”

为了让你看到完整链路,我造一份脏数据,里面同时包含缺失值、重复行、异常值:

import pandas as pd df = pd.DataFrame({ "item_id": ["A001", "A001", "A002", "A003", "A003", "A004", "A005"], "title": ["Python入门", "Python入门", "数据分析", "爬虫实战", "爬虫实战", "数据清洗", ""], "price": [59.9, 59.9, -1, 99999, 129, 0, None], "stock": [100, 100, None, 30, 30, 5, None] })

这里的问题很明显:item_id 有重复,price 里出现了 -1、99999、None,title 里还有空字符串,stock 也有缺失。现在就用前面讲的方法,一步步把它洗干净。

5.2 写一个数据质量报告函数

我建议你建一个函数,把缺失率、重复率、异常值数量一次性展示出来。这样以后每个爬虫项目都能复用。

def data_quality_report(df, key_cols=None): print("===== 缺失率报告 =====") missing_rate = df.isnull().mean() print(missing_rate[missing_rate > 0]) print("\n===== 重复率报告 =====") if key_cols: key_dup_rate = df.duplicated(subset=key_cols).sum() / len(df) print(f"按关键字段 {key_cols} 重复率: {key_dup_rate:.2%}") else: dup_rate = df.duplicated().sum() / len(df) print(f"全行重复率: {dup_rate:.2%}") print("\n===== 异常值数量 =====") numeric_cols = df.select_dtypes(include="number").columns for col in numeric_cols: 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 outlier_count = ((df[col] < lower) | (df[col] > upper)).sum() print(f"{col}: {outlier_count} 个异常值")

调用一次,三份报告全出来:

data_quality_report(df, key_cols=["item_id"])

如果后续数据量变大,你可以把这个报告输出到日志文件里,方便追溯每次采集的数据质量到底如何。

5.3 清洗全过程串联

清洗流程其实是一套固定动作:空字符串转缺失 → 去重 → 填充 → 异常处理 → 再次检查。我把完整流程写在这里,你可以直接抄:

# 1. 空字符串统一转缺失 df = df.replace("", pd.NA) # 2. 按商品ID去重,保留第一条,重置索引 df = df.drop_duplicates(subset=["item_id"], keep="first").reset_index(drop=True) # 3. 数值列转成数值类型,非数字会被转成缺失 df["price"] = pd.to_numeric(df["price"], errors="coerce") # 4. 填充缺失:库存缺失填0,价格缺失填中位数 df["stock"] = df["stock"].fillna(0) df["price"] = df["price"].fillna(df["price"].median()) # 5. 异常值截断:价格限制在0到9999之间 df["price"] = df["price"].clip(lower=0, upper=9999) # 6. 最后再跑一遍质量报告 data_quality_report(df, key_cols=["item_id"])

为什么要把pd.to_numeric单独拎出来?因为很多爬虫字段看起来是数字,实际上存的是字符串,甚至混进了一些中文或者空格。比如"59.9"是字符串,-1也可能是字符串。直接用median()会报错,clip()也可能静默失败。所以先用errors="coerce"做一次强转,把不能转的变成缺失,再来处理。

清洗完以后,你可以打印一下 DataFrame:

print(df)

看到的是一个item_id唯一、价格都在合理区间、没有空字符串的表。到这一步,这份数据才算真正能用于后续的分析和可视化。

6. 常见问题与排查技巧实录

6.1 明明有数据,却显示这一列全缺失?

有一次我打印missing_rate,某个字段缺失率居然是 100%,可我用肉眼在 CSV 里明明看到了值。折腾半天才发现,解析时我把字段名取成了"商品标题",但实际 DataFrame 里那一列叫"商品标题 ",末尾带一个看不见的空格。于是isnull()检查的是一列不存在的列,自然全是缺失。

排查方法很简单:打印所有列名,并且把每个列名的 repr 打出来。

print([repr(col) for col in df.columns])

如果看到'商品标题 '这种带空格的,先用strip统一处理:

df.columns = [col.strip() for col in df.columns]

6.2 drop_duplicates去重没生效?

去重没生效,多半是因为数据类型不统一。比如item_id这一列,有些来自 JSON 是整数123,有些来自 HTML 是字符串"123",在 pandas 里它们会被判定成不同值,所以重复率被严重低估。处理办法是预先统一列类型:

df["item_id"] = df["item_id"].astype(str).str.strip()

另一种情况是字段里有不可见字符,比如换行符。打印出来看起来是同一个值,实际上一个是"A001",一个是"A001\n"。我常在清洗开头跑一遍:

df["item_id"] = df["item_id"].astype(str).str.replace("\n", "").str.strip()

6.3 异常值识别把正常值误伤了怎么办?

IQR 方法并不能保证每次都合理。我之前抓一个打折平台的商品价格,很多商品 1 元、9.9 元,这在业务上完全正常。但 IQR 可能把 1 元判定成下边缘之外,直接标记成异常。

遇到这种情况,我建议先看一眼被标记的都是些什么值,千万别批量替换。如果 1 元商品数量很多,说明业务里确实存在低价场景,这时候要么降低倍数,要么直接不处理,要么单独加一列标记,而不是统一删除。数据清洗从来不是公式一跑就完事,它需要针对业务做判断。

6.4 一个经验:把质量检查写成函数,强迫自己先体检再分析

我个人在实际项目里的习惯是,把data_quality_report放在爬虫脚本的固定位置,每次采集完成,先输出一段质量报告,再决定要不要入库。一开始会觉得多了一步操作很烦,但踩过几次坑之后你就明白了:缺失率、重复率、异常值这三个数据质量指标,才是真正决定你后续分析靠不靠谱的地基。代码跑通只是第一步,数据干净才是能交付的起点。希望这份经验能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询