写爬虫的人可能都经历过这种时刻代码跑得挺顺数据也抓下来了但一进分析环节整个人就麻了——价格列冒出一堆 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.20sales1500.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(axis1, threshint(len(df) * 0.6), inplaceTrue)填充适合缺失率不高、并且字段本身有业务含义的情况。数值列一般用中位数或均值填充分类列用“未知”填充# 用中位数填充价格受极端值影响更小 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(inplaceTrue)实际项目中我几乎不会直接这么用而是会指定subset和keepdf df.drop_duplicates(subset[item_id], keepfirst).reset_index(dropTrue)参数拆开解释subset一个列表按哪些字段判断重复。keepfirst保留第一次出现的行删除后面重复的keeplast保留最后一次keepFalse把重复的全删掉。reset_index(dropTrue)去重后行数减少DataFrame 索引会留下空档。后续如果按索引合并数据容易莫名报错所以我习惯顺手重置索引。如果你想保留最后一次抓到的数据比如商品价格每天在变而你希望留下最新值可以先按采集时间排序再执行drop_duplicatesdf.sort_values(crawl_time, ascendingFalse, inplaceTrue) df df.drop_duplicates(subset[item_id], keepfirst)先排倒序再保留第一个就相当于保留了最后抓到的记录。这个组合用法在很多生产脚本里非常常见。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(lower0, upper9999)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、Nonetitle 里还有空字符串stock 也有缺失。现在就用前面讲的方法一步步把它洗干净。5.2 写一个数据质量报告函数我建议你建一个函数把缺失率、重复率、异常值数量一次性展示出来。这样以后每个爬虫项目都能复用。def data_quality_report(df, key_colsNone): print( 缺失率报告 ) missing_rate df.isnull().mean() print(missing_rate[missing_rate 0]) print(\n 重复率报告 ) if key_cols: key_dup_rate df.duplicated(subsetkey_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(includenumber).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], keepfirst).reset_index(dropTrue) # 3. 数值列转成数值类型非数字会被转成缺失 df[price] pd.to_numeric(df[price], errorscoerce) # 4. 填充缺失库存缺失填0价格缺失填中位数 df[stock] df[stock].fillna(0) df[price] df[price].fillna(df[price].median()) # 5. 异常值截断价格限制在0到9999之间 df[price] df[price].clip(lower0, upper9999) # 6. 最后再跑一遍质量报告 data_quality_report(df, key_cols[item_id])为什么要把pd.to_numeric单独拎出来因为很多爬虫字段看起来是数字实际上存的是字符串甚至混进了一些中文或者空格。比如59.9是字符串-1也可能是字符串。直接用median()会报错clip()也可能静默失败。所以先用errorscoerce做一次强转把不能转的变成缺失再来处理。清洗完以后你可以打印一下 DataFrameprint(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放在爬虫脚本的固定位置每次采集完成先输出一段质量报告再决定要不要入库。一开始会觉得多了一步操作很烦但踩过几次坑之后你就明白了缺失率、重复率、异常值这三个数据质量指标才是真正决定你后续分析靠不靠谱的地基。代码跑通只是第一步数据干净才是能交付的起点。希望这份经验能帮你少走点弯路。