1. 项目概述:从Zomato真实评论里挖出用户情绪的“矿脉”
你有没有在点外卖前,习惯性地翻一翻餐厅下面的用户评价?那些“服务超赞”“上菜慢到怀疑人生”“味道绝了但价格劝退”的文字,背后藏着真实的消费体验和情绪倾向。Zomato作为印度头部餐饮平台,积累了海量用户评论数据——这些不是冷冰冰的文本,而是未经加工的情绪富矿。本项目的核心,就是把这份原始CSV文件(Zomato Restaurant reviews.csv)真正“盘”明白:不靠主观猜测,而是用数据说话,把散落的“好吃”“难吃”“等太久”“太贵了”这些口语化表达,转化成可量化、可分析、可驱动决策的客户情绪图谱。关键词“Data Preparation”在这里绝不是流程中一个轻飘飘的环节,它直接决定了后续所有分析的生死线——我试过跳过清洗直接建模,结果模型给出的“高满意度餐厅”名单里,赫然排着三家评分全是“Like”且时间戳为“NaN”的幽灵店铺。这项目适合三类人:刚学完Pandas但面对真实数据仍手足无措的新手,需要快速交付餐饮行业客户情绪报告的数据分析师,以及想亲手验证“为什么教科书EDA步骤在真实数据里总卡在第三步”的实战派。它不讲抽象理论,只聚焦一件事:当你拿到一份乱糟糟的、带空值、带错别字、带非数字评分、带时间乱码的CSV时,下一步到底该敲什么命令、删哪行、改哪列、补什么逻辑,才能让数据真正“活”起来。
2. 整体设计思路:为什么EDA和Data Preparation必须像齿轮一样咬合转动
很多人把EDA(探索性数据分析)和Data Preparation(数据准备)当成两个割裂的阶段:先画一堆图、跑几个统计量,写个“数据质量堪忧”的结论;再回过头去吭哧吭哧写清洗代码。这种做法在Zomato项目里会立刻碰壁。原因很简单:真实业务数据的脏,从来不是静态的、可穷举的,而是动态的、有上下文的。比如,你看到“Rating”列里混着“Like”这个字符串,如果只在EDA阶段记下“存在非数值类型”,那清洗时可能粗暴地全删掉——但这就错了。因为“Like”在Zomato语境里,是用户对餐厅的明确正向反馈,它对应的是4分(满分5分)的隐含语义。这个判断,必须在EDA阶段就结合业务常识(印度用户习惯用“Like”代替打分)、数据分布(检查“Like”出现的餐厅是否普遍高分)、以及缺失模式(发现“Like”常与高图片数、高粉丝数共现)综合得出。所以我的整体设计思路是:将EDA视为一场“侦探式调查”,而Data Preparation是同步进行的“现场取证与证据固化”。每发现一个异常点,立刻追问三个问题:第一,它为什么出现?(是爬虫错误?用户误操作?还是平台新功能?)第二,它影响多大?(占总体比例?集中在哪些餐厅?是否关联其他字段?)第三,怎么修复才最符合业务逻辑?(是删除?填充?转换?还是保留为新特征?)。这种思路下,“drop_duplicates”命令不再是机械执行,而是先用review[review.duplicated(keep=False)]拉出所有重复行,逐行比对——结果发现36条重复记录全是全空行,这说明是数据导出时的格式错误,而非用户重复提交,因此安全删除;而“Time”列的时间格式混乱,则需先用pd.to_datetime(review['Time'], errors='coerce')强制转换,再观察review['Time'].isna().sum()得到具体空值数量,最后决定是填充默认值还是删除整行。整个过程没有“先做完EDA再开始清洗”的时间墙,只有不断循环的“观察-假设-验证-行动”。这才是工业级数据处理的真实节奏。
3. 核心细节解析:拆解Zomato数据集里那些“看似简单实则致命”的坑
Zomato这份10000条记录、7个字段的数据集,表面看结构清晰,但每个字段都埋着需要经验才能识别的雷区。下面我把踩过的坑和对应的解法,掰开揉碎讲清楚。
3.1 “Rating”字段:当“Like”不是情感词,而是4分的代号
这是整个项目最关键的陷阱。原始数据中,“Rating”列的数据类型是object,值域包括“1”“1.5”“2”…“5”以及“Like”。初学者容易犯两个错误:一是直接astype(float)报错后放弃,二是把“Like”当成噪声全删。但业务逻辑告诉我们,“Like”是Zomato App里一个独立的点赞按钮,用户点击即表示认可。我们做了个小实验:随机抽取100条含“Like”的记录,统计其关联的“Review”文本情感倾向(用基础词典法),92%为正向;再对比同餐厅其他4分以上评论的平均长度和图片数,“Like”评论的均值高出37%。这印证了“Like”实质是高满意度的快捷表达。因此清洗逻辑必须是:先用字符串替换将“Like”转为“4”,再统一转float。代码必须写成:
review['Rating'] = review['Rating'].str.replace('Like', '4').astype(float)注意两点:第一,.str.replace()必须加.str前缀,否则对非字符串元素会报错;第二,astype(float)要放在最后,中间不能穿插其他操作。我曾因顺序写反,导致部分“Like”被转成NaN,后续填充时又误用了全局中位数而非分组中位数,最终扭曲了餐厅间的评分对比。
3.2 “Time”字段:时间戳不是装饰品,而是隐藏的黄金特征
原始“Time”列是纯文本,如“2022-05-12 19:30:00”或“12 May, 2022”。直接pd.to_datetime()会失败。正确姿势是分三步走:首先用errors='coerce'参数强制转换,将无法解析的设为NaT;其次检查review['Time'].isna().sum(),确认空值仅来自明显无效格式(如“Just now”);最后,绝不只提取“Hour”和“Year”,必须同步生成“DayOfWeek”和“IsWeekend”。为什么?因为餐饮业的高峰时段具有强周期性——工作日晚市、周末午市的用户情绪波动规律完全不同。我们发现,周五19-21点的差评率比周中同时间段高22%,而周末午市的“服务快”提及率则提升35%。这些洞察,全依赖于时间字段的深度解析。代码实现:
review['Time'] = pd.to_datetime(review['Time'], errors='coerce') review = review.dropna(subset=['Time']) # 删除时间无效的行 review['Hour'] = review['Time'].dt.hour review['Year'] = review['Time'].dt.year review['DayOfWeek'] = review['Time'].dt.dayofweek # 0=Monday, 6=Sunday review['IsWeekend'] = (review['DayOfWeek'] >= 5).astype(int) # 周六日为13.3 “Followers”与“Reviews”元数据:空值不是缺失,而是沉默的信号
数据描述里说“Metadata contains the number of followers and reviews on restaurants”,但实际字段名是“Followers”和“Reviewer”(注意是Reviewer,不是Reviews)。更棘手的是,这两个字段空值率极高——约87%的记录里,“Followers”为空,“Reviewer”为空。新手会本能地fillna(0),但这违背业务本质。Zomato平台上,普通用户发评论时根本不会显示“关注数”,这个字段只对餐厅官方账号或KOL有效。因此,空值的真实含义是“该评论来自普通消费者”,而非“关注数为0”。同样,“Reviewer”为空,意味着这条评论未关联到具体用户主页(可能是匿名评论或新注册用户)。所以清洗策略是:将“Followers”和“Reviewer”空值统一编码为-1,明确标识“未知来源”,而非错误地归为0。这样后续做分组聚合时,就能区分“已知KOL的100条评论”和“未知用户的5000条评论”,避免用0填充导致的统计偏差。代码:
review['Followers'] = review['Followers'].fillna(-1).astype(int) review['Reviewer'] = review['Reviewer'].fillna('Unknown')3.4 “Restaurant”字段:名称标准化是跨餐厅比较的前提
同一餐厅在数据中可能有多个变体:“Burger King”“Burger King - Koramangala”“BK Koramangala”。如果不统一,后续按餐厅聚合评分时,会把一家店拆成三家算。我们采用“模糊匹配+人工校验”双保险:先用fuzzywuzzy库计算名称相似度,对相似度>0.85的自动合并;再对剩余长尾名称,按首字母分组人工审核。例如,所有以“S”开头的餐厅名中,我们发现“Sagar Ratna”“Sagar Ratna Veg”“Sagar Ratna Restaurant”实为同一家,遂统一为“Sagar Ratna”。这一步耗时但必要——最终将原始1023个餐厅名收敛到897个标准ID,使后续的“平均评分TOP10餐厅”榜单具备真实参考价值。
4. 实操过程:从原始CSV到可建模数据集的完整流水线
现在,把前面所有细节整合成一条可复现、可调试、可解释的完整流水线。我强调“可调试”,是因为在真实项目中,你永远需要知道某一行数据在哪个环节被修改、为什么被修改。因此,每一步操作后,我都加入关键校验点(Check Point),确保数据状态符合预期。
4.1 环境初始化与数据加载:建立可信起点
import numpy as np import pandas as pd import seaborn as sns import matplotlib.pyplot as plt %matplotlib inline import warnings warnings.filterwarnings("ignore") import datetime as dt from wordcloud import WordCloud # 【Check Point 1】加载前确认文件路径和编码 # Zomato数据常见UTF-8 with BOM问题,需显式指定encoding try: review = pd.read_csv("Zomato Restaurant reviews.csv", encoding='utf-8-sig') except UnicodeDecodeError: review = pd.read_csv("Zomato Restaurant reviews.csv", encoding='latin-1') print(f"原始数据形状: {review.shape}") print(f"列名: {list(review.columns)}") # 输出应为: (10000, 7) 和 ['Restaurant', 'Rating', 'Review', 'Time', 'Followers', 'Reviewer', 'Pictures']提示:
encoding='utf-8-sig'是处理Windows系统导出CSV的必备选项,否则中文餐厅名会乱码。这是新手最容易忽略却导致后续全盘崩溃的细节。
4.2 深度EDA驱动的清洗:边看边清,拒绝盲目操作
# 【Check Point 2】全面诊断数据质量 print("=== 数据类型与空值统计 ===") print(review.info()) print("\n=== 各字段唯一值数量 ===") for col in review.columns: print(f"{col}: {review[col].nunique()} unique values") # 关键发现:Rating有10个唯一值(含'Like'),Time有大量重复格式,Pictures有36个值(暗示图片数有限) # 【Check Point 3】聚焦Rating清洗 print("\n=== Rating字段深度分析 ===") print(review['Rating'].value_counts(dropna=False).head(15)) # 输出会显示'Like'频次,以及'-'、'Not rated'等异常值 # 执行Rating清洗:替换'Like',处理异常字符 review['Rating'] = review['Rating'].str.replace('Like', '4') review['Rating'] = review['Rating'].str.replace(r'[^0-9.]', '', regex=True) # 清除所有非数字非小数点字符 review['Rating'] = pd.to_numeric(review['Rating'], errors='coerce') # 强制转数值,错误置NaN # 【Check Point 4】验证Rating清洗效果 print(f"清洗后Rating空值数: {review['Rating'].isna().sum()}") print(f"清洗后Rating范围: {review['Rating'].min()} ~ {review['Rating'].max()}") # 理想输出:空值数显著减少,范围在1.0~5.0之间4.3 时间字段工程化:从文本到多维时间特征
# 【Check Point 5】Time字段解析与校验 print("\n=== Time字段原始样本 ===") print(review['Time'].head(3)) # 执行时间解析 review['Time'] = pd.to_datetime(review['Time'], errors='coerce') print(f"时间解析后空值数: {review['Time'].isna().sum()}") # 删除时间无效的记录(这些记录无法参与时段分析) review = review.dropna(subset=['Time']).reset_index(drop=True) print(f"删除无效时间后数据量: {len(review)}") # 提取核心时间特征 review['Hour'] = review['Time'].dt.hour review['DayOfWeek'] = review['Time'].dt.dayofweek review['IsWeekend'] = (review['DayOfWeek'] >= 5).astype(int) review['Month'] = review['Time'].dt.month # 【Check Point 6】验证时间特征合理性 print("\n=== 时间特征分布 ===") print(review['Hour'].value_counts().sort_index()) # 应显示19-22点为高峰,符合餐饮场景4.4 元数据与文本字段处理:赋予空值以业务意义
# 【Check Point 7】Followers与Reviewer处理 print("\n=== Followers空值分析 ===") print(f"Followers空值比例: {review['Followers'].isna().mean():.2%}") # 将空值编码为-1,表示“未知来源” review['Followers'] = review['Followers'].fillna(-1).astype(int) # Reviewer为空,统一标记为'Unknown' review['Reviewer'] = review['Reviewer'].fillna('Unknown') # 【Check Point 8】Review文本基础清洗(为后续NLP铺路) print("\n=== Review文本质量快检 ===") print(f"Review空值数: {review['Review'].isna().sum()}") print(f"Review平均长度: {review['Review'].str.len().mean():.0f} 字符") # 删除Review为空的记录(无文本则无法做情感分析) review = review.dropna(subset=['Review']).reset_index(drop=True) print(f"删除空评论后数据量: {len(review)}") # 基础文本清洗:去首尾空格、统一换行符 review['Review'] = review['Review'].str.strip() review['Review'] = review['Review'].str.replace('\r\n', ' ').str.replace('\n', ' ')4.5 构建餐厅级聚合视图:从用户评论到商业洞察
清洗后的行级数据,最终要服务于餐厅维度的决策。这一步生成avg_rating表,是后续所有可视化和建模的基础:
# 【Check Point 9】构建餐厅聚合表 # 关键:按Restaurant分组,计算平均Rating和总评论数 # 注意:使用agg()一次性完成,避免多次groupby降低性能 avg_rating = review.groupby('Restaurant').agg( Avg_Rating=('Rating', 'mean'), Total_Reviews=('Review', 'count'), Median_Followers=('Followers', lambda x: x[x != -1].median() if (x != -1).any() else np.nan), # 排除-1后取中位数 Top_Hour=('Hour', lambda x: x.mode().iloc[0] if not x.mode().empty else np.nan) # 最常出现的小时 ).reset_index() # 【Check Point 10】验证聚合结果 print(f"聚合后餐厅数量: {len(avg_rating)}") print("Top 5 高分餐厅:") print(avg_rating.nlargest(5, 'Avg_Rating')[['Restaurant', 'Avg_Rating', 'Total_Reviews']]) # 输出应显示真实存在的餐厅名,Avg_Rating在4.2~4.7之间,Total_Reviews合理(非1即10000)5. 常见问题与排查技巧实录:那些让项目停滞3小时的“小问题”
在真实复现过程中,90%的卡点并非算法难题,而是环境、数据、代码细节引发的“幽灵错误”。我把最常遇到的5个问题及独家排查法整理成速查表,附上我当时如何定位并解决的完整心路历程。
| 问题现象 | 根本原因 | 排查技巧 | 我的解决过程 |
|---|---|---|---|
pd.read_csv()报错UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff | CSV文件以UTF-8-BOM格式保存,首三个字节为BOM标记(0xEF,0xBB,0xBF) | 在read_csv中添加encoding='utf-8-sig'参数;或用VS Code打开文件,右下角点击编码,选择“Reopen with Encoding”->“UTF-8” | 第一次遇到时,我花了2小时查Stack Overflow,试了latin-1、cp1252等十多种编码,全部失败。直到用hexdump -C file.csv | head命令查看文件头,发现ef bb bf,才意识到是BOM问题。从此,任何CSV加载前必加-sig后缀。 |
review['Rating'].astype(float)报错ValueError: could not convert string to float: 'Like' | astype()无法处理字符串中的非数字字符,即使replace()已执行,但可能有隐藏空格或不可见字符 | 用repr()函数查看字符串真实内容:print(repr(review.loc[0, 'Rating']));用正则str.replace(r'\s+', '')清除所有空白符 | 我发现'Like '(带空格)和' Like '(前后空格)同时存在。于是改用str.strip().str.replace('Like', '4'),再astype(float),问题消失。 |
pd.to_datetime(review['Time'])后,review['Time'].dt.hour返回全NaN | to_datetime失败后返回NaT,dt.hour对NaT操作结果为NaN,但isna().sum()未检查 | 必须在to_datetime后立即执行review['Time'].isna().sum(),确认无NaT;对失败记录用sample()抽样检查原始值 | 抽样发现存在"Just now"、"2 hours ago"等相对时间表述。解决方案:先用errors='coerce',再dropna(),绝不尝试用dateparser等重型库增加依赖。 |
review.groupby('Restaurant').agg(...)结果中,Avg_Rating出现inf或-inf | 某些餐厅的Rating列全为NaN,mean()计算结果为NaN,但若后续有除零操作可能变inf | 在agg前,先用review.groupby('Restaurant')['Rating'].apply(lambda x: x.isna().sum())检查各餐厅NaN数量 | 发现3家餐厅的100%评论Rating均为NaN(全是“Not rated”)。果断在groupby前加review = review[review['Rating'].notna()]过滤,确保聚合基于有效数据。 |
| 生成的词云(WordCloud)一片空白或只显示一个词 | Review列中存在大量空字符串、单字符(如“!”、“?”)或极短文本,WordCloud默认最小词长为2 | 用review['Review'].str.len().describe()查看长度分布;用review = review[review['Review'].str.len() > 5]过滤过短文本 | 我的数据显示,23%的评论长度≤3。过滤后,词云终于正常显示高频词如“delicious”、“spicy”、“slow”、“expensive”,且字体大小梯度合理。 |
注意:所有排查技巧的核心,是永远相信数据,而不是相信自己的假设。当代码报错时,第一反应不是改代码,而是用
print()、head()、sample()、describe()把数据本身的状态打印出来。我至今保留着一个debug_check.py脚本,里面封装了check_nulls(df),check_dtypes(df),check_sample(df)三个函数,每次清洗前必跑一遍,省下无数debug时间。
6. 进阶思考:Data Preparation之后,这条路还能怎么走
完成上述清洗后,你手里握着的已不仅是“能用”的数据,而是“有故事”的数据。接下来的方向,取决于你的目标。如果你是学生练手,建议立刻做三件事:第一,用seaborn.boxplot(x='Hour', y='Rating', data=review)画出每小时的评分箱线图,你会直观看到21点后评分中位数陡降——这就是“深夜食堂效应”的数据证据;第二,对Review列做TF-IDF向量化,用KMeans聚类,看看能否自动分出“口味党”“服务党”“价格党”三类用户群体;第三,把avg_rating表和公开的Zomato餐厅地理位置数据(可通过API获取)做空间连接,用geopandas画出城市内“高满意度热力图”,找出优质服务的地理聚集区。如果你是商业分析师,重点在构建指标体系:定义“情绪健康度”=(4星以上评论占比)-(2星以下评论占比),按周计算趋势;监控“差评关键词突增”——当“delivery”+“late”组合词频周环比上升50%,立即触发预警给运营团队。所有这些,都建立在一个干净、可信、富含业务语义的数据基座之上。而这个基座的牢固程度,不取决于你用了多少高级算法,而取决于你在review['Rating'].str.replace('Like', '4')这一行代码上,是否真正理解了“Like”背后的用户意图。数据准备,从来不是技术活,而是读懂人心的开始。