
简介面向数据分析初学者与旅游业运营人员这份资源以去哪儿网国庆景点真实数据为案例完整演示从数据清洗、建模分析到可视化呈现的旅游景点数据分析实战流程。资源共7个文件包含5个HTML交互式图表景区分布热力图、门票销售柱状图、星级占比饼图等、1个Python分析脚本和1个原始Excel数据表压缩包仅79KB轻量易部署。通过脚本可学习Pandas、NumPy数据处理与Matplotlib可视化技巧结合图表能快速识别客流热点区域、比较景区销售业绩、洞察星级分布规律并借助时间序列分析把握国庆客流变化趋势。资源已有1379人学习适合想系统掌握数据分析全流程并应用于旅游场景的读者可直接复用脚本与模板减少从零搭建环境的时间也适合作为课堂教学或毕业设计的参考案例。1. 旅游景点数据分析实战一条能直接抄的完整技术链路旅游景点数据分析这件事很多人一开始以为就是爬点数据画几张图真正上手才发现坑全在数据里。某个景点评分 4.9可能只有 3 条评论撑起来的某个 5A 景区门票 300 多评论区全是“来了后悔”。这些现象靠肉眼根本看不出来必须把评分、评论数、门票价、地理位置、等级标签放到一个数据集里做交叉验证才能得到能落地的结论。这份资源就是围绕这个场景设计的包含一份 4.8 万条景点记录的数据集、全套清洗与特征工程脚木、多维分析 Notebook以及从静态图表到动态页面的可视化方案覆盖“数据到手 → 可信结论 → 可交付报告”的完整链路适合想用数据解决实际业务问题的数据分析师、运营和刚入门的学习者。2. 技术选型与数据准备为什么我选了这套分析组合2.1 分析对象与数据来源这个模拟项目X的数据集来自某开放数据平台的 JSON 导出采集时间是某年旅游旺季的末尾相当于全年流量峰值后的快照。原始字段一共 14 个覆盖了景点的基础属性、运营指标和用户反馈具体如下景点名称、所在城市、所在省份、景区等级、门票价格、评分、评论数、评论内容摘要、经度、纬度、营业时间、建议游玩时长、分类标签、最近一周热度值。原始记录 52,680 条但里面有大量重复锚点同一个景点因为经纬度小数点后位数不一致被拆成了两条记录同一个景点在不同月份被人为录入两次评分和评论数还不一样。这份资源里提供了一个01_数据预处理.py脚本能把 JSON 洗成干净的结构化 CSV顺便把经纬度统一到 6 位小数精度。2.2 环境与依赖清单整个分析链路我建议用 Python 3.10 起步低于 3.8 会有语法兼容问题高于 3.12 部分地理可视化库还没适配。装依赖不要一次性pip install -r requirements.txt一把梭我习惯分开装哪一步出了问题能立刻定位。# 建议在虚拟环境里执行 conda create -n tourism python3.10 -y conda activate tourism # 数据处理三件套 pip install pandas2.1.4 numpy1.26.4 # 可视化静态图表 动态网页图表 pip install matplotlib3.8.2 pyecharts2.0.5 # 地图底图组件 pip install pyecharts-snapshot0.2.1装完验证一下版本避免环境里残留旧版干扰python -c import pandas as pd; print(pd.__version__)这里是把 pyecharts 单独列出因为它的版本迭代比较激进2.x 和 1.x 的 API 命名差异很大比如Bar()的初始化参数在 2.x 里改成了init_opts老教程里的add()写法直接报废。做地图可视化还依赖pyecharts-snapshot它负责把渲染出的 HTML 页面转成 PNG但这个库对网络环境有要求离线环境下会卡在出图这一步后文避坑章节会细讲。2.3 数据装载与初步探查原始数据是一份嵌套 JSON直接用pd.read_json会得到一堆 dict 列后续处理非常被动。正确做法是先展开到行再做类型推断。import pandas as pd # 读取原始 JSON逐层展开嵌套字段 df_raw pd.read_json(data/tourism_raw.json) df pd.json_normalize(df_raw[data]) # 统一经纬度精度去掉多余小数位 df[经度] df[经度].astype(float).round(6) df[纬度] df[纬度].astype(float).round(6) # 强制类型门票价格可能出现 免费 或 -先转成字符串再清洗 df[门票价格] df[门票价格].astype(str) print(df.shape) print(df.dtypes) print(df.head(5))这段代码的关键点在于pd.json_normalize它能把嵌套在data字段下的列表对象展开成表结构避免手工循环 append。astype(float).round(6)是为了让经纬度在后续去重时有统一的比较基准不然同一个地点会因为116.3912和116.3912001被判为两条记录。门票价格先转字符串是为了把免费、-这类非数字值统一收进来后面清洗时再统一映射成 0 或剔除。数据装载完成后的第一件事不是跑分析而是做完整性检查。先看每列空值占比再看描述性统计这两个动作花不了 1 分钟但能避开后面 80% 的翻车。我一般会关注三件事评论数是 0 的景点有多少门票价格分布有没有极端值经纬度是否落在合法范围内经度 73~135纬度 18~54。3. 数据清洗与特征工程把 4.8 万条记录整理成可信数据集3.1 缺失值与异常值处理原始数据缺失情况比较典型评分缺失集中在评论数为 0 的景点门票价格缺失集中在“未收费”类目营业时间缺失在博物馆类景点里很常见。无脑 dropna 会把大量有效样本丢掉我采用的策略是分字段区别对待。import pandas as pd import numpy as np # 1) 评分缺失评论数为 0 时补全为 NaN后续单独处理 df.loc[(df[评分].isna()) (df[评论数] 0), 评分] np.nan # 2) 门票价格把 免费 和 - 统一映射为 0缺失值按同类景点的中位数填充 df[门票价格] df[门票价格].replace({免费: 0, -: 0}) df[门票价格] pd.to_numeric(df[门票价格], errorscoerce) med_price df.groupby(分类标签)[门票价格].transform(median) df[门票价格] df[门票价格].fillna(med_price) # 3) 经度纬度筛掉超出中国范围的非法坐标 df df[(df[经度].between(73, 135)) (df[纬度].between(18, 54))] # 4) 评分范围强制校验保留 0~5 分区间 df df[df[评分].between(0, 5)] print(f清洗后剩余记录数: {df.shape[0]})这个清洗思路有几个可复用的点。评分缺失分成两类处理评论数为 0 的景点评分缺失是天然事实不能硬填评论数大于 0 但评分缺失的记录才考虑用同类均值。门票价格用同一分类标签下的中位数填充比全局均值更合理因为 5A 山岳类景区和城市公园的票价差距可能是 20 倍。坐标过滤用的是硬边界超出中国版图范围的直接剔除不要靠目测判断。评分范围过滤是为了防止后续做分布分析时出现 6.2 分这种明显录入错误的数据。3.2 去重策略这个环节是数据清洗里最容易被低估的一步。同一个景点的记录名称写法不一样比如“故宫”和“故宫博物院”经纬度精度不一样评分评论数分别来自不同时间点。用单一字段去重一定会误删。我采用的是多字段联合去重。# 步骤1基于名称 城市 分类标签分组的精确去重 df[分组键] df[景点名称] _ df[所在城市] _ df[分类标签] # 步骤2组内按评论数降序保留评论数最大的记录信息量最全 df df.sort_values(评论数, ascendingFalse).drop_duplicates( subset[分组键], keepfirst ) # 步骤3对经纬度相似度做二次去重同一个地点名称只有细微差别 # 使用两两距离计算阈值设为 200 米 from math import radians, sin, cos, asin, sqrt def haversine(lon1, lat1, lon2, lat2): R 6371 dlon radians(lon2 - lon1) dlat radians(lat2 - lat1) a sin(dlat/2)**2 cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 return R * 2 * asin(sqrt(a)) # 只对同一城市内的记录两两计算距离控制计算量 dup_remove [] for city, group in df.groupby(所在城市): coords group[[经度, 纬度]].values for i in range(len(coords)): for j in range(i1, len(coords)): dist haversine(coords[i][0], coords[i][1], coords[j][0], coords[j][1]) if dist 0.2: dup_remove.append(group.index[j]) df df.drop(indexdup_remove) df df.drop(columns[分组键])这里有两个设计细节值得说明。第一去重优先级定为“评论数最大”因为评论数越多评分和摘要文本的信息量越可信这是信息完整性优先的策略。第二经纬度二次去重的计算量很大所以先按城市分组再做两两距离把计算范围从全局 4.8 万条的两两组合缩小到城市内部运算时间从分钟级降到秒级。200 米阈值是经验值城市尺度下同一个景点的多次录入经纬度偏移极少超过这个范围而两个不同景点相距 200 米内是极罕见的。3.3 特征构造清洗干净之后直接拿原始字段跑分析会有几个盲区评论数为 5、评分为 5 的景点不能和评论数 5000、评分为 4.8 的景点视为同一可信度门票价格 0 元但评论数极高的景点和门票价格 120 元评论数相似的景点反映出的吸引力逻辑也不同。所以我在原字段基础上构造了四个衍生特征。# 1) 评分置信度评论数越少评分置信度越低用对数压缩 df[评分置信度] np.log10(df[评论数] 1) / np.log10(df[评论数].max() 1) # 2) 评论密度单位价格对应的评论量衡量性价比感知 df[评论密度] df[评论数] / (df[门票价格] 1) # 3) 热度分层最近一周热度值按四分位数分层避免极端值干扰 df[热度分层] pd.qcut(df[最近一周热度值], q4, labels[低热度, 中低热度, 中高热度, 高热度]) # 4) 评分修正用置信度做加权修正后的评分向均值回归 df[修正评分] df[评分] * df[评分置信度] 4.0 * (1 - df[评分置信度]) print(df[[景点名称, 评分, 评论数, 评分置信度, 修正评分]].head(10))评分置信度的意义在于一个 5.0 分但只有 2 条评论的景点修正分会从 5.0 拉低到 4.0 左右而一个 4.8 分评论数 8000 的景点几乎保持不变。评论密度反映的是“每花一块钱能看到多少用户反馈”免费景点天然占便宜但这个特征能找出免费又不值得去的地方。热度分层用四分位数而不是绝对值因为热度值的分布长尾极其严重最大值为最小值的几千倍直接拿原始数值做聚类会把所有样本挤到一两层里。4. 核心分析与洞察挖掘评分、评论文本、门票三个维度交叉验证4.1 评分分布与高分景点的真实含金量拿到修正评分之后第一个分析动作是做评分分布对比看原始评分和修正评分的分布差异在哪里。这一步能直观暴露出“评分注水”的区域。import matplotlib.pyplot as plt fig, axes plt.subplots(1, 2, figsize(12, 4)) # 原始评分分布 df[评分].value_counts(bins10).sort_index().plot( kindbar, axaxes[0], title原始评分分布, color#4C72B0 ) # 修正评分分布 df[修正评分].value_counts(bins10).sort_index().plot( kindbar, axaxes[1], title修正评分分布置信度加权, color#DD8452 ) plt.tight_layout() plt.savefig(output/评分分布对比.png, dpi150, bbox_inchestight)这里能看到一个典型现象原始评分 4.5~5.0 区间占比非常高但修正后这个区间的柱高明显缩水分数向 4.0~4.5 区间迁移。原因是大量低评论数的 5.0 分样本被拉回均值附近。接下来量化高分景区的“含水量”我按景区等级分组统计每个等级下的高分占比和高分平均评论数。high_score df[df[评分] 4.5] summary df.groupby(景区等级).apply( lambda g: pd.Series({ 景点总数: len(g), 高分占比: (g[评分] 4.5).mean(), 高分平均评论数: g.loc[g[评分] 4.5, 评论数].mean(), 总平均评论数: g[评论数].mean(), }) ).round(2) print(summary)关键观察点如果某个等级的高分占比很高但高分平均评论数远低于该等级总平均评论数说明这个等级的高分主要由小样本撑起来参考价值有限。另一个值得注意的维度是按城市聚合看哪些城市的高分景点占比高但评论数密度低这类城市往往是营销评分重灾区。4.2 评论量与热度分层找出真正的热门景点评论数和热度值之间不是简单的正相关有的景点评论数高但热度已经回落属于“口碑沉淀型”有的景点评论数不高但热度值爆表属于“新晋网红型”。我通过交叉表把景点分成四类让两类特征各自发挥价值。# 评论数也做四分位分层与热度分层交叉 df[评论量分层] pd.qcut(df[评论数], q4, labels[低评论, 中低评论, 中高评论, 高评论]) # 交叉表 cross pd.crosstab(df[评论量分层], df[热度分层]) print(cross) # 提取四个典型象限 hot_and_reviewed df[(df[评论量分层] 高评论) (df[热度分层] 高热度)] hot_only df[(df[评论量分层] 低评论) (df[热度分层] 高热度)] new_comers df[(df[评论量分层] 低评论) (df[热度分层] 中低热度)] print(f口碑沉淀型高评论高热度{len(hot_and_reviewed)} 个) print(f网红新贵型低评论高热度{len(hot_only)} 个)热门景点的识别如果只看单一维度会漏掉这两种截然不同的类型。口碑沉淀型是游客用脚投票投出来的适合做长线推荐网红新贵型是营销推手在短期内制造的热度流量来的快、退得也快做旅行规划时需要谨慎。交叉分析的价值就在于把这两种类型同时拎出来而不是给一个笼统的“最热 TOP10”。4.3 门票价格带与吸引力关系价格和吸引力之间的关系很多人直觉认为“越贵越值得去”但数据呈现出来的往往是一个倒 U 型曲线。我用价格分箱的方式验证这个假设。# 价格分箱0元、1-50元、51-100元、101-200元、200元以上 bins [-1, 0, 50, 100, 200, float(inf)] labels [免费, 1-50元, 51-100元, 101-200元, 200元以上] df[价格带] pd.cut(df[门票价格], binsbins, labelslabels) price_analysis df.groupby(价格带, observedTrue).agg( 景点数(景点名称, count), 平均评分(评分, mean), 平均评论数(评论数, mean), ).round(2) print(price_analysis)这类分析的结果通常有清晰的业务指向免费景点的评论数均值往往最高说明免费带来的流量效应显著200 元以上景点的评分均值可能并不比 51-100 元档高多少但评论数骤减说明高价形成了一定的门槛效应。如果把价格和评分置信度结合起来看会发现在 200 元以上档位高分景点的置信度反而偏低因为样本量太小说明高票价景区的口碑评价基数本身就不足。5. 可视化呈现与避坑静态图表与动态页面并存的高频踩坑记录5.1 matplotlib 与 pyecharts 的分工逻辑一套完整报告里静态图和动态图各有各的用武之地。我的分工逻辑是趋势分布类图表用 matplotlib 出静态图方便直接插入 PPT 和 Word地理分布和交互筛选类图表用 pyecharts 出 HTML方便在线查看和演示。两者不重复各干各的。import pyecharts.options as opts from pyecharts.charts import Bar # 景区等级与平均评分的柱状图动态交互版 bar ( Bar(init_optsopts.InitOpts(width1200px, height600px)) .add_xaxis(summary.index.tolist()) .add_yaxis(平均评分, summary[平均评分].tolist()) .set_global_opts( title_optsopts.TitleOpts(title各景区等级平均评分对比), yaxis_optsopts.AxisOpts(min_0, max_5), ) ) bar.render(output/等级评分对比.html)这里最容易踩的坑是init_opts的写法。2.x 版本必须在InitOpts里传opts对象而不是直接传字典。另外add_yaxis的数据必须是列表不能传 numpy 数组否则会报TypeError。5.2 地理可视化与坐标系设置省级分布和城市分布使用地图展示时pyecharts 的 Geo 和 Map 组件框架相似但坐标系差异很大。Geo 图表自带百度或高德底图用经纬度数据点图Map 组件用省份或城市名称做区域填充是注册的地图数据。两者选错会直接翻车。from pyecharts.charts import Map # 按省份聚合评论数 province_stats df.groupby(所在省份)[评论数].sum().reset_index() # 省份名称需要与地图库的命名对齐 province_stats[所在省份] province_stats[所在省份].str.replace(省, ) map_chart ( Map(init_optsopts.InitOpts(width1200px, height700px)) .add(评论数, province_stats[[所在省份, 评论数]].values.tolist(), maptypechina) .set_global_opts( title_optsopts.TitleOpts(title全国景点评论数分布), visualmap_optsopts.VisualMapOpts(max_province_stats[评论数].max()), ) ) map_chart.render(output/全国评论数分布.html)最容易翻车的是省份名称与地图库不匹配。地图库内置数据里如果用“北京”“河北”这种简称你传“北京市”“河北省”就会整片显示为灰色。另外一个坑是地图组件需要在线加载 GeoJSON离线环境下历史版本常见的现象是只渲染出标题、地图区域一块空白后文避坑章节细说。5.3 避坑可视化与运行时的高频踩坑记录以下几条坑来自实测过程中的血泪经验按现象、原因、解决的格式整理希望能帮你省下重复排查的时间。现象 1pyecharts 渲染出的 HTML 页面是空白的标题能显示地图和图表区域全灰。原因组件默认尝试从远程服务器加载地图 GeoJSON 数据环境不能联网时会加载失败。解决做地图可视化前先把依赖的地图数据文件下载到本地放到pyecharts/datasets/目录下并把maptype参数与本地文件名对齐或者纯离线环境下改用 matplotlib Basemap 做静态地图不依赖在线底图。现象 2matplotlib 图表的汉字全部显示为方块。原因matplotlib 默认字体是 DejaVu Sans不支持中文。解决在绘图代码前设置中文字体和负号显示标准配置是plt.rcParams[font.sans-serif] [SimHei]同时加上plt.rcParams[axes.unicode_minus] False否则坐标轴的负号也会显示成方块。现象 3pandas 读取 CSV 时门票价格列变成了字符串数值计算全部报错。原因源数据里同一列混入了“免费”“-”“面议”等非数值内容pandas 读进来时整列被推断为 object 类型。解决读取时指定dtype{门票价格: str}读进来之后统一做字符串替换再用pd.to_numeric(errorscoerce)转换这样保证清洗逻辑完全可控。现象 4df.groupby()后调用apply(lambda g: ...)时某些组返回空结果导致整条链中断。原因组内数据全部是 NaN或者某一列全是缺失值分组函数没有做空值保护。解决在自定义分组函数内部先判断g是否为空若为空则返回pd.Series带默认值防止中断。现象 5用pd.qcut()分位数分层时报错Bin edges must be unique。原因数据里有大量重复值导致分位数的边界重合。解决分位数切分前先加一个极小的随机噪声或者用duplicatesdrop参数再退一步改用cut按固定阈值切分。6. 部署与自动化把分析流程封装成可重复运行的 Pipeline6.1 项目目录结构与脚本分层分析做到这个程度单靠一个 Jupyter Notebook 已经很难维护了。后一步我把整个分析流程拆成五个脚本每个脚本只干一件事按顺序执行。这个结构也方便你后续只重跑某一步不用从头再来。tourism_analysis/ ├── data/ # 数据目录放原始 JSON 和清洗后 CSV │ ├── tourism_raw.json │ └── tourism_cleaned.csv ├── scripts/ # 脚本目录 │ ├── 01_data_preprocess.py # 数据装载、清洗、去重、特征工程 │ ├── 02_analysis.py # 核心分析评分、评论、价格交叉 │ ├── 03_visualize.py # 静态图 动态图输出 │ └── 04_export_report.py # 汇总 Markdown 报告 ├── output/ # 输出目录图表、HTML、报告 └── requirements.txt脚本分层的关键是每层只消费上一层产出的数据文件。01_data_preprocess.py产出tourism_cleaned.csv02_analysis.py只读这个 CSV不碰原始 JSON。这样清洗逻辑一旦调整只需要重跑第一个脚本后面分析不需要动。6.2 增量更新与缓存机制数据分析最烦的是每次跑全流程都要等数据装载和清洗几万条记录虽然也就几秒钟但反复调试时这几秒钟会被放大成几十次无效等待。我在这里加了一个缓存机制清洗结果以 CSV 落盘分析脚本起动时先检查缓存文件是否存在存在就直接读取。import os import pandas as pd CACHE_PATH data/tourism_cleaned.csv RAW_PATH data/tourism_raw.json def load_clean_data(force_recleanFalse): 优先读缓存缓存缺失或强制重建时才重新清洗 if os.path.exists(CACHE_PATH) and not force_reclean: print(f读取缓存数据: {CACHE_PATH}) return pd.read_csv(CACHE_PATH) print(执行数据清洗流程...) df pd.read_json(RAW_PATH) # 省略清洗步骤... df.to_csv(CACHE_PATH, indexFalse, encodingutf-8-sig) return df # 使用示例日常跑分析走缓存修改清洗逻辑后强制重建 df load_clean_data(force_recleanFalse)这里两个参数值得说明。force_recleanTrue在修改清洗规则后使用避免旧缓存影响新逻辑encodingutf-8-sig是为了让 Excel 打开 CSV 时不出现中文乱码虽然 pandas 默认写入 utf-8但 Excel 只认带 BOM 的 utf-8。6.3 验证持久化与报告导出分析结果不能只停留在控制台。我这个流程的最后一步是把关键分析结论汇总成一个 markdown 报告文件把图表路径和结论文本都写进去后续可以直接转 PDF 或交给其他团队成员阅读。def export_report(df): lines [] lines.append(# 旅游景点数据分析报告\n) # 核心数据概览 lines.append(f- 清洗后景点总数**{df.shape[0]}**) lines.append(f- 高评分景点占比原始评分≥4.5**{(df[评分] 4.5).mean():.1%}**) lines.append(f- 高评分景点占比修正评分≥4.5**{(df[修正评分] 4.5).mean():.1%}**) lines.append(f- 免费景点占比**{(df[门票价格] 0).mean():.1%}**\n) # 按省份写入 TOP 排行 lines.append(## 评论数 TOP10 省份\n) for row in df.groupby(所在省份)[评论数].sum().nlargest(10).items(): lines.append(f- {row[0]}{row[1]} 条\n) report_text \n.join(lines) with open(output/分析报告.md, w, encodingutf-8) as f: f.write(report_text) print(报告已导出到 output/分析报告.md)报告导出不是简单把结果拼起来要让读报告的人只看文本就能抓住重点。比如修正评分和原始评分占比的对比直接说明评分可信度的变化幅度这就是整个分析链路里最有信息量的一行数据。从那以后我每做一次分析都会强制走一遍“装载 → 清洗 → 特征 → 分析 → 可视化 → 报告导出”这个完整流程即便数据只有几百条也照跑不为别的就为了流程固化之后每步都能追溯、能复现、能自动化。这套资源里的脚本和数据集按这个顺序跑一遍你能完整看到从原始 JSON 到最终报告的全过程希望帮到你。本文还有配套的精品资源点击获取