首页
/ ML-For-Beginners NLP 实战:用 pandas 探索 51.5 万条欧洲酒店评价数据(Hotel Reviews 课程)

ML-For-Beginners NLP 实战:用 pandas 探索 51.5 万条欧洲酒店评价数据(Hotel Reviews 课程)

2026-09-06 17:56:17作者:范靓好Udolf

本篇基于 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 的 .ipynb notebook
  • 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_NameHotel_Addresslat(纬度)、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_Datedays_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。它们可能是一回事,但变体太多,只能二选一:

  1. 试图把所有词归一到单一标准——非常难,因为很多情况下映射路径不清晰(Classic single room 能映射到 Single room,但 Superior Queen Room with Courtyard Garden or City View 就很难)
  2. 走 NLP 路线:统计 SoloBusiness TravellerFamily 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_NameAdditional_Number_of_ScoringAverage_ScoreTotal_Number_of_ReviewsReviewer_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_ScoreAdditional_Number_of_ScoringTotal_Number_of_Reviews
  • 更复杂的:评论数第二多的酒店,计算均分 8.12 与 Average_Score 8.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 九问(含完整参考答案)

把以下问题当编程题处理,先自己作答再对照。原则同许多编程任务:有几条路可走时,选最简单、日后回看最容易理解的那条。

任务清单

  1. 打印 DataFrame 的 shape(行、列数)
  2. 计算评价者国籍的频率:
    1. Reviewer_Nationality 有多少个不同取值?
    2. 最常见的国籍是什么(打印国家与评论数)?
    3. 接下来 10 个高频国籍及频率?
  3. 对上述 Top 10 国籍,各自被评最多的酒店是哪家?
  4. 数据集中每家酒店各有多少条评论?
  5. 新增一列 Calc_Average_Score,用数据集中各评价者 Reviewer_Score 按酒店分组求均值(保留一位小数)
  6. 是否存在 Average_ScoreCalc_Average_Score(四舍五入到一位小数后)相等的酒店?提示:写一个接收行 Series 的函数比较两值,不等时打印消息,再用 .apply() 逐行执行
  7. 统计 Negative_Review 为 "No Negative" 的行数
  8. 统计 Positive_Review 为 "No Positive" 的行数
  9. 同时满足 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_avgtransform('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 非标准化等特性均源于该特定数据集,换数据集时需重新验证这些结论。

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