1. 从“脏数据”到“干净数据”:为什么清洗比爬取更重要
上次咱们聊了怎么从大众点评上把评论数据“搬”下来,存进数据库。很多朋友跟着操作,兴冲冲地跑完爬虫,看着数据库里几万条记录,觉得大功告成,可以开始做情感分析、词云图了。但别急,我以过来人的经验告诉你,这时候直接去分析,大概率会得到一堆莫名其妙甚至错误的结果。
为什么?因为爬下来的数据,我们称之为“原始数据”或者“脏数据”。它就像刚从地里挖出来的矿石,里面混着泥土、石块,不经过清洗和提炼,是没法直接拿去锻造的。大众点评的评论数据尤其如此,它来自成千上万个真实用户的随意输入,充满了各种“噪声”。这些噪声如果不处理,会严重干扰后续的文本挖掘模型。
具体有哪些“脏东西”呢?我列几个最常见的:
- 无意义字符和乱码:比如一堆“********”、“@@@”,或者因为编码问题产生的“锟斤拷”。
- 无关的广告和模板文本:比如“系统默认好评”、“此用户没有填写评价”,或者商家自己刷的固定话术。
- 大量的重复内容:用户可能只打了分数没写评论,系统自动生成“默认评论”,或者用户复制粘贴了同样的评价。
- 极端简短的无效评论:比如只有一个“好”、“棒”、“差”字,这种评论信息量几乎为零。
- HTML/JS实体和特殊符号:爬虫虽然解析了标签,但有时会残留
<br>换行符的文本表示、 空格符,或者一堆表情符号的代码。 - 结构不一致的评分信息:我们爬取的“口味”、“环境”、“服务”字段,有时是“很好”,有时是“非常好”,有时干脆缺失,需要统一。
如果直接把这些数据喂给词频统计工具,你会发现“默认”、“好评”、“用户”这些词成了高频词,完全扭曲了真实的产品口碑。如果用来训练情感分析模型,模型会被这些噪声带偏,学不到真正的评价规律。
所以,数据清洗的目标,就是把“脏数据”变成“干净数据”,确保每条评论都是真实、完整、格式统一的,为后续的深度分析打下坚实基础。这个过程可能有点枯燥,但绝对是事半功倍的关键一步,磨刀不误砍柴工。
2. 实战文本清洗:一步步搞定评论里的“花活儿”
清洗数据没有一成不变的公式,但有一套通用的“组合拳”。下面我结合代码,带你一步步处理我们爬取的大众点评评论数据。假设我们的数据已经以字典列表的形式加载在 raw_comments 变量里。
2.1 基础清洗:去除“肉眼可见”的噪声
第一步,我们先处理那些明显的、格式化的噪声。这里主要用Python的字符串方法和正则表达式(re库)。
import re
def basic_clean(text):
"""
基础文本清洗函数
"""
if not isinstance(text, str):
return ""
# 1. 去除HTML标签残留(例如 <br> 被转义后的形式)
text = re.sub(r'<.*?>', '', text)
text = re.sub(r'&[a-z]+;', '', text) # 去除 & 等
# 2. 去除无意义的重复字符(如多个*、#、~)
text = re.sub(r'[*#~]{2,}', '', text)
# 3. 去除常见的默认评价模板(可根据实际情况扩充)
default_templates = [
'系统默认好评',
'此用户没有填写评价',
'用户未点评',
'默认评论',
'评价方未及时做出评价'
]
for template in default_templates:
if template in text:
return "" # 直接标记为空,后续可过滤
# 4. 统一换行符和多余空白
text = text.replace('\r\n', '\n').replace('\r', '\n')
text = re.sub(r'\n+', '\n', text) # 合并多个换行
text = re.sub(r'[ \t]+', ' ', text) # 合并多个空格/制表符
text = text.strip() # 去除首尾空白
return text
# 应用到评论字段
for comment in raw_comments:
comment['cus_comment'] = basic_clean(comment['cus_comment'])
这个函数像一把大扫帚,先把最表面的垃圾清理掉。特别要注意默认评价模板,它们对分析毫无价值,我通常直接将其清洗为空字符串,后面再统一过滤掉。
2.2 深度处理:应对中文文本的特有问题
基础清洗后,我们面对的是相对纯净的中文文本。但中文有自己的特点,比如没有自然空格分词、存在繁体简体、全角半角符号等。
import jieba
from zhon.hanzi import punctuation as zh_punctuation
def advanced_clean(text):
"""
高级文本清洗与处理
"""
if not text:
return ""
# 1. 处理全角字符:将全角字母、数字、符号转换为半角
# 这里简化处理,实际项目中可用 `str.maketrans` 做完整映射
text = re.sub(r'[A-Za-z0-9]', lambda x: chr(ord(x.group()) - 0xfee0), text)
# 2. 去除所有中文标点符号(根据分析目标决定是否保留)
# 如果后续要做句法分析,可能需要保留。这里假设我们只关心词。
all_punctuation = zh_punctuation + '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
text = re.sub(f'[{re.escape(all_punctuation)}]+', ' ', text)
# 3. 处理超短评论:如果清洗后字符数少于2,视为无效
if len(text.strip()) < 2:
return ""
# 4. (可选) 繁体转简体,如果需要的话
# 可以使用 `opencc` 库,这里不展开
return text.strip()
# 应用高级清洗
for comment in raw_comments:
cleaned = advanced_clean(comment['cus_comment'])
comment['cus_comment_cleaned'] = cleaned
# 可以同时保留原始文本和清洗后文本,以备不时之需
这里我创建了一个新字段 cus_comment_cleaned 来存储深度清洗后的结果。保留原始文本是一个好习惯,因为有些清洗操作是不可逆的,万一后续需要回溯查看原始信息呢?
2.3 处理缺失值与评分标准化
除了文本内容,我们爬取的结构化字段(口味、环境、服务)也需要清洗。
def clean_and_standardize_scores(comment):
"""
清洗和标准化评分字段
"""
# 处理缺失值:将‘无’或空值转换为统一的NaN占位符,例如‘未知’
score_fields = ['kouwei', 'huanjing', 'fuwu']
for field in score_fields:
if comment.get(field) in [None, '', '无', '暂无']:
comment[field] = '未知'
# 标准化评分文本:将“很好”、“非常好”、“好”等映射为统一等级
# 首先,我们可以建立一个简单的映射字典
score_mapping = {
'很差': 1, '差': 2, '一般': 3, '好': 4, '很好': 5, '非常好': 5,
'非常差': 1, '较差': 2, '还行': 3, '较好': 4, '极好': 5
}
for field in score_fields:
raw_score = comment.get(field, '未知')
if raw_score != '未知' and raw_score in score_mapping:
# 新增一个标准化后的数值字段,便于后续计算平均分等
comment[f'{field}_numeric'] = score_mapping[raw_score]
else:
comment[f'{field}_numeric'] = None # 无法映射的仍为空
return comment
# 应用评分清洗
raw_comments = [clean_and_standardize_scores(c) for c in raw_comments]
通过这个步骤,我们把“口味:很好”这样的文本,转换成了一个可以参与数学计算的数值 5。同时,把缺失值统一标记为“未知”,避免了后续分析时因数据类型混乱而报错。
2.4 去重与最终过滤
最后一步,我们把清洗后的数据做一次整体过滤,去除重复项和无效数据。
def final_filtering(comments_list):
"""
最终过滤:去重、去除空评论、去除无效数据
"""
# 1. 基于关键字段去重(假设同一用户ID在同一店铺同一时间的评论为重复)
seen = set()
unique_comments = []
for comment in comments_list:
# 构造一个唯一标识符
identifier = (comment.get('cus_id'), comment.get('shopID'), comment.get('comment_time')[:10]) # 取日期到天
if identifier not in seen and comment.get('cus_comment_cleaned'):
seen.add(identifier)
unique_comments.append(comment)
# 2. 过滤掉清洗后评论内容为空的记录
filtered_comments = [c for c in unique_comments if c.get('cus_comment_cleaned', '').strip()]
# 3. (可选) 过滤掉评分全部为‘未知’且评论极短的记录,可能是无效刷单
final_comments = []
for c in filtered_comments:
scores_unknown = all([c.get(f) == '未知' for f in ['kouwei', 'huanjing', 'fuwu']])
comment_short = len(c.get('cus_comment_cleaned', '')) < 5
if not (scores_unknown and comment_short):
final_comments.append(c)
print(f"原始数据量:{len(comments_list)}, 去重并过滤后:{len(final_comments)}")
return final_comments
cleaned_comments = final_filtering(raw_comments)
走完这四步,我们的数据就从一个“毛坯房”变成了“精装房”,干净、整齐、结构一致。你可以把清洗前后的数据各打印几条对比一下,成就感满满。接下来,我们就要考虑怎么把这些精装数据高效、安全地存进“仓库”(数据库)了。
3. 数据库存储优化:让海量评论存得快、查得爽
数据清洗干净了,如果存储环节是瓶颈,那前面的功夫就白费了。特别是当评论数据量达到十万、百万级时,糟糕的数据库设计会让你在查询时等到怀疑人生。我来分享几个让MySQL存储效率倍增的实战技巧。
3.1 表结构设计:为分析而生
首先,别再用爬虫时那个简单的表结构了。为了分析,我们需要设计得更合理。假设我们的数据库叫 dzdp_analysis。
CREATE TABLE `shop_comments` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键,自增',
`shop_id` bigint(20) NOT NULL COMMENT '店铺ID,用于关联店铺信息',
`cus_id` varchar(100) NOT NULL COMMENT '用户ID',
`comment_time` datetime NOT NULL COMMENT '评论时间',
`comment_star` tinyint(4) NOT NULL COMMENT '总体星级(1-5)',
`kouwei` varchar(10) DEFAULT '未知' COMMENT '口味评分文本',
`kouwei_num` tinyint(4) DEFAULT NULL COMMENT '口味评分数值(1-5)',
`huanjing` varchar(10) DEFAULT '未知' COMMENT '环境评分文本',
`huanjing_num` tinyint(4) DEFAULT NULL COMMENT '环境评分数值(1-5)',
`fuwu` varchar(10) DEFAULT '未知' COMMENT '服务评分文本',
`fuwu_num` tinyint(4) DEFAULT NULL COMMENT '服务评分数值(1-5)',
`comment_raw` text COMMENT '原始评论内容(保留备用)',
`comment_clean` text NOT NULL COMMENT '清洗后的评论内容',
`word_count` int(11) GENERATED ALWAYS AS (CHAR_LENGTH(`comment_clean`)) STORED COMMENT '评论字数(生成列)',
`crawl_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '爬取入库时间',
PRIMARY KEY (`id`),
KEY `idx_shop_time` (`shop_id`,`comment_time`),
KEY `idx_cus` (`cus_id`),
KEY `idx_star` (`comment_star`),
KEY `idx_wordcount` (`word_count`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大众点评清洗后评论主表';
这个设计有几个小心思:
id自增主键:InnoDB引擎下,自增主键对插入性能最友好,能避免页分裂。- 评分字段分离:既保留了原始文本(
kouwei),也增加了数值型字段(kouwei_num),后者用于快速计算平均分、分组统计。 - 评论内容分离:
comment_raw存原始文本,comment_clean存清洗后文本。分析时只用干净文本,但原始文本可用于复查或不同的清洗策略。 - 生成列(Generated Column):
word_count字段是MySQL 5.7+的黑科技。它自动计算comment_clean的字数,并物理存储。这样我们想筛选“长评论”(比如字数>50)时,可以直接在这个列上查询或建索引,速度快得飞起,省去了每次用CHAR_LENGTH()函数计算的成本。 - 字符集
utf8mb4:必须用这个,才能支持所有Emoji表情(比如用户评论里的😊👍),老的utf8会出问题。 - 合理的索引:这是性能的关键,下面单独讲。
3.2 索引设计:给查询装上“导航”
没有索引的数据库查询,就像在图书馆里找一本书,却不知道目录和书架编号,只能一排排从头翻到尾(全表扫描)。索引就是书的目录。
根据我们常见的分析场景,我设计了上面表结构里的几个索引:
PRIMARY KEY (id):主键索引,默认就有,用于快速按ID定位。KEY idx_shop_time (shop_id, comment_time):联合索引。这是最重要的一个索引。我们90%的查询可能都是“查某个店铺最近一个月的评论”或者“按店铺分组统计时间趋势”。这个索引把shop_id放在前面,再按comment_time排序,使得这类查询可以直接在索引里完成,速度极快。KEY idx_cus (cus_id):用于查询某个用户的所有评论。KEY idx_star (comment_star):用于快速筛选好评(5星)或差评(1-2星)。KEY idx_wordcount (word_count):用于快速查找长评或短评。
注意:索引不是越多越好。每个索引都会占用磁盘空间,并在每次插入、更新、删除数据时增加维护开销。上面这几个是针对典型查询场景的,如果你有“经常按kouwei_num排序”的需求,那可能需要再加一个idx_kouwei索引。需要根据你的实际查询模式来调整。
3.3 高效批量插入:告别“一条一条”的蜗牛速度
爬虫数据入库,最忌讳的就是用for循环一条条执行INSERT。每一条INSERT都是一次网络通信+磁盘写入,速度慢得令人发指。必须用批量插入。
import pymysql
from pymysql.cursors import DictCursor
def batch_insert_comments(comments_list, batch_size=500):
"""
批量插入评论数据到MySQL
:param comments_list: 清洗后的评论字典列表
:param batch_size: 每批插入的数量,建议在200-1000之间调整
"""
# 1. 连接数据库
db = pymysql.connect(
host='localhost',
user='your_user',
password='your_password',
database='dzdp_analysis',
charset='utf8mb4',
cursorclass=DictCursor
)
cursor = db.cursor()
# 2. 准备SQL语句
insert_sql = """
INSERT INTO shop_comments
(shop_id, cus_id, comment_time, comment_star,
kouwei, kouwei_num, huanjing, huanjing_num, fuwu, fuwu_num,
comment_raw, comment_clean)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
# 3. 分批处理数据
for i in range(0, len(comments_list), batch_size):
batch = comments_list[i:i+batch_size]
# 构造批量插入的参数元组列表
values = []
for comment in batch:
val = (
comment['shopID'],
comment['cus_id'],
comment['comment_time'],
int(comment['comment_star']),
comment.get('kouwei', '未知'),
comment.get('kouwei_numeric'),
comment.get('huanjing', '未知'),
comment.get('huanjing_numeric'),
comment.get('fuwu', '未知'),
comment.get('fuwu_numeric'),
comment.get('cus_comment'), # 原始评论
comment.get('cus_comment_cleaned') # 清洗后评论
)
values.append(val)
try:
# executemany 是批量插入的关键!
cursor.executemany(insert_sql, values)
db.commit()
print(f"成功插入第 {i//batch_size + 1} 批,共 {len(batch)} 条记录")
except Exception as e:
db.rollback()
print(f"第 {i//batch_size + 1} 批插入失败,错误:{e}")
# 这里可以记录失败的批次,或者改为单条插入尝试
# 但为了效率,通常记录日志后跳过,事后补漏
cursor.close()
db.close()
print("所有数据插入完成!")
# 调用批量插入函数
batch_insert_comments(cleaned_comments, batch_size=500)
使用 cursor.executemany() 一次性发送多条数据,数据库只需要执行一次SQL解析和优化,网络往返次数也大大减少。我实测过,插入1万条数据,一条条插要几分钟,批量插入(batch_size=500)可能只需要十几秒,性能提升几十倍。
batch_size的选择:不是越大越好。太大可能导致单个SQL包过大,或者数据库事务锁住太久。一般设置在200到1000之间,你可以根据自己数据库的性能和网络情况测试一个最佳值。
4. 避坑指南与进阶思考
在实际操作中,我踩过不少坑,这里总结一下,希望你能绕过去。
关于编码的坑:Python连接MySQL时,一定要在连接参数里指定 charset='utf8mb4',并且在建表语句中也声明 CHARSET=utf8mb4。否则,那些可爱的Emoji表情存进去就会变成一堆问号 ???。
关于连接管理的坑:别在循环内部频繁连接和断开数据库。像上面的例子,在整个批量插入过程中,只建立一次连接,用完再关闭。频繁开关连接的开销非常大。对于长期运行的爬虫或数据处理脚本,可以考虑使用连接池(如 DBUtils 或 SQLAlchemy 的连接池),性能会更稳定。
关于事务的坑:批量插入时,我每批提交一次事务(db.commit())。这样做的好处是,如果某一批插入失败,只回滚这一批(db.rollback()),不会影响前面已经成功插入的数据。同时,也避免了将所有数据放在一个超大事务里,导致数据库产生巨大的回滚日志,影响性能。
数据备份与版本化:清洗和存储过程中,原始数据、中间清洗数据、最终入库数据,最好都有备份。我习惯用时间戳命名文件,比如 raw_data_20231027.json, cleaned_data_20231027.csv。这样如果后续清洗逻辑改了,我可以随时用原始数据重新跑一遍,而不用重新爬取。
下一步做什么? 数据清洗和存储优化完成后,你的数据仓库就建好了。接下来,你就可以在这个干净、高效的数据基础上大展拳脚:比如,用 jieba 分词和 wordcloud 做词云图,看看这家店的招牌产品是什么;用 snownlp 或 sklearn 做情感分析,分析口碑变化趋势;或者计算口味、环境、服务的平均分,找出店铺的优劣势。有了扎实的数据基础,这些分析做起来才会得心应手,结果也才可信。
825



被折叠的 条评论
为什么被折叠?



