ML-For-Beginners 实战课:用 NLTK VADER 对酒店评论做数据过滤与情感分析(Hotel Reviews NLP 全流程)
导读
本课承接《ML-For-Beginners》6-NLP 单元的「欧洲浪漫酒店」专题。在前一课完成数据集探索后,你将基于约 51.5 万行、1427 家欧洲酒店的真实评论数据,完成两大工程任务:一是对数据进行"去伪存真"式的二次清洗——剔除无用/无法验证的列、重算平均值、把杂乱的 Tags 文本收缩为 8 个可量化的业务标签;二是用 NLTK 的 VADER 情感分析器为每条评论生成 Negative_Sentiment / Positive_Sentiment 两个情感分,并把结果存成可交给下游聚类与推荐任务使用的 Hotel_Reviews_NLP.csv。读完本文,你将掌握一套"探索→过滤→标签工程→去停用词加速→情感打分→归档"的可复用 NLP 数据处理流水线。
前言:为什么在探索之后还需要"再加工一次"
上一课(4-Hotel-Reviews-1,其探索 notebook 见 solution/notebook.ipynb)让我们对原始 Hotel_Reviews.csv 有了整体认识,但该数据集存在几类硬伤(这也是真实数据的常态):
- 有些列装满了对推荐无用的信息(如经纬度、设备来源);
- 有些列看似正确,却无法用我们自己的计算独立验证(如
Total_Number_of_Reviews、Average_Score是否为全量统计、如何算出都不透明); - 有些列本身就是脏数据(如以字符串形式存储、顺序和数量都不固定的
Tags列表)。
本课(6-NLP/5-Hotel-Reviews-2)的目标很明确:把数据集加工成"能帮你(或你受托开发酒店推荐机器人的客户)挑选最佳酒店"的形态——添加情感分列,并补充诸如"出差还是休闲、是否带宠物、同行人群"这类在推荐场景中有区分度的标签特征。
实践约定:本课假设数据文件已按 6-NLP/data/README.md 的说明下载到
6-NLP/data/目录;课程自带的 notebook.ipynb 是供学习者自行填充的空起始文件,完整的参考实现见 1-notebook.ipynb(过滤)与 3-notebook.ipynb(情感分析),该目录还提供了 Julia、R 的实现说明。
一、数据过滤实战:四组列的取舍与重算
参考实现位于 1-notebook.ipynb,其中前三步和本课 README 一一对应。整体分四组列处理。
1. 初始列处理:丢弃坐标、压缩地址为"城市, 国家"
数据集只有以下 6 个城市/国家组合,原始 Hotel_Address 却带着完整街道地址,既冗余又无法做国家/城市级聚合,因此 lat、lng 直接丢弃,地址用规则映射收缩成标准形式:
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())
注意参考 notebook 里的 replace_address 多写了一个 else: return row.Hotel_Address 兜底分支,避免意外命中时把地址清成 NaN——这是对 README 版本的一个稳健性补强。地址标准化后即可做国家/城市级聚合,例如统计各城市酒店数量:
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 家酒店(各教程版本口径略有差异),也印证了 value_counts() 之和应等于评论总数这一自检手段的价值。
2. 酒店元评论列:用"自己算的"替换"不可验证的"
Additional_Number_of_Scoring(来自其他渠道的附加评分)不可靠,删除;Total_Number_of_Reviews 改为数据集中该酒店实际存在的评论条数;Average_Score 改为我们自己对 Reviewer_Score 求均值(保留 1 位小数):
# 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)
这里的核心思想是可复现性:任何下游读者都能用同一份 CSV 重新算出相同的均值与条数,消除了对原始数据集作者计算口径的盲信。
3. 评论内容列:删字数列、保正文列
- 删除
Review_Total_Negative_Word_Counts、Review_Total_Positive_Word_Counts(正/负向字数,可在清洗后自算)、Review_Date与days_since_review(情感分析暂不关心时间); - 保留
Reviewer_Score(评分,将作为情感打分的"真值对照")、Negative_Review、Positive_Review(两句评论正文原样保留); - 暂时保留
Tags——下一节还会对它做专门的过滤操作,处理完再整体丢弃。
4. 评论者列:删除冗余、保留国籍
Total_Number_of_Reviews_Reviewer_Has_Given 对"酒店推荐"目标价值有限,删除;Reviewer_Nationality 保留,因为评论者国籍是后续分析(如跨文化差评模式)的潜在维度。
二、Tags 列的 NLP 难题:藏在"列表字符串"里的多词短语
Tags 列本质上是以文本形式存储的列表,例如一行可能长这样:
[' Business trip ', ' Solo traveler ', ' Single Room ', ' Stayed 5 nights ', ' Submitted from a mobile device ']
为什么说它"有问题"?
- 子标签的顺序不固定、数量不固定:有的行 3 个标签,有的 5 个、6 个;
- 规模让人力难以胜任:约 51.5 万行 × 1427 家酒店,每家可选标签组合各不相同,人眼无法逐一甄别;
- 我们关心的不是单词而是多词短语(如 Business trip)。若在全部约 676 万词上直接跑一个多词频次统计算法,将耗时巨大——但好在我们可以先做探索性数据分析(EDA):抽看若干样本后,用"先看后筛"的策略大幅削减后续计算量。
这正是 NLP 的价值场景:机器扫描文本、统计高频短语,人类只需确认"哪些短语有业务含义"。
标签过滤:先清理格式,再处理乱序
标签计数前必须先把它整理成规范格式——去掉方括号和引号。数据量大,所以要求操作足够快,而 pandas 的向量化字符串方法恰好胜任:
# 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 device
接着面对乱序难题。既然每个标签是多词短语、且用逗号分隔,那么可以"利用乱序":创建 6 个临时列,按每个标签在原串中的位置顺序放入,再把 6 列拼接成一列后跑 value_counts()。参考实现 1-notebook 采用了 ast.literal_eval 加列表展开的思路处理该列。统计结果发现一共有 2428 个唯一标签,频次分布的大致样貌如下(节选):
| 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 |
像 Submitted from a mobile device 这类高频标签对我们的推荐目标无用,但因该统计本身极快,可以暂不剔除、在后续语义筛选中自然忽略。
依据业务目标裁定标签去留
回到本课数据集的核心使命——帮助选酒店(为你自己或你的"酒店推荐机器人"客户)。据此可对各标签类别做如下业务裁定:
- 出行类型(如 Leisure/Business trip)相关——保留;
- 客群类型(如 Couple、Group、带小孩家庭)重要——保留;
- 房型/套房/公寓(Double Room、Standard Double Room……)各家酒店大同小异,与推荐不相关——剔除;
- 提交设备(mobile device 等)——无关——剔除;
- 入住夜数(Stayed N nights):除非你要论证"住得久=更喜欢",否则大概率无关——剔除。
一句话总结:只保留 2 类标签(出行类型 + 客群类型),其余剔除。注意此裁定取决于任务目标,若你出于其他原因使用该数据集,保留/剔除的集合可以完全不同。
剔除"入住时长"标签
先去掉 Stayed N nights 一类,显著缩小待考量集合。这里说的"剔除"仅指不再把它们当作要计数/保留的取值,而非从数据集中物理删除该字符串(去不去都行)。
| 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 与 Group 语义几乎等价,因此合并统计(上表括号即表明二者已合并)。识别这 8 个标签的完整代码位于 Tags/过滤 notebook。
三、把标签转成 0/1 特征列
每个有用标签各建一个新列:遍历每行,若原始 Tags 串命中该标签则为 1,否则为 0(Group 列对 "Group" 或 "Travelers with friends" 任一命中即置 1):
# 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)
这样每行就变成一个"聚合画像":这家酒店被多少评论者出于商务 vs 休闲、结伴类型、甚至是否带宠物等目的选择——这正是酒店推荐时非常有区分度的信号。
四、清理收尾并保存 Hotel_Reviews_Filtered.csv
删除所有不再需要的列后,把当前中间产物落盘,文件名清楚标识这是"过滤后"版本:
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)
路径说明:课程 README 中的相对路径以
5-Hotel-Reviews-2目录为基准;在本仓库布局下,产物实际落在6-NLP/data/目录(见 数据目录说明),参考实现 1-notebook.ipynb 中即为df.to_csv('../../data/Hotel_Reviews_Filtered.csv', index=False)。
五、情感分析阶段:先加载过滤后的数据
从本段开始进入"Sentiment Analysis Operations",使用的输入是上一步保存的 Hotel_Reviews_Filtered.csv,而不是原始数据集。脚本开头先准备好运行环境依赖(NLTK 及其 VADER 词表):
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')
# You code will be added here
# Finally remember to save the hotel reviews with new NLP data added
print("Saving results to Hotel_Reviews_NLP.csv")
df.to_csv(r'../data/Hotel_Reviews_NLP.csv', index=False)
六、性能优化第一刀:移除停用词
若直接在约 51.5 万行、两列评论正文上裸跑情感分析,即使测试机 CPU 很强,实测也需要 12~14 分钟(视所选情感库而异)。这个耗时值得优化。第一步就是移除停用词——即不影响句子情感倾向的常见英文词。去掉它们:
- 情感分析更快;
- 准确率不会下降(停用词不承载情感,只会拖慢分析)。
一个直观的收益例子:数据集中最长的负向评论原文 395 词,移除停用词后仅剩 195 词,正文规模直接砍半。停用词移除本身也是廉价操作:在测试设备上对 51.5 万行的两列评论做全量去停用词约耗时 3.3 秒(实际时间随 CPU 速度、内存、是否 SSD 等浮动)。若这 3 秒能换来情感分析从十几分钟显著下降,显然值得。
去停用词实现要点:先把停用词装入 set(O(1) 查表),再逐词过滤,避免每次都线性扫描列表:
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)
注:课程 README 援引了 Kaggle 上 Ryan Han 对不同去停用词方案的性能实测结论作为该方法选型的依据(该链接属于外部资料,此处仅转述其思路——先缓存停用词集合再过滤)。
七、核心:用 NLTK VADER 计算评论情感
7.1 什么是 VADER
NLTK 内置多种可供学习与替换的情感分析器,本课选用 VADER(Valence Aware Dictionary and sEntiment Reasoner),其原论文为:
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.
VADER 是一种基于规则与情感词典的模型,专为社交媒体/短文本设计,对情感强度、程度副词与表情符号都有处理,polarity_scores() 返回的 compound 分值落在 -1(极负)到 +1(极正) 区间,适合作为单条评论的情感标量。
7.2 处理三种输入分支的 calc_sentiment
本数据集的评论列存在占位符约定:无负向评论时值为 "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"]
7.3 逐行应用并写入新列
对两列正文分别调用 apply(calc_sentiment),产出 Negative_Sentiment 与 Positive_Sentiment 两列,并用 time 记录耗时便于调优对比:
# 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 秒(去停用词之后),但具体数值因设备而异。需要说明的是:之所以先去停用词、保留完整打分循环,正因为它把"十几分钟"级操作降到了"两分钟"级——这就是第六节优化策略的最终收益。
7.4 用评分检验情感合理性
检验情感分是否靠谱的最直接方式:与同一评论的 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"]])
7.5 重排列并保存 Hotel_Reviews_NLP.csv
进入下游挑战前最后一步:把新列重排到便于人眼浏览的顺序(纯美化操作),然后落盘:
# 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)
参考实现为 3-notebook.ipynb(在已由 1-notebook.ipynb 生成 Hotel_Reviews_Filtered.csv 的前提下运行;两者之间还存在一个侧重中间处理的 2-notebook.ipynb)。
八、整条流水线回顾:四个文件四步走
- 探索:前一课 4-Hotel-Reviews-1 用探索 notebook 摸清原始
Hotel_Reviews.csv; - 过滤:本课用 过滤 notebook 把
Hotel_Reviews.csv加工为Hotel_Reviews_Filtered.csv(含 8 个 0/1 标签列与重算的均值); - 情感分析:用 情感分析 notebook 处理
Hotel_Reviews_Filtered.csv,产出带情感列的Hotel_Reviews_NLP.csv; - 下游挑战:把
Hotel_Reviews_NLP.csv交给 NLP Challenge——既然每条评论与酒店都已具备情感分与客群标签,可以尝试用本课程已学的聚类策略在情感维度上寻找模式(详见 5-Hotel-Reviews-2 课程首页)。
结语
回头看:最初的数据集"有列有数",但不少内容既无法独立验证也无法直接使用。经过本课两次实操,你完成了数据探索、按需过滤、把 2428 个杂乱的原始标签收敛为 8 个可解释的 0/1 业务特征、用自有口径重算评论数与平均分、通过去停用词把情感分析从 12~14 分钟优化到约 2 分钟、并最终用 VADER 为每句正/负评论打上 compound 情感分——这正是一次围绕"如何处理自然语言文本数据"的完整训练。
如果想继续巩固,可以按本课作业的要求换一个数据集重跑一遍:先做必要的清洗与探索(建议新建 notebook 并记录思考过程),再用 NLTK 赋予文本情感,最后汇报你的发现。情感分析结果的质量评估标准可参考作业评分表——是否给出完整且带充分注释的 notebook 是关键。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00