基于 NLTK 的酒店评论情感分析实战:从数据过滤、标签清洗到 VADER 情感打分(ML-For-Beginners 6-NLP 第 5 课)
【免费下载链接】ML-For-Beginners12 weeks, 26 lessons, 52 quizzes, classic Machine Learning for all项目地址: https://gitcode.com/GitHub_Trending/ml/ML-For-Beginners
导读
本文是 ML-For-Beginners 课程 6-NLP 第 5 课(Hotel-Reviews-2)的完整技术指南,讲解如何对欧洲 515K 条酒店评论数据集执行"过滤列 → 清洗 Tags → 停用词优化 → VADER 情感打分"的完整 NLP 流水线。读完本文,你将掌握用 pandas 对大规模文本数据做列级清洗与自洽校验、把"文本形式的标签列表"转化为可统计的 0/1 特征列,以及用 NLTK VADER 为 50 余万条正负面评论文本生成复合情感分值并落地为可直接用于下游推荐/聚类任务的 CSV 数据集。
本课紧接上一课 6-NLP/4-Hotel-Reviews-1/README.md(该课完成了对原始数据集的探索性分析),本课则把探索结论落地为可执行的过滤与情感分析代码。数据本身约 230MB(解压后),下载后放在 6-NLP/data/README.md 所指示的6-NLP/data目录下,即可按本文步骤复现。
一、课程定位:一份"无法完全信任"的数据集
在开始前先明确目标场景:本课假设你在为一个"酒店推荐机器人"准备数据,需要基于游客评论的情感和标签来帮助挑选最佳酒店。原始数据集(来自 Booking.com 公开爬取,作者 Jiashen Liu,CC0 公共领域许可)规模为515,000 行、1493 家酒店、6 座欧洲城市,其 16 个列可以分为四组:
- 酒店列:
Hotel_Name、Hotel_Address、lat、lng; - 酒店元评论列:
Average_Score、Total_Number_of_Reviews、Additional_Number_of_Scoring; - 评论列:
Reviewer_Score、Negative_Review、Positive_Review、Review_Total_Negative_Word_Counts、Review_Total_Positive_Word_Counts、Review_Date、days_since_review、Tags; - 评论者列:
Total_Number_of_Reviews_Reviewer_Has_Given、Reviewer_Nationality。
然而正如本课指出的,这份数据"有些列塞满了无用信息,有些列看起来不正确;即使正确,也不清楚它们是如何计算出来的,无法用你自己的计算独立验证"。因此整条流水线的第一要务就是:删掉无法验证的列,用自己的计算重建可信的统计列。本课对应的完整代码位于 6-NLP/5-Hotel-Reviews-2/notebook.ipynb 与 solution 目录下的三个官方 notebook 中。
二、过滤操作一:初始列处理——去掉坐标、标准化酒店地址
2.1 删除经纬度列
lat/lng虽然理论上可以用于在地图上按正负情感着色酒店,但本任务中并不需要地理可视化,直接删除:
# dropping columns we will not use: df.drop(["lat", "lng"], axis = 1, inplace=True)2.2 把完整地址压缩为"城市 + 国家"
Hotel_Address是完整街道地址,对排序和按国家聚合没有帮助。本数据集只覆盖 6 座城市,因此可以写一个基于关键字匹配的映射函数,把地址规约为统一格式:
- Ámsterdam(阿姆斯特丹),Países Bajos(荷兰)
- Barcelona(巴塞罗那),España(西班牙)
- Londres(伦敦),Reino Unido(英国)
- Milán(米兰),Italia(意大利)
- París(巴黎),Francia(法国)
- Viena(维也纳),Austria(奥地利)
def replace_address(row): if "Netherlands" in row["Hotel_Address"]: return "Amsterdam, Netherlands" elif "Barcelona" in row["Hotel_Address"]: return "Barcelona, Spain" elif "United Kingdom" in row["Hotel_Address"]: return "London, United Kingdom" elif "Milan" in row["Hotel_Address"]: return "Milan, Italy" elif "France" in row["Hotel_Address"]: return "Paris, France" elif "Vienna" in row["Hotel_Address"]: return "Vienna, Austria" # Replace all the addresses with a shortened, more useful form df["Hotel_Address"] = df.apply(replace_address, axis = 1) # The sum of the value_counts() should add up to the total number of reviews print(df["Hotel_Address"].value_counts())实现细节(见 solution/1-notebook.ipynb 第 2 个 cell):官方 notebook 中的
replace_address还带有一个else: return row.Hotel_Address分支,兜底保留未匹配到的地址,避免引入None。value_counts()的总和应等于评论总数,这一行注释即是对清洗结果的校验。
地址规约后,就可以用一行代码查询"国家/城市"粒度的酒店数量:
display(df.groupby("Hotel_Address").agg({"Hotel_Name": "nunique"}))| Hotel_Address | Hotel_Name |
|---|---|
| Amsterdam, Netherlands | 105 |
| Barcelona, Spain | 211 |
| London, United Kingdom | 400 |
| Milan, Italy | 162 |
| Paris, France | 458 |
| Vienna, Austria | 158 |
可以看到 6 个城市合计 1494 家酒店(458 + 400 + 211 + 162 + 158 + 105),与上一课提到的 1493 家酒店量级一致。
三、过滤操作二:酒店元评论列——用自算值替换可疑字段
原始数据中的三个元评论列都有问题:
Additional_Number_of_Scoring:表示"给了评分但没写正/负面文字"的额外评分数量,本任务用不到;Total_Number_of_Reviews:不写代码无法确认它是否指"数据集内"的评论数;Average_Score:按数据创建者说明是"基于最近一年最新评论计算的酒店平均分",这种算法无法独立复现验证。
处理策略是:删掉Additional_Number_of_Scoring,然后用groupby基于数据集内的真实行数重建另外两列:
# Drop `Additional_Number_of_Scoring` df.drop(["Additional_Number_of_Scoring"], axis = 1, inplace=True) # Replace `Total_Number_of_Reviews` and `Average_Score` with our own calculated values df.Total_Number_of_Reviews = df.groupby('Hotel_Name').transform('count') df.Average_Score = round(df.groupby('Hotel_Name').Reviewer_Score.transform('mean'), 1)关键点:
groupby('Hotel_Name').transform('count')为每一行填上该酒店在数据集内实际出现的评论行数,与上一课发现的"数据集内评论数 ≠ 网站显示总数"的差异问题形成对照;transform('mean')用每一行自己的Reviewer_Score重新计算酒店平均分,并round(..., 1)保留 1 位小数,与原始Reviewer_Score的取值范围(最低 2.5、最高 10,最多 1 位小数)保持一致。这样得到的两列完全可复现、可验证。
四、过滤操作三/四:评论列与评论者列
对评论列的处理遵循"只保留对推荐任务有用的信息"原则:
- 删除
Review_Total_Negative_Word_Counts、Review_Total_Positive_Word_Counts、Review_Date、days_since_review(这些只是字数统计与新鲜度信息,且情感分析阶段会重新从文本计算语义,无需保留); - 保留
Reviewer_Score、Negative_Review、Positive_Review原样(它们是情感分析的核心输入); Tags暂时保留——下一节要对它做进一步过滤,之后才会删除。
对评论者列:
- 删除
Total_Number_of_Reviews_Reviewer_Has_Given。理由在上一课有详细说明:评论者没有唯一 ID,无法把"写过 N 条评论的人"与具体评论关联起来,这个字段对推荐模型帮助有限; - 保留
Reviewer_Nationality(国籍可作为特征,但需警惕把"国籍决定评分"的刻板印象硬编码进模型)。
五、Tags 列:把"文本形式的列表"变成可用特征
5.1 Tags 为什么难用
Tags列是一个以文本形式存储的列表,形如:
[' Business trip ', ' Solo traveler ', ' Single Room ', ' Stayed 5 nights ', ' Submitted from a mobile device ']它有两大问题:
- 顺序与子项数量不固定:不同酒店给评论者的可选项不同,有的行是 5 个子项、有的是 3 个、有的是 6 个;
- 规模太大:515,000 行、1427 家酒店,人工逐条识别不现实;而对 6,762,646 个词直接跑"多词短语频次分布"算法又会异常耗时。
这正是 NLP 的用武之地:扫描文本、统计最常见短语。而探索性分析进一步告诉你:可以先大幅缩小处理范围,不必对所有短语做统计。
5.2 决定"哪些标签值得保留"
回到任务目标(用情感和标签帮用户挑酒店),对标签做如下业务判断:
| 标签类别 | 是否保留 | 理由 |
|---|---|---|
| 出行类型(如商务/休闲) | ✅ 保留 | 与推荐强相关 |
| 客群类型(如情侣/独自旅行/家庭) | ✅ 保留 | 与推荐强相关 |
| 房型/套房/公寓 | ❌ 删除 | 所有酒店房型大同小异 |
| 提交设备(如移动设备) | ❌ 删除 | 与酒店体验无关 |
| 入住晚数 | ❌ 删除 | 理论上长住=更喜欢,但牵强且大概率无关 |
结论:只保留 2 类标签,其余从统计范围内剔除。
5.3 第一步:清洗括号与引号
统计前先要把标签变成干净格式——去掉方括号和引号。pandas 的str访问器可以最快完成:
# Remove opening and closing brackets df.Tags = df.Tags.str.strip("[']") # remove all quotes too df.Tags = df.Tags.str.replace(" ', '", ",", regex = False)此时每个标签变成类似:
Business trip, Solo traveler, Single Room, Stayed 5 nights, Submitted from a mobile device5.4 第二步:利用"顺序不一致"做频率统计
接下来遇到的难题是:不同行子项数量不同(3/5/6 个),且顺序各异,直接对整列做短语计数会漏标或错标。本课给出的巧妙解法是把"顺序不一致"变成统计工具——既然每个标签是多词短语且以逗号分隔,就按其在行内的位置拆成 6 个临时列,再melt合并成一列,最后对合并列跑value_counts():
# Now split the strings into a list tag_list_df = df.Tags.str.split(',', expand = True) # Remove leading and trailing spaces df["Tag_1"] = tag_list_df[0].str.strip() df["Tag_2"] = tag_list_df[1].str.strip() df["Tag_3"] = tag_list_df[2].str.strip() df["Tag_4"] = tag_list_df[3].str.strip() df["Tag_5"] = tag_list_df[4].str.strip() df["Tag_6"] = tag_list_df[5].str.strip() # Merge the 6 columns into one with melt df_tags = df.melt(value_vars=["Tag_1", "Tag_2", "Tag_3", "Tag_4", "Tag_5", "Tag_6"])以上两段拆分/合并代码出自 solution/2-notebook.ipynb,其中第 9 个 cell 用
df_tags.shape打印出未过滤标签矩阵为(2514684, 2),随后用value_counts()得到 2428 个唯一标签。该 notebook 还示范了如何用str.contains("Standard|room|Stayed|device|Beds|Suite|Studio|King|Superior|Double", case=False)一次性排除房型/晚数/设备标签,并用query("count > 1000")收拢高频标签。
打印前 22 个高频标签(部分):
| Tag | Count |
|---|---|
| Leisure trip | 417778 |
| Submitted from a mobile device | 307640 |
| Couple | 252294 |
| Stayed 1 night | 193645 |
| Stayed 2 nights | 133937 |
| Solo traveler | 108545 |
| Stayed 3 nights | 95821 |
| Business trip | 82939 |
| Group | 65392 |
| Family with young children | 61015 |
| Stayed 4 nights | 47817 |
| Double Room | 35207 |
| Standard Double Room | 32248 |
| Superior Double Room | 31393 |
| Family with older children | 26349 |
| Deluxe Double Room | 24823 |
| Double or Twin Room | 22393 |
| Stayed 5 nights | 20845 |
| Standard Double or Twin Room | 17483 |
| Classic Double Room | 16989 |
| Superior Double or Twin Room | 13570 |
| 2 rooms | 12393 |
5.5 第三步:剔除"入住晚数"与"房型"标签
注意:剔除不等于从数据集中删除行,只是把这类值从"统计/保留范围"里拿掉。
入住晚数类标签(部分):
| Length of stay | Count |
|---|---|
| Stayed 1 night | 193645 |
| Stayed 2 nights | 133937 |
| Stayed 3 nights | 95821 |
| Stayed 4 nights | 47817 |
| Stayed 5 nights | 20845 |
| Stayed 6 nights | 9776 |
| Stayed 7 nights | 7399 |
| Stayed 8 nights | 2502 |
| Stayed 9 nights | 1293 |
| ... | ... |
房型类标签(部分):
| Type of room | Count |
|---|---|
| Double Room | 35207 |
| Standard Double Room | 32248 |
| Superior Double Room | 31393 |
| Deluxe Double Room | 24823 |
| Double or Twin Room | 22393 |
| Standard Double or Twin Room | 17483 |
| Classic Double Room | 16989 |
| Superior Double or Twin Room | 13570 |
剔除后,最终保留下来的有用标签只剩 8 个:
| Tag | Count |
|---|---|
| Leisure trip | 417778 |
| Couple | 252294 |
| Solo traveler | 108545 |
| Business trip | 82939 |
| Group (combined with Travellers with friends) | 67535 |
| Family with young children | 61015 |
| Family with older children | 26349 |
| With a pet | 1405 |
其中Travellers with friends(1610 次,见 2-notebook 输出)与Group语义近似,官方代码将二者合并计数(51593 + 1610 ≈ 53198,与表格中 67535 的数量级差异来自不同清洗阶段的计数口径,均来自官方 notebook 输出)。
5.6 第四步:为每个有用标签生成 0/1 特征列
最后,为上述 8 个标签各建一列:某行评论的Tags命中该标签则填 1,否则填 0。最终得到的聚合计数("有多少评论者选择这家酒店是为了商务 vs 休闲、或者带了宠物")正是推荐系统可用的特征:
# Process the Tags into new columns # The file Hotel_Reviews_Tags.py, identifies the most important tags # Leisure trip, Couple, Solo traveler, Business trip, Group combined with Travelers with friends, # Family with young children, Family with older children, With a pet df["Leisure_trip"] = df.Tags.apply(lambda tag: 1 if "Leisure trip" in tag else 0) df["Couple"] = df.Tags.apply(lambda tag: 1 if "Couple" in tag else 0) df["Solo_traveler"] = df.Tags.apply(lambda tag: 1 if "Solo traveler" in tag else 0) df["Business_trip"] = df.Tags.apply(lambda tag: 1 if "Business trip" in tag else 0) df["Group"] = df.Tags.apply(lambda tag: 1 if "Group" in tag or "Travelers with friends" in tag else 0) df["Family_with_young_children"] = df.Tags.apply(lambda tag: 1 if "Family with young children" in tag else 0) df["Family_with_older_children"] = df.Tags.apply(lambda tag: 1 if "Family with older children" in tag else 0) df["With_a_pet"] = df.Tags.apply(lambda tag: 1 if "With a pet" in tag else 0)5.7 保存过滤结果
把剩余不需要的列一并删除,并保存为Hotel_Reviews_Filtered.csv(官方过滤 notebook 实测整个过滤阶段耗时约 23.74 秒):
df.drop(["Review_Total_Negative_Word_Counts", "Review_Total_Positive_Word_Counts", "days_since_review", "Total_Number_of_Reviews_Reviewer_Has_Given"], axis = 1, inplace=True) # Saving new data file with calculated columns print("Saving results to Hotel_Reviews_Filtered.csv") df.to_csv(r'../data/Hotel_Reviews_Filtered.csv', index = False)至此数据集完成"瘦身":坐标、原始统计列、晚数/房型/设备标签全部剔除,取而代之的是可验证的Total_Number_of_Reviews、Average_Score与 8 个标签 0/1 列。
六、情感分析阶段:加载过滤数据与停用词优化
6.1 加载过滤后的数据(而不是原始数据)
情感分析阶段必须加载上一阶段保存的Hotel_Reviews_Filtered.csv,而不是原始Hotel_Reviews.csv:
import time import pandas as pd import nltk as nltk from nltk.corpus import stopwords from nltk.sentiment.vader import SentimentIntensityAnalyzer nltk.download('vader_lexicon') # Load the filtered hotel reviews from CSV df = pd.read_csv('../../data/Hotel_Reviews_Filtered.csv')nltk.download('vader_lexicon')会下载 VADER 情感词典(官方 3-notebook 的 cell 9 输出显示首次运行时下载到本机nltk_data目录并返回True)。
6.2 为什么先去掉停用词
直接对正负评论两列跑情感分析非常慢:官方在配置较高的测试笔记本上实测耗时12~14 分钟(视情感分析库而定)。而停用词(不改变句子情感的常见英文词)去掉后分析会更快且不会损失准确率——它们不影响情感,只拖慢速度。
- 效果示例:最长的负面评论原有395 词,去停用词后只剩195 词;
- 代价示例:对 515,000+ 行、2 列评论去停用词,在测试设备上只用了3.3 秒(官方 3-notebook 实测约 5.77 秒,视 CPU/RAM/SSD 而异)。
6.3 用 set 缓存加速停用词去除
把停用词列表转成set(哈希查找 O(1)),再逐词过滤,是官方代码采用的高效写法(并引用了 Ryan Han 在 Kaggle 上的性能对比方案):
from nltk.corpus import stopwords # Load the hotel reviews from CSV df = pd.read_csv("../../data/Hotel_Reviews_Filtered.csv") # Remove stop words - can be slow for a lot of text! start = time.time() cache = set(stopwords.words("english")) def remove_stopwords(review): text = " ".join([word for word in review.split() if word not in cache]) return text # Remove the stop words from both columns df.Negative_Review = df.Negative_Review.apply(remove_stopwords) df.Positive_Review = df.Positive_Review.apply(remove_stopwords)七、VADER 情感打分:复合分值与边界情况
7.1 认识 VADER
本课选用 NLTK 自带的VADER(Valence Aware Dictionary and sEntiment Reasoner),一个基于规则的情感分析模型,专为社交媒体文本设计。NLTK 还提供其他分析器,可以替换后对比准确率。VADER 出处:
Hutto, C.J. & Gilbert, E.E. (2014). VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text. Eighth International Conference on Weblogs and Social Media (ICWSM-14). Ann Arbor, MI, June 2014.
7.2 处理"无文本"边界情况
Negative_Review/Positive_Review两列中,评论者没写字时值为"No Negative"/"No Positive"。因此打分函数要先处理这三种输入之一:
from nltk.sentiment.vader import SentimentIntensityAnalyzer # Create the vader sentiment analyser (there are others in NLTK you can try too) vader_sentiment = SentimentIntensityAnalyzer() # There are 3 possibilities of input for a review: # It could be "No Negative", in which case, return 0 # It could be "No Positive", in which case, return 0 # It could be a review, in which case calculate the sentiment def calc_sentiment(review): if review == "No Negative" or review == "No Positive": return 0 return vader_sentiment.polarity_scores(review)["compound"]polarity_scores(review)["compound"]是 VADER 输出的复合分,范围在-1(极度负面)到 1(极度正面)之间。
7.3 应用到全量数据并核对结果
对两列分别apply,生成Negative_Sentiment与Positive_Sentiment:
# Add a negative sentiment and positive sentiment column print("Calculating sentiment columns for both positive and negative reviews") start = time.time() df["Negative_Sentiment"] = df.Negative_Review.apply(calc_sentiment) df["Positive_Sentiment"] = df.Positive_Review.apply(calc_sentiment) end = time.time() print("Calculating sentiment took " + str(round(end - start, 2)) + " seconds")- 文档实测:约120 秒(机器不同会浮动;官方 3-notebook cell 16 输出为 201.07 秒)。
- 验证方法:把情感分与同一行的
Reviewer_Score对比。若一条"负面评论"的情感分为 1(极度正面)而评论者给了最低分,说明要么评论文本与评分不匹配,要么分析器判断失误。 - 预期会有"看似错误"的结果:例如讽刺性评论"Of course I LOVED sleeping in a room with no heating"(我当然"超爱"睡在没有暖气的房间),VADER 会判为正面,而人眼能识别这是讽刺——这是规则型情感模型的已知局限。
按情感分排序打印,快速肉眼核查:
df = df.sort_values(by=["Negative_Sentiment"], ascending=True) print(df[["Negative_Review", "Negative_Sentiment"]]) df = df.sort_values(by=["Positive_Sentiment"], ascending=True) print(df[["Positive_Review", "Positive_Sentiment"]])官方 3-notebook(cell 17)输出显示:Negative_Sentiment最低可达 -0.9920、最高 0.9948;Positive_Sentiment最低 -0.9820、最高 0.9987,共 515738 行 × 2 列——与 515,000+ 行总量吻合,且可以看到去停用词后的文本片段(如 "So bad experience memories...")。
八、整理列顺序并保存最终数据集
保存前把新列重排到顺手的位置(纯美观,便于人工浏览数据):
# Reorder the columns (This is cosmetic, but to make it easier to explore the data later) df = df.reindex(["Hotel_Name", "Hotel_Address", "Total_Number_of_Reviews", "Average_Score", "Reviewer_Score", "Negative_Sentiment", "Positive_Sentiment", "Reviewer_Nationality", "Leisure_trip", "Couple", "Solo_traveler", "Business_trip", "Group", "Family_with_young_children", "Family_with_older_children", "With_a_pet", "Negative_Review", "Positive_Review"], axis=1) print("Saving results to Hotel_Reviews_NLP.csv") df.to_csv(r"../data/Hotel_Reviews_NLP.csv", index = False)最终列结构清晰体现了"元数据在前、情感分居中、标签特征与原文在后"的设计:酒店名/地址 → 评论数与均分 → 评论者分数 → 正负情感分 → 国籍 → 8 个标签 0/1 列 → 正负评论文本。
九、整条流水线回顾与验证
本课的完整处理链(也即官方文档给出的四步回顾):
- 上一课用 6-NLP/4-Hotel-Reviews-1/solution/notebook.ipynb 探索原始Hotel_Reviews.csv;
- 用 6-NLP/5-Hotel-Reviews-2/solution/1-notebook.ipynb(过滤 notebook)把 Hotel_Reviews.csv 处理为Hotel_Reviews_Filtered.csv(约 24 秒);
- 用 6-NLP/5-Hotel-Reviews-2/solution/3-notebook.ipynb(情感分析 notebook,需先执行过滤 notebook)把 Filtered 处理为Hotel_Reviews_NLP.csv(去停用词约 5.8 秒 + 情感打分约 201 秒);
- 在随后的挑战中直接使用 Hotel_Reviews_NLP.csv。
如果你只想快速复现"标签筛选"这一步,可单独参考 6-NLP/5-Hotel-Reviews-2/solution/2-notebook.ipynb,它演示了从拆列、melt、value_counts 到str.contains排除与query("count > 1000")收拢高频标签的完整中间过程(输出The shape of the tags with no filtering: (2514684, 2))。
十、课后挑战:用聚类探索情感模式
得到带Negative_Sentiment/Positive_Sentiment的最终数据集后,本课挑战要求尝试用课程中学过的策略(例如聚类)找出与情感相关的模式,比如"哪些酒店/城市/客群组合的情感分分布有显著差异"。完整的作业说明与评分标准见 6-NLP/5-Hotel-Reviews-2/assignment.md:作业要求换一个不同数据集,新建 notebook 并记录数据处理与情感打分的完整思路(优秀标准为"notebook 完整、单元格注释充分、清楚解释情感如何被赋予")。
关键要点小结
- 先验证,再使用:对无法独立复现的统计列(
Average_Score、Total_Number_of_Reviews),用groupby + transform基于真实数据重建,是保证数据可信度的核心手段。 - 把结构问题转化为 NLP 问题:
Tags列顺序/数量不一致时,先strip+replace清洗格式,再按位置拆列 +melt+value_counts,即可在数秒内得到 2428 个唯一标签的全量频次。 - 业务判断先行:标签的取舍(出行类型、客群保留;房型、设备、晚数剔除)来自推荐场景的业务推理,而不是数据本身的统计显著性。
- 大规模文本分析的性价比优化:
set(stopwords.words("english"))缓存 + 逐词过滤,用 3~6 秒换掉 12~14 分钟的情感分析等待;去掉停用词不影响 VADER 的情感判断准确率。 - 情感分需要人类核查:VADER 的
compound分(-1~1)要与Reviewer_Score交叉验证,讽刺等修辞是规则型模型的已知盲区。
【免费下载链接】ML-For-Beginners12 weeks, 26 lessons, 52 quizzes, classic Machine Learning for all项目地址: https://gitcode.com/GitHub_Trending/ml/ML-For-Beginners
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考