首页
/ ML-For-Beginners:用 NLTK 与 VADER 对 51 万条酒店评论做过滤、标签特征化与情感分析的完整实战

ML-For-Beginners:用 NLTK 与 VADER 对 51 万条酒店评论做过滤、标签特征化与情感分析的完整实战

2026-09-04 19:02:40作者:袁立春Spencer

本篇基于 ML-For-Beginners 课程 NLP 模块的第 5 课,讲清一条完整的文本数据处理管线:如何把 51 万条、17 列的欧洲酒店评论原始数据集,经过列清洗、地址归一、Tags 标签特征工程和 NLTK 停用词优化,最终用 VADER 规则情感分析器产出带正负情感分列的 Hotel_Reviews_NLP.csv。读完本篇,你可以复现“原始 CSV → 过滤数据集 → NLP 增强数据集”的三步流水线,并理解每一步的 pandas 技巧(groupby.transformmeltstr.split)与性能考量。

课程定位与数据管线总览

这一课属于 NLP 模块 的第 5 节“NLTK for Sentiment Analysis”,承接第 4 节 酒店评论数据准备与探索 中对数据集的逐列分析。整个挑战的假设场景是:你要构建一个酒店推荐机器人,利用情感分析和评分来给出建议。原始数据集是约 230 MB(解压后)的 515K Hotel Reviews Data in Europe(Kaggle 上 CC0 公共领域授权的 Booking.com 爬取数据,由 Jiashen Liu 发布),包含 6 个欧洲城市、上千家酒店的 51 万多条评论。按 数据目录说明,需要把该数据集下载后放入课程的 /data 文件夹。

整条管线在 README 结尾被明确归纳为四步,后处理时建议按此顺序执行:

  1. 原始数据集 Hotel_Reviews.csv 在上一课中被 探索笔记本 探查;
  2. 过滤笔记本Hotel_Reviews.csv 做列过滤与重算,产出 Hotel_Reviews_Filtered.csv
  3. 情感分析笔记本 处理 Hotel_Reviews_Filtered.csv,产出 Hotel_Reviews_NLP.csv
  4. Hotel_Reviews_NLP.csv 用于本课结尾的 NLP Challenge。

其中第 2 步内部还夹了一个“标签探索”环节,对应仓库中的 标签处理笔记本(README 正文中只给了思路,具体的正则过滤代码在这个 notebook 里,下文会展开)。

运行环境要求:能运行 Python 3 的 Jupyter notebook,以及 pandas 与 NLTK(NLTK 需本地安装,并在代码中执行 nltk.download('vader_lexicon') 下载情感词典)。

第一步:基础列清洗与地址归一

README 开篇指出数据集存在明显问题:一些列充满无用信息,另一些列的数值(如 Average_ScoreTotal_Number_of_Reviews)无法通过自己的计算独立验证。因此本步的目标是“多清理一点数据:加入后面有用的列、改写其他列的值、彻底删掉某些列”。

1. 删掉经纬度,把地址压缩成城市级

第一组操作是丢弃 latlng(做地图可视化不是本任务目标),然后对 Hotel_Address 做归一化——如果地址里包含城市和国名,就统一替换成“城市, 国家”的短格式。数据集中只有以下 6 组城市/国家:

  • Amsterdam, Netherlands
  • Barcelona, Spain
  • London, United Kingdom
  • Milan, Italy
  • Paris, France
  • Vienna, Austria

README 给出的实现如下(过滤笔记本 中的版本多了一个 else 分支:六国都匹配不上时返回原始地址,避免产生空值):

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())

注意 README 特意提醒: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

2. 用 groupby.transform 重算不可验证的元数据列

对酒店元数据列,README 的处理是:

  1. 丢弃 Additional_Number_of_Scoring(该列仅表示“只打分没写文字”的评论数,无分析价值);
  2. 用“该酒店在数据集中实际出现的评论数”替换 Total_Number_of_Reviews
  3. 用自算的 Reviewer_Score 均值替换 Average_Score
# 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)

这里的两个 transform 是关键:与 agg 会把每行折叠成一行不同,transform('count') / transform('mean') 会把分组统计结果按原索引对齐地广播回每一行,因此替换后 DataFrame 仍是 51.5 万行、每行带着自己酒店的全量统计值,可以直接参与后续逐行操作。这正是“让每家酒店的每一条评论都能引用该酒店聚合值”的 pandas 惯用手法。

3. 评论列与评论者列的去留

  • 丢弃 Review_Total_Negative_Word_CountsReview_Total_Positive_Word_CountsReview_Datedays_since_review——上一课已经论证过单纯词数不等于情感、日期新鲜度也不在本次分析范围内;
  • 原样保留 Reviewer_ScoreNegative_ReviewPositive_Review
  • Tags 暂时保留——它还要在下个小节做过滤后才能丢弃;
  • 丢弃 Total_Number_of_Reviews_Reviewer_Has_Given(数据里没有评论者唯一 ID,无法把多条评论关联到同一个人,该列无法支撑建模);
  • 保留 Reviewer_Nationality

第二步:把列表文本形式的 Tags 变成可用的 0/1 特征

为什么 Tags 列“问题很大”但值得处理

Tags 列以“文本形式存的 Python 列表”存储,例如 [' Business trip ', ' Solo traveler ', ' Single Room ', ' Stayed 5 nights ', ' Submitted from a mobile device ']。它的难点在于:

  • 子项个数和顺序不固定(有的行 5 个、有的 3 个、有的 6 个),导致按位置统计会出错,某些酒店可能拿不到本应属于它的标签;
  • 数据量大到难以人工识别值得关注的短语:515,000 行、1,427 家酒店,每家可选的标签组合还略有差异——这正是 NLP 的用武之地:扫描文本、统计最常见短语。
  • 我们关心的不是单词而是多词短语(如 Business trip)。对全部约 6,762,646 个词跑多词频率分布算法耗时巨大,但探索式分析(EDA)帮了忙:既然样本里能看到标签本身就是逗号分隔的现成短语,就可以大幅减少处理量。

先按业务目标决定“哪些标签值得留”

README 强调:标签的去留取决于数据集的最终用途。这里的用途是“给推荐机器人补充有助于选酒店的特征”,据此给出五条判断:

  1. 旅行类型(type of trip)相关 → 保留;
  2. 客人群体类型(type of guest group)重要 → 保留;
  3. 房间/套房/公寓类型无关(各酒店的房间本质上大同小异)→ 剔除;
  4. 提交评论所用的设备无关 → 剔除;
  5. 入住晚数 也许 与“住得久就更喜欢”相关,但牵强,大概率无关 → 剔除。

一句话总结:保留 2 类标签(旅行类型、客人群体),其余全部移出统计范围。

清洗:先去掉括号和引号

计数之前要先把标签变成更规整的格式——去掉方括号和引号。README 明确要求“最快的方式”,因为 51 万行数据的逐行处理不能慢,而 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 个临时列 + melt 全局计数

由于同一短语在不同行里所处的位置不同,直接按列计数会失真。README 的解法是把这个乱序变废为宝:利用“每个标签都是多词短语、且彼此用逗号分隔”这一事实,先按逗号把每个单元格拆成最多 6 个临时列 Tag_1Tag_6,再把 6 列合并成 1 个大列跑 value_counts(),即可得到不分位置的全局短语频次。结果显示共有 2,428 个唯一标签,高频样本如下:

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

README 提示:像 Submitted from a mobile device 这种高频标签虽然无用,但“剔除”这一步本身非常快,可以留着不删、分析时忽略即可。

从源码看,标签处理笔记本 里这一步的具体实现是:先 df.Tags.str.split(',', expand=True) 拆列、逐列 str.strip() 去首尾空格,再用 df.melt(value_vars=["Tag_1", ..., "Tag_6"]) 把宽表摊平成长表(输出形状为 (2514684, 2),即约 251 万条标签实例),然后一条正则把房间类、入住时长类、设备类标签整体过滤掉,并只保留出现次数大于 1000 的标签:

df_tags = df_tags[~df_tags.value.str.contains("Standard|room|Stayed|device|Beds|Suite|Studio|King|Superior|Double", na=False, case=False)]
tag_vc = df_tags.value.value_counts().reset_index(name="count").query("count > 1000")

过滤后的最终频次(README 未展示、notebook 输出中的真实结果)是:

index count
Leisure trip 338423
Couple 205305
Solo traveler 89779
Business trip 68176
Group 51593
Family with young children 49318
Family with older children 21509
Travelers with friends 1610
With a pet 1078

剔除“入住晚数”与“房型”两类标签

README 分别用两张表展示了被移出统计范围的标签:

入住晚数(Length of stay)——注意:是“不再把它们作为要计数/保留的取值来考虑”,而不是从数据集里删掉:

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)——种类极多但语义几乎等价,对选酒店没帮助:

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 个有用标签

经过上面几步(README 原文:这一步“令人愉快”,因为几乎没花多少处理量),最终留下:

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 语义基本一致,合并处理是合理选择。

为每个标签生成 0/1 列

最后一步是“标签 → 列”:为每个保留的标签新建一列,逐行检查 Tags 是否包含该短语,包含记 1、否则记 0。这样每家酒店在聚合层面就得到了“多少位评论者把这家酒店当作商务/休闲/带宠物等场景选择”的计数,对推荐非常有价值:

# 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)

这里用 in tag 子串匹配而非精确相等,正是为了规避标签顺序、空格不规范的匹配陷阱——只要单元格里出现过短语就记 1。

第三步:保存过滤后的数据集

保存前把已经确认无用的列一并删掉,并以新文件名落盘(Hotel_Reviews_Filtered.csv)。README 中的保存代码:

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)

从源码看,过滤笔记本 的对应单元格额外把 Review_Date 也放进了丢弃列表,并对整段流程计时——其运行记录显示“Filtering took 23.74 seconds”,即整轮列过滤与重算在参考设备上约 24 秒完成。

第四步:加载过滤数据并做停用词优化

下一节要读取的是上面保存的过滤后数据集,而不是原始数据集(README 用加粗特别强调了这一点)。环境准备代码:

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)

为什么要先移除停用词

直接在 Negative_ReviewPositive_Review 两列上跑情感分析,在参考设备(高性能笔记本、快 CPU)上需要 12~14 分钟,取决于所用情感库——这段时间值得优化。停用词(the、and、of 等不改变句子情感色彩的常用词)是安全的提速点:删掉它们情感分析应该更快,但准确性不降(停用词不影响情感判定,只拖慢速度)。README 给出的量化依据:

  • 最长的一条负面评论 395 词,去停用词后为 195 词(篇幅直接减半以上);
  • 对 515,000 行、2 个评论列做去停用词,参考设备耗时 3.3 秒情感分析笔记本 的运行记录为 5.77 秒,实际会因 CPU、内存、SSD 等硬件因素浮动)。

去停用词本身是轻量操作,若它能换回情感分析的大幅提速,就是划算的交易。实现上参考了社区里“不同去停用词方案性能对比”的做法——把停用词表预编译成 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!
# Ryan Han (ryanxjhan on Kaggle) has a great post measuring performance of different stop words removal approaches
# using the approach that Ryan recommends
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 计算正负情感列

分析器与三种输入情况

README 说明情感结果的检验方法是与评论者的数值评分对照:如果情感分析认为一条负面评论的情感分是 1(极度正面)、正面评论也是 1,而评论者却给了最低分,那么要么评论文本与评分不匹配,要么分析器没认对情感。README 同时提醒:应预期一部分情感分会完全错,且往往是可解释的,例如极端反讽——“Of course I LOVED sleeping in a room with no heating”(“当然,我太喜欢睡在没暖气的房间里了”)会被判成正面,而人类一读就懂这是反话。

NLTK 提供了多种情感分析器可以替换对比,本课选用 VADER(Hutto, C.J. & Gilbert, E.E. (2014). VADER: A Parsimonious Rule-based Model for Sentiment Analysis of Social Media Text. ICWSM-14)。构造分析器与核心回调函数:

from nltk.sentiment.vader import SentimentIntensityAnalyzer

# Create the vader sentiment analyser (there are others in NLTK you can try too)
vader_sentiment = SentimentIntensityAnalyzer()
# 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.

# 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"]

要点有两个:其一,数据集中没写内容时该列会被填上字面占位值 No Negative / No Positive,必须先拦截返回 0,否则这些占位字符串会被当成真实文本送进情感分析器;其二,取的是 polarity_scores() 返回字典中的 compound 复合分(约 -1 到 +1 的归一化得分),而非 pos/neu/neg 的原始分。

批量应用、计时与人工抽查

# 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")

README 给出的耗时约为 120 秒(随机器不同而变化);情感分析笔记本 的实际运行记录是 201.07 秒。计算完成后,可以把两列分别按分数排序、打印首尾行来做人工抽查,验证“分数最低的行读起来确实最负、最高的行确实最正”:

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"]])

该 notebook 的实际输出印证了这一点:负面情感最低的分值为 -0.9920(“So bad experience memories I hotel The first…”),而排序两端也出现了典型的“名不副实”案例——例如一条写着 “Wifi terribly slow I speed test network upload…” 的文本被排到了高情感分一侧,直观展示了反讽/否定语境下规则式分析器的误差形态,这正是“情感分会错、但往往可解释”的实例。

收尾:重排列并保存最终数据集

README 指出,正式交付给 Challenge 之前还有两件事:重排列(纯外观性的整理,方便人后续探索)与再次保存。重排后的 18 列顺序为:

# 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)

从列顺序可以看出最终数据集的“信息分层”:先是酒店身份与聚合统计(名称、地址、评论数、自算均分),再是个体评分与两条 VADER 情感分,然后是国籍与 8 个 0/1 标签特征,最后才放两列原始(已去停用词)文本。README 建议的完整运行顺序是:先跑完 过滤笔记本 生成 Hotel_Reviews_Filtered.csv,再运行整个 情感分析笔记本 生成 Hotel_Reviews_NLP.csv

小结与延伸练习

回顾这一课:起点是一个“有列、有数据,但并非全部可验证、可用”的数据集;经过探索、过滤无用列、把 Tags 转成有用的 0/1 特征列、自算酒店级均值、新增情感列之后,最终得到一份能直接支撑推荐建模的 NLP 增强数据集。README 的结论段也概括了本课能力增量:过滤不可信数据、把标签转成可用特征、自算均值、添加情感列,并顺带学会了不少处理自然语言文本的技巧。

Challenge

README 布置的挑战题:数据集现在已经完成了情感分析,试着用本课程学过的策略(比如聚类 clustering)来发现情感分周围存在的模式。可操作的思路是把 Negative_SentimentPositive_SentimentReviewer_Score 与 8 个标签列组合成特征矩阵做无监督分群,再检查各簇对应的城市、国籍与标签组合分布——仓库中 5-Clustering 模块 提供了 K-Means 与可视化的完整教材。

Assignment

课后作业 要求:在学习了用 NLTK 给文本指派情感之后,换一个数据集重复这一流程。由于大概率要做一些数据预处理,需要自建 notebook 并记录你的思考过程。其评分标准(Rubric)如下:

维度 优秀(Exemplary) 合格(Adequate) 待改进(Needs Improvement)
交付物 提交完整的 notebook 与数据集,附带解释清楚的单元,说明情感如何被指派 notebook 缺少充分的解释 notebook 存在缺陷

本篇涉及的仓库文件

文件 作用
6-NLP/5-Hotel-Reviews-2/README.md 本课主体:过滤、标签处理、情感分析全流程
6-NLP/5-Hotel-Reviews-2/solution/1-notebook.ipynb 过滤笔记本:产出 Hotel_Reviews_Filtered.csv
6-NLP/5-Hotel-Reviews-2/solution/2-notebook.ipynb 标签探索笔记本:melt + 正则过滤得到 9 个有效标签
6-NLP/5-Hotel-Reviews-2/solution/3-notebook.ipynb 情感分析笔记本:去停用词 + VADER,产出 Hotel_Reviews_NLP.csv
6-NLP/4-Hotel-Reviews-1/README.md 上一课:数据集列定义与探索式分析
6-NLP/5-Hotel-Reviews-2/assignment.md 课后作业:换数据集复现情感分析流程
6-NLP/data/README.md 数据目录说明:需将数据集下载到该文件夹

适用前提与限制说明:文中所有耗时数据(23.74 秒过滤、3.3/5.77 秒去停用词、约 120~201 秒情感分析)均为课程作者参考设备上的实测记录,随硬件与所用情感库不同会明显浮动;数据集中 Hotel_Reviews.csv 等文件需自行下载放入 6-NLP/data/,仓库本身不包含该 230 MB 原始数据。

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