ML-For-Beginners NLP 实战:用 pandas 探索 51.5 万条欧洲酒店评价数据(Hotel Reviews 课程)
本篇基于 ML-For-Beginners 课程仓库 NLP 章节的《酒店评价情感分析:数据处理》一课,带你完成一次真实场景的探索性数据分析(EDA):从加载 515K 条 Booking.com 酒店评价 CSV 开始,逐列理解 17 个字段的业务含义与数据陷阱(非标准 Tags、口径不明的 Average_Score),并掌握用 pandas 计算列频率、按酒店分组求均分、以及 Lambda 与向量化 sum 两种行级统计的取舍。读完并跟练后,你将能独立判断哪些列可信、哪些列应弃用,为后续的情感分析与推荐模型准备干净的数据。
课程目标与准备
这一节承接 NLP 前面的基础课,用真实数据集做探索性数据分析。一旦搞清楚各列的可用性,你将学会三件事:
- 如何删除不必要的列
- 如何基于现有列计算新数据
- 如何保存处理后的数据集,供最终挑战(final challenge)使用
数据说明:课程使用的是欧洲 515K 条酒店评价数据集(Kaggle 上的 "515K Hotel Reviews Data in Europe",作者 Jiashen Liu,CC0 公共领域许可),从 Booking.com 公开页面爬取。数据集覆盖 6 座城市中的 1493 家酒店,解压后约 230 MB。
环境准备清单:
- 能运行 Python 3 的
.ipynbnotebook pandas库NLTK库(需本地安装,后续情感分析课使用)- 数据集本身:下载后放到 NLP 课程的
/data根目录。仓库中的 6-NLP/data/README.md 即提示"Download the hotel review data to this folder",即把Hotel_Reviews.csv放入 6-NLP/data/ 目录;代码中使用的相对路径../../data/Hotel_Reviews.csv正是从 6-NLP/4-Hotel-Reviews-1/ 回退两级指向该目录。
假设的业务场景:你要基于情感分析和住客评分构建一个"酒店推荐机器人"。借助 Python、该数据集和 NLTK 的情感分析,你可以回答:
- 评价中最高频的词和短语是什么?
- 官方
Tags(如"带小孩的家庭")是否与评分相关?例如某酒店差评是否更多来自 Family with young children 而非 Solo traveller(若如此,可能说明该酒店更适合独行旅客)? - NLTK 算出的情感分是否与评价者的数字评分"一致"?
配套练习文件为 notebook.ipynb,仓库同时提供了 solution/notebook.ipynb 参考答案 notebook,可对照运行验证。
数据集列结构:17 列的分组解读
用 VS Code 或 Excel 打开 CSV 后,列头共 17 个:
Hotel_Address, Additional_Number_of_Scoring, Review_Date, Average_Score, Hotel_Name, Reviewer_Nationality, Negative_Review, Review_Total_Negative_Word_Counts, Total_Number_of_Reviews, Positive_Review, Review_Total_Positive_Word_Counts, Total_Number_of_Reviews_Reviewer_Has_Given, Reviewer_Score, Tags, days_since_review, lat, lng
按业务含义可分成四组:
酒店列
Hotel_Name、Hotel_Address、lat(纬度)、lng(经度)- 用
lat/lng可以画地图展示酒店位置(甚至按正/负面评价着色) Hotel_Address对模型帮助不大,实践中通常替换为国家字段以便排序检索
- 用
酒店"元评价"列
Average_Score:据数据集作者,是"基于过去一年最新评论计算的酒店平均分"——这是一种很特别的算法,但既然这是爬取到的原始值,暂且按字面接受- ✅ 思考题:基于其他列,你能想出另一种计算平均分的方式吗?(答案就是用
Reviewer_Score自行分组求均值,见下文)
- ✅ 思考题:基于其他列,你能想出另一种计算平均分的方式吗?(答案就是用
Total_Number_of_Reviews:该酒店获得的评论总数——在不写代码的情况下无法确认它是否指本数据集内的评论Additional_Number_of_Scoring:表示"打了分但没有写正/负面评论"的评分数量
评价列
Reviewer_Score:最多一位小数的数字,取值范围 2.5 到 10(注意下限是 2.5 而不是 0,且没有解释为什么)Negative_Review:若评价者没写内容,字段值为 "No Negative";注意评价者也可能在负面栏写好评(如"这酒店没什么不好")Review_Total_Negative_Word_Counts:负面词数量越多往往评分越低(未经情感验证的弱信号)Positive_Review:没写内容时为 "No Positive";同理,正面栏里也可能出现差评Review_Total_Positive_Word_Counts:正面词数量越多评分往往越高Review_Date与days_since_review:可施加"新鲜度/陈旧度"度量——酒店可能换了管理层、翻新、加了泳池,旧评价未必准确Tags:评价者自选的短描述(客人类型、房型、入住晚数、评价提交设备)。使用这些标签是有坑的,见下节
评价者列
Total_Number_of_Reviews_Reviewer_Has_Given:可能是推荐模型的一个因子(例如高产评价者是否更偏负面),但单条评价中的评价者没有唯一 ID,无法关联其全部评论;虽有 30 位评价者写了 100 条以上评论,却难以在推荐模型中利用Reviewer_Nationality:警惕把"某些国籍的人更爱给差评/好评"这种基于国家(甚至种族)的刻板印象写进模型——每位评价者都是基于个人体验(过往住宿、旅途距离、个人性情等多重因素)写评论的,把分数归因于国籍很难站住脚
一个典型样本
| 平均分 | 总评论数 | 评价者评分 | 负面评价 | 正面评价 | 标签 |
|---|---|---|---|---|---|
| 7.8 | 1945 | 2.5 | 这现在根本不是酒店,是工地。从清晨起我就被建筑噪音折磨……(115 词的长差评) | 没有。糟糕的地方。别来。 | 商务出行, 情侣, 标准双床房, 入住两晚 |
这条样本暴露了文本数据的本质:酒店整体平均分 7.8、评论 1945 条,但这位评价者给了 2.5 分并写了一篇 115 词的差评;正面栏只写了 7 个词的警告("Nothing / Terrible place / Stay away")。如果只数词数而不理解词义与情感,就会严重误判评价者的真实意图。 另外 2.5 分也令人困惑——入住体验那么差为何还打几分?因为该数据集的最低分就是 2.5,最高 10。
Tags 列的麻烦:没有标准化
初看用 Tags 给数据分类很合理,但它并不统一:某家酒店可能给 Single room / Twin room / Double room,下一家却是 Deluxe Single Room / Classic Queen Room / Executive King Room。它们可能是一回事,但变体太多,只能二选一:
- 试图把所有词归一到单一标准——非常难,因为很多情况下映射路径不清晰(Classic single room 能映射到 Single room,但 Superior Queen Room with Courtyard Garden or City View 就很难)
- 走 NLP 路线:统计 Solo、Business Traveller、Family with young children 等特定词组在每家酒店的出现频率,把它作为推荐模型的输入
Tags 通常(但不总是)是一个包含 5~6 个逗号分隔值的字段,分别对应 行程类型、客人类型、房型、入住晚数、提交评价的设备。由于有些评价者会留空某个字段,值的顺序并不固定。
以"客人类型"为例:Tags 列中该字段有 1025 种唯一取值,其中只有一部分指代"客人类型"(另一些是房型等)。只筛选提到 family 的值,结果里混入大量 Family room(房型);把 with 也纳入条件、只统计 Family with 开头,结果就好多了——515,000 条结果中有超过 80,000 条包含 "Family with young children" 或 "Family with older children"。结论:Tags 列并非完全无用,但要让它有用需要额外清洗工作。
Average_Score 的口径疑云
数据集存在几处文档作者也没完全解释清楚的怪异/矛盾点,建模时必须心里有数:
- 数据集中与平均分、评论数相关的列有:
Hotel_Name、Additional_Number_of_Scoring、Average_Score、Total_Number_of_Reviews、Reviewer_Score - 评论数最多的酒店是 Britannia International Hotel Canary Wharf:数据集内 4789 条评论,但它的
Total_Number_of_Reviews是 9086。也许有大量"只打分不写评"的记录,于是加上Additional_Number_of_Scoring= 2682,4789 + 2682 = 7471,仍差 1615 到 9086 - 若猜
Average_Score是数据集内评论的均值:Kaggle 描述却是"按过去一年最新评论计算"。同样以该酒店为例,给出的平均分是 7.1,而用数据集中的Reviewer_Score算出来是 6.8——接近但不相等,只能猜测是那些"只打分不写评"的分数把均值拉高到 7.1。无法验证,也就难以信任基于"我们手中没有的数据"的Average_Score、Additional_Number_of_Scoring、Total_Number_of_Reviews - 更复杂的:评论数第二多的酒店,计算均分 8.12 与
Average_Score8.1 几乎吻合——这是巧合还是第一家酒店是异常值?
基于"可能只是个别异常、大多数值能对上"的假设,正确做法就是写代码验证——这正是下面练习的核心。
⚠️ 一个提醒:使用这份数据时,你会写代码从文本中提取含义,而无需亲自逐条阅读——这正是 NLP 的精髓:由程序解释语义/情感,不依赖人工。但注意,部分负面评价可能包含冒犯性内容(公开网站爬取的通病)。建议让代码去度量情感,而不是自己逐条读并受其影响。
练习一:加载数据
# Load the hotel reviews from CSV
import pandas as pd
import time
# importing time so the start and end time can be used to calculate file loading time
print("Loading data file now, this could take a while depending on file size")
start = time.time()
# df is 'DataFrame' - make sure you downloaded the file to the data folder
df = pd.read_csv('../../data/Hotel_Reviews.csv')
end = time.time()
print("Loading took " + str(round(end - start, 2)) + " seconds")
该 CSV 超过 50 万行但只有 17 列。这份数据恰好是"干净"的——没有会干扰"只预期英文字符"的算法的其他语言字符,可以直接处理。(✅ 拓展思考:如果拿到的是含非英语字符的文本,你会先做哪些预处理?)
先别急着对 Negative_Review / Positive_Review 开 NLP——要先用下面的代码验证:数据集给定的统计值,是否与你用 pandas 算出的值一致。
练习二:DataFrame 九问(含完整参考答案)
把以下问题当编程题处理,先自己作答再对照。原则同许多编程任务:有几条路可走时,选最简单、日后回看最容易理解的那条。
任务清单:
- 打印 DataFrame 的 shape(行、列数)
- 计算评价者国籍的频率:
Reviewer_Nationality有多少个不同取值?- 最常见的国籍是什么(打印国家与评论数)?
- 接下来 10 个高频国籍及频率?
- 对上述 Top 10 国籍,各自被评最多的酒店是哪家?
- 数据集中每家酒店各有多少条评论?
- 新增一列
Calc_Average_Score,用数据集中各评价者Reviewer_Score按酒店分组求均值(保留一位小数) - 是否存在
Average_Score与Calc_Average_Score(四舍五入到一位小数后)相等的酒店?提示:写一个接收行 Series 的函数比较两值,不等时打印消息,再用.apply()逐行执行 - 统计
Negative_Review为 "No Negative" 的行数 - 统计
Positive_Review为 "No Positive" 的行数 - 同时满足 7 和 8 的行数
参考答案与结果解读
1. 形状
print("The shape of the data (rows, cols) is " + str(df.shape))
# > The shape of the data (rows, cols) is (515738, 17)
2. 国籍频率
# value_counts() creates a Series object that has index and values in this case,
# the country and the frequency they occur in reviewer nationality
nationality_freq = df["Reviewer_Nationality"].value_counts()
print("There are " + str(nationality_freq.size) + " different nationalities")
print(nationality_freq)
结果:227 个不同国籍,英国以 245,246 条居首,随后是美国(35,437)、澳大利亚(21,686)、爱尔兰(14,827)、阿联酋(10,235),长尾一直到只出现 1 次的科摩罗、帕劳等。
print("The highest frequency reviewer nationality is " + str(nationality_freq.index[0]).strip() + " with " + str(nationality_freq[0]) + " reviews.")
# Notice there is a leading space on the values, strip() removes that for printing
print("The next 10 highest frequency reviewer nationalities are:")
print(nationality_freq[1:11].to_string())
注意输出值前面带一个空格,strip() 是为此准备的。Top 10 之后依次是:United States of America 35437、Australia 21686、Ireland 14827、United Arab Emirates 10235、Saudi Arabia 8951、Netherlands 8772、Switzerland 8678、Germany 7941、Canada 7894、France 7296。
3. Top 10 国籍各自最常评的酒店
# Normally with pandas you will avoid an explicit loop, but wanted to show
# creating a new dataframe using criteria
# (don't do this with large amounts of data because it could be very slow)
for nat in nationality_freq[:10].index:
# First, extract all the rows that match the criteria into a new dataframe
nat_df = df[df["Reviewer_Nationality"] == nat]
# Now get the hotel freq
freq = nat_df["Hotel_Name"].value_counts()
print("The most reviewed hotel for " + str(nat).strip() + " was " + str(freq.index[0]) + " with " + str(freq[0]) + " reviews.")
输出摘录:英国 → Britannia International Hotel Canary Wharf(3833 条);美国 → Hotel Esther a(423 条);澳大利亚 → Park Plaza Westminster Bridge London(167 条);加拿大 → St James Court A Taj Hotel London(61 条)等。注意这里用显式循环只是为了演示"按条件构造新 DataFrame"的写法,原作者特别提示:大数据量下这种过滤循环会很慢,平时 pandas 应尽量避免。
4. 每酒店评论数
# First create a new dataframe based on the old one, removing the unneeded columns
hotel_freq_df = df.drop(["Hotel_Address", "Additional_Number_of_Scoring", "Review_Date", "Average_Score", "Reviewer_Nationality", "Negative_Review", "Review_Total_Negative_Word_Counts", "Positive_Review", "Review_Total_Positive_Word_Counts", "Total_Number_of_Reviews_Reviewer_Has_Given", "Reviewer_Score", "Tags", "days_since_review", "lat", "lng"], axis = 1)
# Group the rows by Hotel_Name, count them and put the result in a new column Total_Reviews_Found
hotel_freq_df['Total_Reviews_Found'] = hotel_freq_df.groupby('Hotel_Name').transform('count')
# Get rid of all the duplicated rows
hotel_freq_df = hotel_freq_df.drop_duplicates(subset = ["Hotel_Name"])
display(hotel_freq_df)
| Hotel_Name | Total_Number_of_Reviews | Total_Reviews_Found |
|---|---|---|
| Britannia International Hotel Canary Wharf | 9086 | 4789 |
| Park Plaza Westminster Bridge London | 12158 | 4169 |
| Copthorne Tara Hotel London Kensington | 7105 | 3578 |
| … | … | … |
| Mercure Paris Porte d Orleans | 110 | 10 |
| Hotel Wagner | 135 | 10 |
| Hotel Gallitzinberg | 173 | 8 |
可以看到"数据集内实际数出来的条数"与 Total_Number_of_Reviews 对不上:不确定该列是"酒店总评论数(但没全爬下来)"还是别的口径。正因为这种模糊,后续模型不使用 Total_Number_of_Reviews。 这里用到的关键 API 是 groupby('Hotel_Name').transform('count')——分组计数后把结果广播回每一行,再用 drop_duplicates 收敛到每酒店一行。
5. 自算平均分 Calc_Average_Score
# define a function that takes a row and performs some calculation with it
def get_difference_review_avg(row):
return row["Average_Score"] - row["Calc_Average_Score"]
# 'mean' is mathematical word for 'average'
df['Calc_Average_Score'] = round(df.groupby('Hotel_Name').Reviewer_Score.transform('mean'), 1)
# Add a new column with the difference between the two average scores
df["Average_Score_Difference"] = df.apply(get_difference_review_avg, axis = 1)
# Create a df without all the duplicates of Hotel_Name (so only 1 row per hotel)
review_scores_df = df.drop_duplicates(subset = ["Hotel_Name"])
# Sort the dataframe to find the lowest and highest average score difference
review_scores_df = review_scores_df.sort_values(by=["Average_Score_Difference"])
display(review_scores_df[["Average_Score_Difference", "Average_Score", "Calc_Average_Score", "Hotel_Name"]])
偏差最大的酒店(摘录):
| Average_Score_Difference | Average_Score | Calc_Average_Score | Hotel_Name |
|---|---|---|---|
| -0.8 | 7.7 | 8.5 | Best Western Hotel Astoria |
| -0.7 | 8.8 | 9.5 | Hotel Stendhal Place Vend me Paris MGallery |
| -0.7 | 7.5 | 8.2 | Mercure Paris Porte d Orleans |
| -0.7 | 7.9 | 8.6 | Renaissance Paris Vendome Hotel |
| -0.5 | 7.0 | 7.5 | Hotel Royal Elys es |
| … | … | … | … |
| 0.7 | 7.5 | 6.8 | Mercure Paris Op ra Faubourg Montmartre |
| 0.8 | 7.1 | 6.3 | Holiday Inn Paris Montparnasse Pasteur |
| 0.9 | 6.8 | 5.9 | Villa Eugenie |
| 0.9 | 8.6 | 7.7 | MARQUIS Faubourg St Honor Relais Ch teaux |
| 1.3 | 7.2 | 5.9 | Kube Hotel Ice Bar |
结论:只有 1 家酒店的偏差超过 1 分,因此可以接受"自算均分"替代 Average_Score。这也回答了之前的思考题——既然无法解释为何部分值吻合、部分不一致,最稳妥就是用自己手里的 Reviewer_Score 重新算。参考实现见 solution/notebook.ipynb 中对应的 get_difference_review_avg 与 transform('mean') 单元格。
6–9. 统计 "No Negative" / "No Positive"
# with lambdas:
start = time.time()
no_negative_reviews = df.apply(lambda x: True if x['Negative_Review'] == "No Negative" else False , axis=1)
print("Number of No Negative reviews: " + str(len(no_negative_reviews[no_negative_reviews == True].index)))
no_positive_reviews = df.apply(lambda x: True if x['Positive_Review'] == "No Positive" else False , axis=1)
print("Number of No Positive reviews: " + str(len(no_positive_reviews[no_positive_reviews == True].index)))
both_no_reviews = df.apply(lambda x: True if x['Negative_Review'] == "No Negative" and x['Positive_Review'] == "No Positive" else False , axis=1)
print("Number of both No Negative and No Positive reviews: " + str(len(both_no_reviews[both_no_reviews == True].index)))
end = time.time()
print("Lambdas took " + str(round(end - start, 2)) + " seconds")
# Number of No Negative reviews: 127890
# Number of No Positive reviews: 35946
# Number of both No Negative and No Positive reviews: 127
# Lambdas took 9.64 seconds
更高效的写法:向量化 sum 对比 Lambda
同一统计不用 Lambda,改用布尔序列加 sum:
# without lambdas (using a mixture of notations to show you can use both)
start = time.time()
no_negative_reviews = sum(df.Negative_Review == "No Negative")
print("Number of No Negative reviews: " + str(no_negative_reviews))
no_positive_reviews = sum(df["Positive_Review"] == "No Positive")
print("Number of No Positive reviews: " + str(no_positive_reviews))
both_no_reviews = sum((df.Negative_Review == "No Negative") & (df.Positive_Review == "No Positive"))
print("Number of both No Negative and No Positive reviews: " + str(both_no_reviews))
end = time.time()
print("Sum took " + str(round(end - start, 2)) + " seconds")
# Number of No Negative reviews: 127890
# Number of No Positive reviews: 35946
# Number of both No Negative and No Positive reviews: 127
# Sum took 0.19 seconds
两种写法结果完全一致(127890 / 35946 / 127),但向量化 sum 用了 0.19 秒,而 df.apply(lambda ...) 用了 9.64 秒——在 51 万行规模上相差约 50 倍。这正是课程反复强调的原则:DataFrame 的向量化 API 优先,apply 逐行 Lambda 仅作兜底。
另一个值得记住的发现:有 127 行同时是 "No Negative" 和 "No Positive"(占 515738 行的 0.02%)——这些评价者只打了数字分、拒绝写任何文字评论。虽然比例小到不太可能让模型偏向某侧,但"评论数据集里竟有零文本的行"这一点本身就说明:动手前探索数据,才能发现这些意料之外的形态。
挑战与延伸
本课的核心启示:在文本数据上做任何运算之前,先彻底理解数据的形态与毛病(foibles)。课程挑战建议你去翻几个文本密集型数据集,找出可能给模型引入偏见或扭曲情感的字段。
下一步(课程的下一节 Hotel-Reviews-2)将在本 EDA 基础上过滤数据、加入情感分析;配套延伸练习是深入阅读 NLTK 官方书籍并完成其习题,见 assignment.md。
适用前提与限制:本文所有代码路径(../../data/Hotel_Reviews.csv)基于仓库目录结构,要求先把约 230 MB 的 Hotel_Reviews.csv 下载至 6-NLP/data/;数据集本身托管在 Kaggle 且不在仓库内,且 Reviewer_Score 最低 2.5、Tags 非标准化等特性均源于该特定数据集,换数据集时需重新验证这些结论。
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 StartedRust0623
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