做数据清洗这活儿,很多人觉得没技术含量,不就是删删空值、去去重嘛。但真正接过电商数据、用户行为数据、UGC文本数据的人会知道,清洗才是整个分析链路里最耗时间、最考验耐心、也最容易翻车的一环。这次拿“电商用户评价数据”开刀,是因为它几乎集齐了文本类脏数据的所有经典形态:全角半角混用、emoji表情、HTML残留、默认好评、灌水评论、评分和文字情感互相打脸,甚至连用户ID都涉及隐私合规。这一套流程走完,你基本就掌握了用pandas处理中文文本数据的完整套路,后面的AI文本分析、情感分类、评价关键词挖掘,全都建立在这份干净数据之上。
这个项目是【PythonAI】系列里2.2.5节的项目实战,适合刚学完pandas基础、想通过一个完整案例建立“数据清洗方法论”的读者。不需要你有多高深的理论,但需要你跟着动手敲代码,因为清洗这件事,看十遍不如跑一遍。
1. 为什么拿“电商用户评价”做数据清洗项目:场景与交付物
1.1 评价数据几乎是“脏数据样本间”
电商评价数据在数据清洗教学里属于“上帝故意给你设坑”的那种数据集。它同时涵盖结构化字段和非结构化文本,每一类字段的脏法还不一样。
先看一眼典型的用户评价表长什么样,字段大致如下:
| 字段名 | 含义 | 常见的脏数据形态 |
|---|---|---|
| review_id | 评价ID | 重复导入导致ID重复 |
| user_id | 用户ID | 需脱敏,含手机号样式 |
| product_id | 商品ID | 空值、格式不统一 |
| rating | 评分(1-5) | 字符串“4分”、小数“4.5”、越界值9 |
| review_time | 评价时间 | 混有“2024/1/1”和“2024-01-01”不同格式 |
| review_title | 评价标题 | 大量空值,有的直接把正文复制过来 |
| review_content | 评价内容 | HTML标签、emoji、全半角混用、灌水刷屏 |
| is_direct_purchase | 是否直购 | “是/否”“1/0”“True/False”三种写法 |
| location | 用户地区 | 缺失、省份城市混填 |
我刚拿到真实业务数据的时候,最头疼的不是某一个字段脏,而是这些脏法叠加在一起。比如一条评价内容里同时有HTML标签、emoji、全角括号、还带着一堆连续重复的“啊啊啊啊啊”灌水痕迹,这就不能靠单一规则解决,必须建立一套清洗管道,按顺序处理。
1.2 这份清洗报告要交付什么
很多初学者以为清洗就是把数据弄干净,跑完代码就结束。实际上,在企业里,清洗环节的交付物是报告,不是代码。数据团队把清洗规则、清洗前后的质量对比、每条规则的业务依据讲清楚,业务方和算法团队才敢放心用这份数据。
所以这个项目我定了三个明确交付物:
- 一份可复现的清洗脚本:别人拿到你的代码,跑一遍能还原你的清洗全过程。
- 一份清洗规则说明:每一条规则为什么要这么定、删了多少数据、填充了多少数据,都要有记录。
- 一份清洗前后对比报告:用统计数字展示数据质量的变化,比如重复率从多少降到0,缺失率从多少降到多少。
这也是为什么我在后面专门留了一节讲清洗日志和报告生成——清洗做得再漂亮,没报告就等于白做。
1.3 字段设计:提前为后续分析留好接口
这个项目虽然是清洗,但我在字段设计上会顺手做一些“为后续分析铺路”的事。比如清洗完文本之后,我不会直接丢弃原文本,而是保留一列review_content_clean,供后续做情感分析、词云、关键词提取时使用;再比如我会增加一列review_length(清洗后的文本长度),这列在后面做异常评论检测、内容质量评估时非常有用。
这样做的思路是:清洗不是终点,而是数据分析和建模的起点。如果你只是把数据弄“干净”却丢了原始信息,后面要做特征工程就得倒回去翻原始数据,那就尴尬了。
2. 环境准备与第一轮数据体检
2.1 依赖安装与模拟数据构造
这个项目的核心依赖是pandas,另外需要numpy做数值处理,re做正则文本清洗,openpyxl用于导出Excel报告。如果后面要做简单的情感分析,可以再加jieba,但第一步用不到。
pip install pandas numpy openpyxl考虑到很多读者手头没有真实的电商评价数据,这里我提供一份模拟数据集的构造代码。你可能会问,模拟数据有什么意义?我的回答是:数据分析的真实难点从来不在“有没有数据”,而在“你知不知道数据里可能藏着什么问题”。用模拟数据练手,你可以把每一种脏数据问题都主动埋进去,然后验证自己的清洗逻辑能不能全部识别出来,这比直接拿真实数据被动踩坑效率高得多。
import pandas as pd import numpy as np import random random.seed(42) np.random.seed(42) n = 1200 review_ids = [f"R{i:06d}" for i in range(1, n + 1)] # 埋入各种脏数据:空值、重复、HTML、emoji、全角、混合格式时间 contents_raw = [ "质量很好,值得购买!!!", "一般般吧,<p>没有想象中好</p>", "太差了,客服态度也不好,退货🤬🤬", "物流很慢,等了一周才到,差评", "这个价格真的划算,下次还来😍😍", "客服态度很好,解答很耐心", "用了几天才来评价,效果超出预期", "一般,没有想象中好,凑合用", "垃圾垃圾垃圾垃圾垃圾!!!", "真的很不错啊哈哈哈哈哈哈哈", ] contents = [random.choice(contents_raw) for _ in range(n)] user_ids = [f"U{random.randint(100000, 999999)}" for _ in range(n)] product_ids = [random.choice(["P1001", "P1002", "P1003", "P1004", "P1005"]) for _ in range(n)] # 评分故意混入字符串、小数、越界值 ratings_raw = [] for _ in range(n): r = random.choice([1, 2, 3, 4, 5, "4分", "5分", 4.5, 9, 0]) ratings_raw.append(r) # 时间混入两种格式,并埋少量空值 times_raw = [] for _ in range(n): t = random.choice([ "2024-06-15 12:30:00", "2024/6/15 18:20", "2024-07-01 09:00:00", "", ]) times_raw.append(t) df_raw = pd.DataFrame({ "review_id": review_ids, "user_id": user_ids, "product_id": product_ids, "rating": ratings_raw, "review_time": times_raw, "review_content": contents, "is_direct_purchase": random.choice(["是", "否", "1", "0", "True", "False"] * (n // 6)), })代码里我刻意埋了问题:评分列混有字符串和小数,时间列混有两种格式和空字符串,内容列有HTML和emoji,直购字段有六种写法。这基本上还原了一份真实系统导出数据的混乱程度。
模拟数据造好以后,导出成CSV,模拟“从业务系统拿到一份原始数据”的场景:
df_raw.to_csv("ecommerce_reviews_raw.csv", index=False)2.2 从 info() 和 describe() 里读出的问题
拿到原始数据的第一件事,不是写清洗代码,而是先体检。我习惯按三个步骤来:先看结构,再看分布,最后看样本。
df = pd.read_csv("ecommerce_reviews_raw.csv") # 第一步:结构 df.info()info()会告诉你每一列的非空数量、数据类型、内存占用。看到rating列显示object而不是int64,你就该知道评分字段是文本型的,后面必须做类型转换;看到review_time列里有空字符串被识别成非空值,你也该意识到所谓的“缺失”可能不是NaN,而是空串。
# 第二步:分布 df.describe(include="all")describe()对数值列给出均值、分位数,对文本列给出唯一值个数和众数。比如review_content的唯一值个数远小于总行数,就说明大量重复评论存在,这往往是默认好评或灌水的结果。
# 第三步:抽样看文本 df["review_content"].sample(10, random_state=1).tolist()抽样看文本非常重要,光靠统计数字你根本看不出HTML标签、emoji、全角符号这些东西长什么样。只有亲眼看到“
没有想象中好
”这种带标签的评论,你才知道正则清洗该写什么模式。2.3 体检结果整理成“问题清单”
体检之后,我习惯把发现的问题列成一张表,这张表就是后面清洗工作的“施工图”。
我这次体检发现的问题如下:
| 问题编号 | 字段 | 问题描述 | 拟处理方案 |
|---|---|---|---|
| 1 | review_id | 可能存在重复ID | duplicated()检测后删除 |
| 2 | user_id | 涉及用户隐私 | 脱敏处理 |
| 3 | rating | 类型为object,含“4分”、小数、越界值 | 提取数字、越界删除 |
| 4 | review_time | 格式混杂,有空字符串 | 统一解析为datetime,空值转NaN |
| 5 | review_content | 含HTML、emoji、全角符号、灌水内容 | 正则清洗 |
| 6 | is_direct_purchase | 六种写法混用 | 映射为统一的“是/否” |
先把问题清单列出来,每清洗一个字段就在清单上打钩,这样才不会漏项。这也是清洗工程师和普通写代码的人之间最大的区别:前者按清单施工,后者想到哪洗到哪。
3. 清洗主流程:六类脏数据逐个击破
3.1 重复值:先干掉完全重复,再处理近似重复
重复数据是评价数据集里最常见的问题,来源通常是系统重复导入、用户重复提交、或者是网络重试导致的重复写入。
清洗重复值,我分两步走。第一步处理完全重复:
before_count = len(df) df = df.drop_duplicates() after_count = len(df) print(f"完全重复删除: {before_count - after_count} 条")drop_duplicates()默认对整行所有列进行比较,只要所有字段都一样才算重复。但是实际业务里,完全重复往往只是一小部分,更常见的是近似重复,比如同一个人对同一个商品提交了内容几乎相同但时间不同或者ID不同的两条评价。
第二步,处理近似重复。我通常按user_id + product_id + review_content这三个业务含义上的关键字段来判定重复:
df = df.drop_duplicates(subset=["user_id", "product_id", "review_content"], keep="first") print(f"近似重复删除: {before_count - len(df)} 条")这里有个细节值得注意:keep="first"表示保留重复组中的第一条,但“第一条”不一定是最完整的那条。更稳妥的做法是先对评论内容长度排序,让内容最长的排在最前面,再去重,这样保留下来的往往是信息更完整的评价。
3.2 缺失值:不同字段用不同策略
缺失值的处理没有万能公式,核心原则就一句话:先判断缺失是否有业务含义,再决定是删除、填充还是保留。
对评价数据集来说,我用了三种不同策略。
第一,review_content为空。很多平台对“用户未填写评价内容”会自动生成“此用户未填写评价”,这种空值不能直接删,因为评分本身还是有分析价值的。我用固定文案填充,并单独标记一列:
df["review_content"] = df["review_content"].astype(str).str.strip() df.loc[df["review_content"].isin(["", "nan", "None", "NaN"]), "review_content"] = np.nan df["content_missing"] = df["review_content"].isna().astype(int) df["review_content"] = df["review_content"].fillna("此用户未填写评价")第二,review_time缺失。评价时间对时间序列分析很重要,缺失比例不高时我选择直接删除这几行,因为填充一个虚假时间反而会污染后续分析。删除前先记录删除行数,写进报告。
第三,rating缺失或非法。如果评分缺失,这条评价的数据价值大打折扣,同样选择删除。
df = df.dropna(subset=["review_time", "rating"])这里我想多说一句为什么不全用填充:填充的本质是“用估计值代替缺失值”,它适用于数值分布稳定、缺失比例不高、且后续建模对完整度要求高的场景。但对于评价时间这种强业务字段,你没法估计“用户是哪天写的评价”,强行填充只会制造错误信息。该删就删,别心疼。
3.3 文本清洗:标签、全半角、emoji、不可见字符
文本清洗是评价数据里最繁重的一步。中文用户写评价格式自由,加上很多平台允许富文本,导致HTML标签、emoji、全半角符号一股脑混进评论里。
我封装了一个clean_text函数,按固定顺序处理:
import re import unicodedata def clean_text(text): if not isinstance(text, str): return "" # 1. 去除HTML标签 text = re.sub(r"<[^>]+>", "", text) # 2. 去除方括号内容,如[表情] text = re.sub(r"\[.*?\]", "", text) # 3. 全角转半角,统一空格类型 text = unicodedata.normalize("NFKC", text) text = text.replace("\u3000", " ").replace("\xa0", " ") # 4. 去emoji emoji_pattern = re.compile( "[\U0001F300-\U0001F64F\U0001F680-\U0001F6FF\u2600-\u26FF\u2700-\u27BF" "\U0001F900-\U0001F9FF\U0001FA00-\U0001FA6F\U0001FA70-\U0001FAFF" "\u2B00-\u2BFF\U00002702-\U000027B0]+", flags=re.UNICODE, ) text = emoji_pattern.sub("", text) # 5. 合并多余空白 text = re.sub(r"\s+", " ", text).strip() return text df["review_content_clean"] = df["review_content"].apply(clean_text)每一步的顺序有讲究。我习惯先去掉HTML标签,再全角转半角,最后去emoji。原因是HTML标签里可能包含全角引号或空格,先去了标签可以避免后面处理残留标签碎片;而NFKC全角转半角的处理会对部分全角符号生效,但不影响emoji,所以两者顺序可以灵活,但一定要在strip()之前完成所有替换,否则空白处理会不彻底。
清洗完以后,强烈建议再抽样一次,看看清理效果:
df[["review_content", "review_content_clean"]].sample(5, random_state=3)清洗前后对照着看,你才能确认正则没有误伤正常文本。比如有些用户会故意用“!!!”表达情绪,这种标点重复其实是有业务含义的,不能一刀切删掉。
3.4 格式统一:时间、评分、脱敏
时间字段是重灾区。同一个表里出现“2024-06-15 12:30:00”和“2024/6/15 18:20”很常见。处理思路是让pandas自己识别常见格式,识别不了的强制转成NaT:
df["review_time"] = pd.to_datetime(df["review_time"], errors="coerce") df = df.dropna(subset=["review_time"])errors="coerce"是关键。没有这个参数,只要有一个非法时间格式,整个转换就会直接报错;有了它,解析失败的值会变成NaT,我们再对NaT做删除或填充。
评分字段,先抽取数字字符,再转数值,最后过滤越界值:
df["rating"] = ( df["rating"] .astype(str) .str.extract(r"(\d+(?:\.\d+)?)")[0] .astype(float) ) df = df[(df["rating"] >= 1) & (df["rating"] <= 5)]这里用str.extract提取数字,把“4分”“评分4.5”这类文本统一变成数值4.0或4.5。之后还需要决定要不要把小数评分取整,我倾向于保留原始小数,因为后续如果做评分预测,小数信息是有价值的;如果只是做分类统计,再单独分桶。
用户ID脱敏,按“前2后2,中间打码”的方式处理:
def mask_user_id(uid): s = str(uid) if len(s) <= 4: return "***" return s[:2] + "*" * (len(s) - 4) + s[-2:] df["user_id_masked"] = df["user_id"].apply(mask_user_id)is_direct_purchase字段的六种写法,用映射表统一:
purchase_map = { "是": "是", "1": "是", "True": "是", "true": "是", "否": "否", "0": "否", "False": "否", "false": "否", } df["is_direct_purchase"] = df["is_direct_purchase"].astype(str).map(purchase_map)处理完以后再用df["is_direct_purchase"].value_counts()验证,如果只剩“是/否”两个值,这一列就干净了。
3.5 异常值与矛盾数据:规则化检测
评价数据里的异常值,光靠统计阈值很难发现,因为真正的异常往往是业务逻辑层面的矛盾。
最常见的矛盾是:用户打了5星好评,但评论内容里全是“垃圾”“退货”“差劲”这样的词。这种数据如果不处理,后续做情感分析时会严重干扰模型训练。我用一个简单的关键词规则来标记:
negative_words = ["垃圾", "差", "退货", "退款", "客服", "太慢", "不值", "生气", "失望"] def check_contradiction(row): content = str(row["review_content_clean"]) hits = [w for w in negative_words if w in content] if row["rating"] >= 4 and hits: return "|".join(hits) return "" df["contradiction_hits"] = df.apply(check_contradiction, axis=1)检出矛盾数据后,不急着删,我先打上标记,然后在报告里单独列出,由业务方决定是确认异常还是人工复评。这也是清洗里很重要的一条原则:清洗规则可以自动化,但疑似异常的最终裁决权要让给业务。
除了矛盾文本,还有一类异常是评论长度极端离谱,比如一大段重复的“哈哈哈哈”,明显是刷评论。我在后面单独用灌水检测来处理。
3.6 灌水与默认好评识别
电商评价里有两类典型的“僵尸数据”:一类是超长刷屏,另一类是连续重复字符。
连续重复字符的检测用正则:(.)\1{4,}能匹配同一个字符连续出现5次及以上的情况,比如“啊啊啊啊”“!!!!!”。
repeat_pattern = re.compile(r"(.)\1{4,}") def flag_spam(text): if not isinstance(text, str): return 0 if len(text) > 200: return 1 # 超长 if repeat_pattern.search(text): return 1 # 连续重复 return 0 df["is_spam"] = df["review_content_clean"].apply(flag_spam)注意,连续重复标点“!!!”虽然命中规则,但在中文评价里往往只是强调情绪,不一定是灌水。所以我把这类数据标记出来而不是直接删除,交给业务判断。超长评论则要看业务场景:有的平台确实存在用户认真写长评的情况,不能一概而论。标记,而不是武断删除,这是清洗的安全性底线。
4. 踩坑记录:五个不细看发现不了的细节
4.1 NaN并不一定是“空”
很多系统导出的CSV里,缺失值长着四种不同的脸:真正的空单元格、空字符串“”、字符串"NaN"、以及不可见字符\xa0或\u3000。df.isna()只能识别第一种,后面三种都会被当成正常文本。
如果不信,你可以跑一下这个测试:
import numpy as np s = pd.Series([np.nan, "nan", "", " "]) print(s.isna().tolist()) # [True, False, False, False] print(s.astype(str).str.strip()) # "nan" 和 "" 仍然是字符串解决办法是清洗前统一把各种“空”都转成NaN:
df = df.replace({"": np.nan, "nan": np.nan, "NaN": np.nan, "None": np.nan}) df["review_content"] = df["review_content"].astype(str).str.strip() df["review_content"] = df["review_content"].replace("", np.nan)这个坑我踩过好几次,不统一处理,后面fillna和dropna全都失灵。
4.2 drop_duplicates 的默认行为会漏掉近似重复
drop_duplicates()默认只看整行完全重复,而业务里常见的重复是“同一个人买同一个商品,写了几乎一样的内容,但因为评价ID不同,整行并不重复”。所以我前面专门用了subset=["user_id", "product_id", "review_content"]来限定判定字段。如果你漏了这一步,报告里重复率会显示0%,感觉数据很干净,实际上重复一大片。去重之前先想想:业务上什么样的重复才算重复?
4.3 正则不写 re.S,换行文本清洗会翻车
HTML标签清理时,如果评论内容里含换行符,<p>和</p>可能跨行,普通正则模式.匹配不到换行符,导致标签清理不彻底。解决方法是给re.sub加flags=re.S:
text = re.sub(r"<[^>]+>", "", text, flags=re.S)这个细节看起来很微小,但真实数据里含有换行符的评论比例相当高,尤其是在移动端输入的长评价里。不给re.S,清洗结果就会出现漏网的半个标签。
4.4 评分字段是“4分”而不是4
我见过很多入门项目把评分字段直接astype(int),然后程序报错,才发现评分是文本。更隐蔽的情况是评分里混着“4.0分”“4分半”这类格式。所以评分清洗必须先提取数字,再转类型,顺序反了就会抛异常或者把整列变成object。前面用的str.extract(r"(\d+(?:\.\d+)?)")能同时兼容整数和小数,是一个比较稳的写法。
4.5 去重顺序影响结果
先做文本清洗再去重,和先去重再清洗,结果完全不同。我建议的顺序是:先去重,再清洗文本,最后填充缺失。理由很直接:如果两条相似评价只是格式不同(一条带HTML,一条不带),先清洗再生效会让它们更容易被判定为重复,导致该保留的信息被误删。先去重能保住更多原始信息,虽然这可能让重复检测漏掉一些“伪装成不同格式”的重复,但在信息保全优先的原则下,这个取舍是值得的。
5. 从清洗过程到最终报告:输出与验证
5.1 清洗日志:每步保留数据量
清洗最怕的是“不知道洗掉了什么”。所以我在每个清洗步骤之后都记录当前数据量,汇总成一条清洗流水账。
log = [] def record(step, df_before, df_after, detail=""): log.append({ "步骤": step, "操作前数量": len(df_before), "操作后数量": len(df_after), "减少/处理数量": len(df_before) - len(df_after), "说明": detail, })每执行一步清洗,就调用一次record(),把前后数据量记录下来。最后生成报告的时候,这些日志就是最可靠的数据来源。不要靠记忆写报告,一定要让代码自己统计。
5.2 Markdown报告结构
清洗报告的最终形式,我推荐用Markdown或数据字典表格输出,既方便存档,也方便贴进团队文档。核心结构如下:
# 电商用户评价数据清洗报告 ## 1. 数据概况 - 原始数据量:1200条 - 清洗后数据量:xxxx条 - 涉及字段:12个 ## 2. 问题统计 | 问题类型 | 数量 | 处理方式 | |---|---|---| | 完全重复 | xx | 删除 | | 近似重复 | xx | 按关键字段去重 | | 缺失内容 | xx | 填充默认文案 | | 缺失时间 | xx | 删除 | | 评分异常 | xx | 删除 | | 矛盾数据 | xx | 标记待人工复核 | | 疑似灌水 | xx | 标记待人工复核 | ## 3. 清洗规则说明 ... ## 4. 数据质量对比 | 指标 | 清洗前 | 清洗后 | |---|---|---| | 重复率 | x% | 0% | | 缺失率 | x% | x% | | 文本格式统一度 | x% | 100% |5.3 清洗前后对比汇总
清洗前后对比是报告里最有说服力的部分。我用一个函数汇总关键指标:
summary = pd.DataFrame({ "指标": ["总行数", "重复率", "缺失率", "评分合法率", "时间格式统一率"], "清洗前": [ len(df_raw), f"{df_raw.duplicated(subset=['user_id','product_id','review_content']).mean() * 100:.1f}%", f"{df_raw['review_content'].isna().mean() * 100:.1f}%", f"{(df_raw['rating'].astype(str).str.extract(r'(\\d+)')[0].astype(float).between(1,5)).mean() * 100:.1f}%", "0%", ], "清洗后": [ len(df), "0%", f"{df['content_missing'].mean() * 100:.1f}%", "100%", "100%", ], }) print(summary.to_markdown(index=False))清洗后报告里的数据,要经得起别人追问“你这个重复率怎么算的”“这个缺失率包不包括空字符串”。写报告的时候每一步的算法口径我都在附录里写清楚,宁可啰嗦一点,不要含糊。
6. 做了几轮清洗之后,我想提醒你的几件事
这套流程完整跑过几遍之后,我最深的体会是:清洗报告的真正价值不在那几张统计表,而在于每一个处理动作都可追溯、可解释。我见过不少数据工程师交付的清洗代码,跑完以后问他“你删了多少条?为什么删?”,答不上来。这种清洗结果,业务方是不敢用的。所以从现在开始,养成每步记录日志、用问题清单驱动清洗的习惯,比学会任何单个函数都重要。
另外一个实用的技巧是:清洗脚本尽量写成函数式管道结构,而不是一长串从上到下的赋值语句。把clean_text、clean_rating、clean_time都封装成独立函数,主流程只用几行调用,这样后续新增清洗规则只需要加一个函数,不需要动主逻辑。我自己维护的清洗脚本已经积累了二十多个这样的清洗函数,换一个项目,直接复用一套,效率翻倍。
这个项目做完以后,如果你还想继续深挖,有两条路可以走:一是把清理完的评价文本接进jieba分词和情感分析,把“好评/差评”的文本情感倾向识别出来;二是基于评分和文本长度做异常评价识别模型,把人工审核的范围进一步缩小。但无论走哪条路,前提都是一样的——先把数据洗干净,把报告写清楚。数据这行,地基不牢,上面盖什么都是危楼。