☰
旅游景点数据分析实战:从评论表到客流预测的完整复现路径
2026/10/10 21:20:36 网站建设 项目流程

简介:这份资源面向具备一定Python基础、希望入门数据分析实战的开发者与旅游行业从业者,围绕去哪儿网国庆期间景点数据展开完整分析流程。包内共7个文件,以5个html可视化页面、1个xlsx数据源和1个py分析脚本为主,压缩包约79KB,体积轻便却覆盖了从数据清洗到结果呈现的关键环节。已有1379人学习下载,说明其在同类实战案例中具备一定参考价值。读者可借助脚本完成缺失值、异常值处理,并生成各省份景点分布热力图、门票销售额TOP20柱状图、景区星级比例饼图及热门景点推荐排行等图表,直观理解游客分布热点、销售业绩差异与星级偏好。整体流程涵盖数据收集、清洗、建模到业务洞察,适合作为数据分析入门练手项目,也可为旅游营销、定价与服务优化提供数据驱动决策的思路参考。

1. 旅游景点数据分析实战:从评论表到客流预测的完整复现路径

手里拿到一份景区评论数据,字段散落在 CSV 和数据库里,评分、时间、客源地、门票价格混在一起,想跑个客流预测却卡在清洗环节——这是很多做旅游数据分析的人真实的状态。旅游景点数据分析实战这套资源,核心就是解决从原始评论、订单、客流记录到可建模特征这条链路上的工程问题。它适合两类人:一类是刚接触文旅数据、想拿真实场景练手的分析师;另一类是在景区或 OTA 做运营、需要自己动手出报表和预测的从业者。资源本身覆盖数据清洗、评分情感倾向、客流时间序列、客源地分布几个模块,不是纯理论讲义,而是能直接跑通的脚本和样例数据。下面按我实际拆包的顺序,把每个环节的参数、坑和验证方法讲清楚。

2. 数据清洗与字段对齐:把评论表和客流表拼成一张宽表

2.1 先搞清楚两张表的粒度差异

旅游数据最麻烦的地方在于,评论表是「一条评论一行」,粒度是用户-景点-时间;客流表往往是「一个景点一天一行」,粒度是景点-日期。直接 merge 会炸行数。我一般先把两张表的时间字段统一成date类型,评论表按天聚合出「当日评论数」「当日均分」,再去和客流表做左连接。这一步不做,后面所有时间序列都是错的。

常见做法是用 pandas 的to_datetime加dt.normalize()把时间戳压到天,再用groupby(['poi_id','date']).agg()生成日粒度特征。注意评论表里可能有同一用户同一天对同一景点的多条评论,聚合时要先去重,否则评论数会虚高。

2.2 清洗脚本与参数说明

import pandas as pd import numpy as np # 读取原始评论表,注意 encoding 常见为 utf-8 或 gbk comments = pd.read_csv('comments.csv', encoding='utf-8') flow = pd.read_csv('flow.csv', encoding='utf-8') # 时间字段统一:评论时间可能带时分秒,压到天 comments['date'] = pd.to_datetime(comments['comment_time']).dt.normalize() flow['date'] = pd.to_datetime(flow['stat_date']).dt.normalize() # 去重:同一用户同一天同一景点只保留一条 comments = comments.drop_duplicates(subset=['user_id','poi_id','date']) # 按天聚合评论特征 daily_comment = comments.groupby(['poi_id','date']).agg( comment_cnt=('comment_id','count'), avg_score=('score','mean'), neg_cnt=('score', lambda x: (x <= 2).sum()) # 2分及以下视为负面 ).reset_index() # 左连接客流表,保留客流表全部日期 wide = pd.merge(flow, daily_comment, on=['poi_id','date'], how='left') wide['comment_cnt'] = wide['comment_cnt'].fillna(0) wide['avg_score'] = wide['avg_score'].fillna(wide['avg_score'].median())

逻辑上,drop_duplicates的 subset 必须包含poi_id,否则跨景点的同日评论会被误删。neg_cnt的阈值 2 分是文旅场景常用经验值,如果数据评分是 1-5 分制,2 分及以下基本对应差评。fillna用中位数而不是 0,是因为评论缺失不代表评分为 0,用 0 会把均分拉低,后面做相关性分析会失真。

参数上,encoding如果报UnicodeDecodeError,先试gbk,再试utf-8-sig。how='left'保证客流表日期不丢,因为预测目标是客流,评论只是特征。如果反过来用inner,会丢掉没有评论的日期,导致时间序列断档。

2.3 字段对齐后的检查清单

拼完宽表别急着建模,先跑三行检查:wide.shape看行数是否等于客流表行数;wide.isnull().sum()看还有哪些列有缺失;wide['date'].diff().dt.days.value_counts()看日期是否连续。如果出现大于 1 的间隔,说明客流表本身有缺日,需要补全日期索引再 reindex。这一步很多教程跳过,但实际做预测时,日期不连续会让滞后特征全部错位。

3. 评分情感倾向与客源地分布:用轻量脚本替代重型模型

3.1 为什么不用 BERT 做评论情感

旅游评论普遍短、口语化、带方言,用预训练大模型跑情感不是不行,但资源里给的样例数据量不大,跑 BERT 的收益远不如把规则做细。我一般用「评分 + 关键词规则」双通道:评分直接作为强标签,关键词规则处理评分缺失或评分与文本矛盾的情况。比如「风景不错但厕所太脏」这种,评分可能给 4 分,但文本里有负面词,规则通道会把它标成混合情感。

关键词表可以按景区场景定制:正面词包括「值得」「惊艳」「方便」「干净」,负面词包括「排队」「宰客」「脏」「失望」。匹配时用str.contains加正则,注意中文分词边界,别用简单的in,否则「不干净」会被「干净」误判。

3.2 客源地提取与分布统计

import re # 从用户信息表提取省份,常见格式:广东省深圳市、北京朝阳区 def extract_province(addr): if pd.isna(addr): return '未知' match = re.match(r'(.{2,8}?(省|市|自治区))', str(addr)) return match.group(1) if match else '其他' user_info['province'] = user_info['address'].apply(extract_province) # 合并到评论表,统计各省评论量 comments = comments.merge(user_info[['user_id','province']], on='user_id', how='left') province_dist = comments.groupby('province')['comment_id'].count().sort_values(ascending=False) # 计算客源地集中度:前三个省份占比 top3_ratio = province_dist.head(3).sum() / province_dist.sum()

正则(.{2,8}?(省|市|自治区))里的?是非贪婪匹配,防止「黑龙江省哈尔滨市」被整段吞掉。.{2,8}限制长度是因为直辖市和省份名称长度差异大,不限制会匹配到奇怪的后缀。top3_ratio这个指标比单纯看排名有用,如果超过 0.6,说明客源高度集中,做营销投放时应该优先打透这几个省,而不是全国撒网。

3.3 分布结果的可视化验证

统计完别只看数字,画个横向条形图,按评论量降序排。如果发现「未知」或「其他」占比超过 20%,说明地址解析规则覆盖不够,需要回头补正则。常见漏网格式包括「XX省XX市XX区」带空格、「XX市」前面没有省名。这时候可以加一条兜底规则:如果匹配不到省,就看地址里有没有出现已知城市名,用城市反查省份。

4. 客流时间序列建模:滞后特征与节假日编码的实操细节

4.1 特征工程比模型选择更重要

客流预测的精度,八成取决于特征,而不是用 ARIMA 还是 LightGBM。我一般构造四类特征:滞后特征(前 1、7、14 天客流)、滑动窗口(7 天均值、14 天标准差)、日历特征(星期、月份、是否节假日)、评论特征(当日评论数、均分)。滞后特征用shift生成,注意 shift 之后第一行会是 NaN,建模时要 drop 掉或者用前向填充。

节假日编码别只用 0/1,因为节假日的「前一天」和「后一天」客流也异常。常见做法是加一列is_holiday_eve和is_holiday_after,用pd.DateOffset对节假日列表做前后偏移。如果资源里没有节假日表,可以用chinese_calendar库,但注意它只覆盖法定节假日,景区自己的活动日需要手动补。

4.2 建模脚本与参数

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit # 构造滞后特征 for lag in [1, 7, 14]: wide[f'flow_lag_{lag}'] = wide.groupby('poi_id')['flow'].shift(lag) # 滑动窗口 wide['flow_roll_7'] = wide.groupby('poi_id')['flow'].transform( lambda x: x.rolling(7, min_periods=1).mean() ) # 日历特征 wide['weekday'] = wide['date'].dt.weekday wide['month'] = wide['date'].dt.month wide['is_weekend'] = (wide['weekday'] >= 5).astype(int) # 去掉含 NaN 的行(主要是滞后特征产生的) model_data = wide.dropna(subset=['flow_lag_14']) features = ['flow_lag_1','flow_lag_7','flow_lag_14','flow_roll_7', 'weekday','month','is_weekend','comment_cnt','avg_score'] X = model_data[features] y = model_data['flow'] # 时间序列交叉验证,不能随机 split tscv = TimeSeriesSplit(n_splits=5) for train_idx, val_idx in tscv.split(X): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = lgb.LGBMRegressor(n_estimators=300, learning_rate=0.05, num_leaves=31, min_child_samples=20) model.fit(X_train, y_train) pred = model.predict(X_val) # 这里算 MAE 或 MAPE

groupby('poi_id')不能省,否则不同景点的客流会串行 shift,滞后特征完全错乱。TimeSeriesSplit替代train_test_split是硬性要求,随机切分会让未来数据泄露到训练集,验证分数虚高。min_child_samples=20是防止过拟合的常用值,如果数据量小于 5000 行,可以调到 10。

4.3 预测结果怎么验证才可信

别只看 MAE。客流预测要分场景看:工作日误差、周末误差、节假日误差分开算。如果节假日 MAE 是工作日的三倍,说明节假日特征没做好,需要加「节假日类型」多分类,而不是一个 0/1。另外,把预测值和真实值按时间画折线,看峰值有没有对上。峰值对不上,MAE 再小也没用,因为景区最关心的就是高峰日。

5. 避坑与常见问题排查:那些跑不通的报错到底卡在哪

5.1 日期解析报 OutOfBoundsDatetime

现象:pd.to_datetime报OutOfBoundsDatetime: Out of bounds nanosecond timestamp。原因:原始数据里有 1970 年之前或 2262 年之后的异常日期,常见于测试数据或脏数据。解决:加errors='coerce'把异常日期转成 NaT,再统一 drop 或填充。别直接改系统时间范围,那是治标不治本。

5.2 merge 后行数暴涨

现象:左连接后行数比左表多出几倍。原因:右表(评论聚合表)里poi_id + date不唯一,可能因为聚合时漏了某个维度,或者原始评论表有重复。解决:merge 前先daily_comment.duplicated(subset=['poi_id','date']).sum()检查,如果有重复,回去检查 groupby 的 key 是否完整。

5.3 滞后特征全为 NaN

现象:shift(1)之后整列都是 NaN。原因:数据没有按poi_id和date排序,shift 是在原始顺序上操作的。解决:shift 之前必须wide.sort_values(['poi_id','date'], inplace=True),否则 groupby shift 的结果没有意义。这个坑我踩过不止一次,排序这行代码看着不起眼,漏了后面全白做。

5.4 模型验证分数高得离谱

现象:交叉验证 R² 超过 0.98。原因:特征里混入了未来信息,比如用了当天的客流去预测当天,或者滑动窗口没有加closed='left'。解决:检查所有特征列,凡是和预测目标同一天生成的,一律删掉。滑动窗口用rolling(7).mean()默认包含当前行,要改成rolling(7).mean().shift(1)。

5.5 中文编码导致关键词匹配失效

现象:负面词规则一条都匹配不上。原因:CSV 读取时编码不对,中文变成乱码,str.contains自然找不到。解决:读文件时显式指定encoding='utf-8-sig',如果还不行,用chardet.detect先探测编码。别用默认编码硬扛,Windows 和 Linux 默认值不一样,换台机器就翻车。

6. 进阶技巧:用分位数回归看客流区间而不是单点

单点预测给运营的价值有限,景区更想知道「明天客流大概率在什么范围」。把 LightGBM 的损失函数从regression改成quantile,分别跑 0.1、0.5、0.9 三个分位点,就能得到客流区间。0.5 分位是中位数预测,0.1 和 0.9 构成 80% 置信区间。参数上,alpha控制分位点,objective='quantile'时学习率要调小到 0.03 左右,否则分位线会交叉。

# 分位数回归示例 for alpha in [0.1, 0.5, 0.9]: model = lgb.LGBMRegressor(objective='quantile', alpha=alpha, n_estimators=500, learning_rate=0.03, num_leaves=31) model.fit(X_train, y_train) pred = model.predict(X_val) # 保存三个分位的预测结果

跑完之后,把三条线画在一起,如果 0.1 和 0.9 的区间在节假日明显变宽,说明模型捕捉到了不确定性,这是好事。如果区间宽度恒定,说明特征里没有区分高低峰的信息,需要加「是否节假日」「天气」这类变量。天气数据资源里没给,但常见做法是接一个公开天气 API,按天对齐,注意 API 返回的是当天天气,预测时要滞后一天使用,否则又是未来信息泄露。

验证区间是否合理,可以用「命中率」:真实值落在 0.1 到 0.9 之间的比例,理论上应该接近 80%。如果只有 60%,说明区间偏窄,把分位点改成 0.05 和 0.95 再试。这个指标比 MAE 更贴近业务,运营看的是「有没有漏掉高峰」。

从那以后我每次做时间序列,都强制先跑一遍sort_values和shift的单元检查,确认滞后特征没有 NaN 才往下走。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询