首页
/ ML-For-Beginners 实战课:用 NLTK VADER 对酒店评论做数据过滤与情感分析(Hotel Reviews NLP 全流程)

ML-For-Beginners 实战课:用 NLTK VADER 对酒店评论做数据过滤与情感分析(Hotel Reviews NLP 全流程)

2026-09-07 11:46:55作者:江焘钦

导读

本课承接《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_ReviewsAverage_Score 是否为全量统计、如何算出都不透明);
  • 有些列本身就是脏数据(如以字符串形式存储、顺序和数量都不固定的 Tags 列表)。

本课(6-NLP/5-Hotel-Reviews-2)的目标很明确:把数据集加工成"能帮你(或你受托开发酒店推荐机器人的客户)挑选最佳酒店"的形态——添加情感分列,并补充诸如"出差还是休闲、是否带宠物、同行人群"这类在推荐场景中有区分度的标签特征。

实践约定:本课假设数据文件已按 6-NLP/data/README.md 的说明下载到 6-NLP/data/ 目录;课程自带的 notebook.ipynb 是供学习者自行填充的空起始文件,完整的参考实现见 1-notebook.ipynb(过滤)与 3-notebook.ipynb(情感分析),该目录还提供了 JuliaR 的实现说明。

一、数据过滤实战:四组列的取舍与重算

参考实现位于 1-notebook.ipynb,其中前三步和本课 README 一一对应。整体分四组列处理。

1. 初始列处理:丢弃坐标、压缩地址为"城市, 国家"

数据集只有以下 6 个城市/国家组合,原始 Hotel_Address 却带着完整街道地址,既冗余又无法做国家/城市级聚合,因此 latlng 直接丢弃,地址用规则映射收缩成标准形式:

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_CountsReview_Total_Positive_Word_Counts(正/负向字数,可在清洗后自算)、Review_Datedays_since_review(情感分析暂不关心时间);
  • 保留 Reviewer_Score(评分,将作为情感打分的"真值对照")、Negative_ReviewPositive_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 ']

为什么说它"有问题"?

  1. 子标签的顺序不固定、数量不固定:有的行 3 个标签,有的 5 个、6 个;
  2. 规模让人力难以胜任:约 51.5 万行 × 1427 家酒店,每家可选标签组合各不相同,人眼无法逐一甄别;
  3. 我们关心的不是单词而是多词短语(如 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 这类高频标签对我们的推荐目标无用,但因该统计本身极快,可以暂不剔除、在后续语义筛选中自然忽略。

依据业务目标裁定标签去留

回到本课数据集的核心使命——帮助选酒店(为你自己或你的"酒店推荐机器人"客户)。据此可对各标签类别做如下业务裁定:

  1. 出行类型(如 Leisure/Business trip)相关——保留;
  2. 客群类型(如 Couple、Group、带小孩家庭)重要——保留;
  3. 房型/套房/公寓(Double Room、Standard Double Room……)各家酒店大同小异,与推荐不相关——剔除;
  4. 提交设备(mobile device 等)——无关——剔除;
  5. 入住夜数(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 friendsGroup 语义几乎等价,因此合并统计(上表括号即表明二者已合并)。识别这 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_SentimentPositive_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)。

八、整条流水线回顾:四个文件四步走

  1. 探索:前一课 4-Hotel-Reviews-1探索 notebook 摸清原始 Hotel_Reviews.csv
  2. 过滤:本课用 过滤 notebookHotel_Reviews.csv 加工为 Hotel_Reviews_Filtered.csv(含 8 个 0/1 标签列与重算的均值);
  3. 情感分析:用 情感分析 notebook 处理 Hotel_Reviews_Filtered.csv,产出带情感列的 Hotel_Reviews_NLP.csv
  4. 下游挑战:把 Hotel_Reviews_NLP.csv 交给 NLP Challenge——既然每条评论与酒店都已具备情感分与客群标签,可以尝试用本课程已学的聚类策略在情感维度上寻找模式(详见 5-Hotel-Reviews-2 课程首页)。

结语

回头看:最初的数据集"有列有数",但不少内容既无法独立验证也无法直接使用。经过本课两次实操,你完成了数据探索、按需过滤、把 2428 个杂乱的原始标签收敛为 8 个可解释的 0/1 业务特征、用自有口径重算评论数与平均分、通过去停用词把情感分析从 12~14 分钟优化到约 2 分钟、并最终用 VADER 为每句正/负评论打上 compound 情感分——这正是一次围绕"如何处理自然语言文本数据"的完整训练。

如果想继续巩固,可以按本课作业的要求换一个数据集重跑一遍:先做必要的清洗与探索(建议新建 notebook 并记录思考过程),再用 NLTK 赋予文本情感,最后汇报你的发现。情感分析结果的质量评估标准可参考作业评分表——是否给出完整且带充分注释的 notebook 是关键。

登录后查看全文
热门项目推荐
相关项目推荐