简介:这份资源面向希望入门数据分析实战的开发者与旅游行业从业者,以去哪儿网国庆景点数据为样本,完整演示从数据清洗到可视化呈现的分析链路,帮助读者掌握用Python处理真实业务数据的思路与方法。压缩包共7个文件,约79KB,包含5个html可视化页面、1个xlsx数据源和1个py分析脚本,分别对应图表成果展示、原始数据存储与预处理分析代码,结构紧凑、便于按需查阅。目前已有1387人学习下载。内容围绕各省份景点分布热力图、门票销量柱状图、景区星级比例饼状图及热门景点推荐排行展开,涉及数据清洗、时间序列趋势判断与业务洞察提取,读者可据此理解GIS热力图制作、Pandas与Matplotlib绘图流程,并借鉴脚本组织方式完成自己的分析报告,适合作为课程作业或小型项目的参考范例。
1. 从一份“景点数据”说起:这套分析方案到底解决什么问题
手里拿到一份旅游景点数据,字段看着挺全——景点名称、所在城市、评分、评论数、门票价格、开放时间,甚至还有经纬度。可真要动手做分析,问题立刻冒出来:评分高的景点评论数往往很少,评论数多的又集中在少数几个头部景区;门票价格缺失一大片,城市字段里“北京市”和“北京”混着写;想按区域做对比,发现根本没有统一的区域划分。这就是旅游景点数据分析实战最典型的起点——数据不是没有,而是脏、散、口径不一。
这套方案要解决的,就是把一份原始景点数据整理成能支撑决策的分析结论:哪些城市景点密度高但质量参差、价格和评分之间到底有没有关系、评论数能不能当作热度代理指标、区域之间呈现什么样的分布格局。适合两类人:正在做数据分析课程设计或毕设的学生,需要一套从清洗到可视化到结论的完整链路;以及要交付一份景点分析报告的从业者,需要能直接改参数、换数据就复用的方案骨架。下面按需求、设计、实现、避坑、验收的顺序展开,中间的关键代码和参数都可以直接抄。
2. 需求拆解与数据口径:先想清楚要回答哪几个问题
2.1 四个核心分析问题与对应指标
做数据分析最容易翻车的地方,不是代码写不出来,而是分析目标没定死,导致后面清洗、建模、可视化全在打转。我一般会先把要回答的问题列成一张表,每个问题绑定一到两个可计算指标,这样后面每一步都有落点。
| 分析问题 | 核心指标 | 数据依赖 |
|---|---|---|
| 哪些城市景点资源集中 | 城市景点数量、城市平均评分 | 城市字段、评分 |
| 景点质量与热度是否一致 | 评分、评论数、二者散点关系 | 评分、评论数 |
| 门票价格是否影响评分 | 价格区间、区间内平均评分 | 门票价格、评分 |
| 区域分布格局如何 | 经纬度聚类、区域景点密度 | 经纬度、区域字段 |
这张表的作用是约束范围。比如“门票价格是否影响评分”这个问题,如果价格缺失率超过三成,就得先决定是剔除还是插补,而不是等到画图时才发现一半数据是空的。指标定下来之后,数据口径才有讨论的基础。
2.2 字段清洗规则与缺失值处理策略
旅游景点数据的脏是结构性的:城市名不统一、价格带“元”字、评分有的是“4.5分”有的是数字、经纬度偶尔为空。清洗规则要写成可复现的代码,而不是手工改 Excel。下面这段是字段标准化的核心逻辑,用 pandas 实现,参数都可以按实际数据调整。
import pandas as pd import numpy as np def clean_scenic_data(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() # 城市字段:去掉“市”“区”后缀,统一为简称 df["city"] = ( df["city"].astype(str) .str.replace(r"[市辖区县]$", "", regex=True) .str.strip() ) # 评分字段:剥离“分”字,转数值,非法值置空 df["score"] = pd.to_numeric( df["score"].astype(str).str.replace("分", "", regex=False), errors="coerce" ) # 价格字段:剥离“元”和“起”字,转数值 df["price"] = pd.to_numeric( df["price"].astype(str).str.replace(r"[元起]", "", regex=True), errors="coerce" ) # 评论数:转数值,缺失按 0 处理(无评论视为无热度) df["comment_count"] = pd.to_numeric(df["comment_count"], errors="coerce").fillna(0) # 评分缺失:按城市中位数插补,城市也缺失则用全局中位数 city_median = df.groupby("city")["score"].transform("median") df["score"] = df["score"].fillna(city_median).fillna(df["score"].median()) # 价格缺失:保留为空,后续按“价格未知”单独分组,不强行插补 return df这段代码有几个设计取舍值得说清楚。城市字段用正则去掉行政后缀,是因为“北京市”和“北京”如果不统一,后面 groupby 会把同一个城市拆成两组,统计结果直接失真。评分用城市中位数插补而不是全局均值,是因为不同城市景点评分基线不同,用全局值会把一线城市的高分景点拉低。价格缺失选择保留而不是插补,是因为价格缺失本身可能携带信息——很多免费景点价格字段就是空的,强行填一个数反而制造了假数据。评论数缺失填 0 是业务判断:没有评论记录,在热度维度上就等同于零热度。
提示:清洗函数一定要写成纯函数,输入输出都是 DataFrame,不要在里面直接读文件或画图。这样后面换一份数据、调一个参数,重跑一遍就行,不用改逻辑。
2.3 分析粒度选择:城市级还是景点级
同一个数据集,按城市聚合和按景点分析,结论可能完全相反。城市级看的是资源分布和整体质量,景点级看的是个体差异和异常值。我的做法是两层都做,但主结论落在城市级,景点级只用来解释异常。比如某城市平均评分很高,但景点级一看只有三个景点、其中一个还是刚上线刷了高分的,这种结论就不能直接写进报告。粒度选择本质上是结论可信度的问题,不是技术问题。
3. 总体方案设计:从原始表到分析结论的四层结构
3.1 四层架构与模块职责划分
整套方案按数据流分成四层,每层职责单一,层与层之间用明确的中间产物衔接。这样设计的好处是任何一层出问题都能单独排查,不用从头跑一遍。
第一层是数据接入层,负责读原始文件、统一编码、做字段类型推断。第二层是清洗与特征层,就是上一章那套清洗规则,外加派生特征——价格区间、热度等级、区域标签。第三层是分析计算层,按四个核心问题分别产出聚合表和统计量。第四层是呈现层,输出图表和结论表。四层之间用 DataFrame 和聚合结果传递,不引入数据库,是因为景点数据量通常在几千到几万条,pandas 完全够用,上数据库反而是过度设计。
模块划分上,我一般拆成loader.py、cleaner.py、analyzer.py、reporter.py四个文件,加一个config.py放路径和参数。这样别人接手时,看文件名就知道去哪改。
3.2 派生特征设计:价格区间、热度等级与区域标签
原始字段直接拿来分析往往不够用,派生特征是把业务判断固化进数据的关键一步。价格区间按分位数切,而不是拍脑袋定 50、100、200,因为不同数据集的价格分布差异很大。热度等级用评论数的对数分箱,避免头部景点把整体分布拉偏。区域标签如果原始数据没有,就用经纬度做简单的地理分桶。
def build_features(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() # 价格区间:按 25/50/75 分位切四档,缺失单独一档 price_valid = df["price"].dropna() if len(price_valid) > 0: bins = [-np.inf] + list(price_valid.quantile([0.25, 0.5, 0.75])) + [np.inf] labels = ["低价", "中低价", "中高价", "高价"] df["price_band"] = pd.cut(df["price"], bins=bins, labels=labels) df["price_band"] = df["price_band"].cat.add_categories("价格未知").fillna("价格未知") # 热度等级:评论数取 log1p 后按三分位切 df["log_comment"] = np.log1p(df["comment_count"]) df["heat_level"] = pd.qcut( df["log_comment"], q=3, labels=["低热度", "中热度", "高热度"], duplicates="drop" ) # 区域标签:按经度粗略分东西,按纬度分南北,仅作分布参考 df["region"] = np.where(df["lng"] >= df["lng"].median(), "东", "西") + \ np.where(df["lat"] >= df["lat"].median(), "北", "南") return df价格分位切分的参数[0.25, 0.5, 0.75]可以按数据分布调整,如果价格高度集中在低价段,可以把分位改成[0.5, 0.8, 0.95],让高档位更有区分度。热度用log1p是因为评论数长尾极重,直接分箱会导致绝大多数景点挤在低热度档。区域标签用中位数切分只是给分布图一个参照,不能当作严格的地理分区结论,报告里要写清楚这是相对划分。
3.3 分析结果的组织方式:聚合表与结论表分离
分析层输出的不是一张大宽表,而是两张表:聚合表放中间计算结果,结论表放最终要写进报告的数字。聚合表按城市、价格区间、热度等级分别 groupby,结论表只保留每个分析问题对应的关键指标和排序。这样分离的好处是,报告里改一个结论不需要重跑全部聚合,直接从聚合表里取数就行。我见过不少课程设计把聚合和结论混在一起,最后想换个排序方式都得重写整段代码,血泪经验。
4. 核心分析模块实现:四个问题的代码落地
4.1 城市维度聚合:景点数量与质量的双指标排序
城市维度要回答的是资源集中度和质量分布。单纯按景点数量排序会漏掉“数量少但质量高”的城市,所以用双指标——景点数量和平均评分——做一个简单的综合排序。代码不复杂,关键是排序逻辑要写清楚,别让读者猜。
def city_summary(df: pd.DataFrame) -> pd.DataFrame: agg = df.groupby("city").agg( scenic_count=("name", "count"), avg_score=("score", "mean"), median_price=("price", "median"), total_comments=("comment_count", "sum"), ).reset_index() # 综合排序:数量归一化 + 评分归一化,各占一半权重 agg["count_norm"] = agg["scenic_count"] / agg["scenic_count"].max() agg["score_norm"] = (agg["avg_score"] - agg["avg_score"].min()) / \ (agg["avg_score"].max() - agg["avg_score"].min()) agg["composite"] = 0.5 * agg["count_norm"] + 0.5 * agg["score_norm"] return agg.sort_values("composite", ascending=False)权重0.5 / 0.5是默认值,如果报告侧重资源规模,可以把数量权重调到 0.7。归一化用 min-max 而不是 z-score,是因为这里只需要相对排序,不需要假设分布。median_price用中位数而不是均值,是为了避免个别高价景点把城市价格水平拉高。这张聚合表是后面所有城市级结论的数据源,改权重重跑一次就能得到不同视角的排序,答辩时被问到“为什么这么排”也有据可依。
4.2 评分与热度关系:散点分组与相关性检验
评分和评论数的关系是旅游数据里最容易被误读的一对指标。直觉上评分高应该评论多,实际数据里往往是两头分化:头部景点评论极多但评分中等,小众景点评分很高但评论寥寥。分析这一步不能只画个散点图就完事,要分组看趋势,再补一个相关性系数。
from scipy.stats import spearmanr def score_heat_relation(df: pd.DataFrame) -> dict: # 剔除评论数为 0 的景点,避免 log 后无意义 sub = df[df["comment_count"] > 0].copy() corr, p_value = spearmanr(sub["score"], sub["log_comment"]) # 按热度等级分组看平均评分 group = sub.groupby("heat_level", observed=True)["score"].agg(["mean", "count"]) return { "spearman_corr": round(corr, 3), "p_value": round(p_value, 4), "group_mean": group, }用 Spearman 而不是 Pearson,是因为评分和评论数都不满足正态假设,评论数还是长尾分布,秩相关更稳健。p_value要一起输出,样本量小的时候相关系数再高也不能下结论。分组均值表用来解释相关系数——如果高热度组平均评分反而低,说明存在“热门但口碑一般”的现象,这个结论比一个孤零零的相关系数有说服力得多。实际跑下来,这类数据里 Spearman 系数通常在 0.1 到 0.3 之间,属于弱相关,报告里要如实写“弱正相关”,不能夸大成“显著影响”。
4.3 价格分档对比:区间均价与评分差异
价格分析的关键不是算一个全局均价,而是看不同价格档位里的评分表现。如果高价档平均评分并不比中价档高,那“贵就是好”的假设就不成立,这本身就是一个有价值的结论。
def price_band_compare(df: pd.DataFrame) -> pd.DataFrame: result = df.groupby("price_band", observed=True).agg( count=("name", "count"), avg_score=("score", "mean"), avg_comments=("comment_count", "mean"), ).reset_index() # 计算各档评分相对全局均值的偏差,便于横向比较 global_mean = df["score"].mean() result["score_diff"] = (result["avg_score"] - global_mean).round(3) return result.sort_values("avg_score", ascending=False)score_diff这一列是给报告用的,正数表示该价格档评分高于全局平均,负数反之。observed=True在 pandas 分类变量 groupby 时必加,否则会把没出现的档位也列出来,count 为 0 的行混进结果里,图表上就会出现空柱子。这个参数踩过一次坑之后我每次 groupby 分类列都强制加上。
4.4 区域分布可视化:经纬度散点与密度热力
区域分布用两张图配合:一张经纬度散点图看景点位置,一张按区域聚合的密度表看数量分布。散点图用 matplotlib 就够,不需要上 GIS 工具,因为分析目标是看格局不是做地图产品。
import matplotlib.pyplot as plt def plot_geo_distribution(df: pd.DataFrame, out_path: str) -> None: fig, ax = plt.subplots(figsize=(8, 6)) scatter = ax.scatter( df["lng"], df["lat"], c=df["score"], cmap="viridis", s=20, alpha=0.7 ) ax.set_xlabel("经度") ax.set_ylabel("纬度") ax.set_title("景点地理分布(颜色表示评分)") fig.colorbar(scatter, label="评分") fig.tight_layout() fig.savefig(out_path, dpi=150) plt.close(fig)颜色映射用评分而不是评论数,是因为评分在视觉上更容易分辨高低,评论数长尾太严重,颜色会集中在少数几个点上。alpha=0.7让重叠点能看出密度,dpi=150保证报告里插图清晰。区域密度表则直接用前面派生的region字段 groupby 计数,和散点图对照着看,能发现“东多西少”或“南北均衡”这类格局性结论。
5. 避坑与常见问题:这类分析最容易翻车的五个点
5.1 城市名不统一导致聚合结果翻倍
现象:按城市 groupby 后,同一个城市出现两行,一行叫“北京”一行叫“北京市”,景点数量被拆成两份。原因:原始数据来源不同,录入口径不一致,清洗时只做了去空格没做后缀统一。解决:在清洗函数里用正则统一去掉行政后缀,并且加一步断言检查——df["city"].nunique()在清洗前后应该只减不增,如果反而变多,说明替换规则写错了。我一般还会打印清洗前后城市数量对比,肉眼扫一眼有没有异常。
5.2 评分缺失插补把结论带偏
现象:插补后某城市平均评分突然变得很平稳,和原始数据分布对不上。原因:用了全局均值插补,把不同城市之间的评分差异抹平了。解决:按城市分组用中位数插补,城市也缺失时才退回全局中位数。更稳妥的做法是插补前先统计各城市缺失比例,缺失超过 50% 的城市直接标记为“数据不足”,不参与城市排名,避免用插补值撑起一个不可信的结论。
5.3 评论数直接参与计算导致长尾主导
现象:算平均评论数时,一个头部景点就能把城市均值拉高一个数量级,排名完全失真。原因:评论数是典型长尾分布,均值对极端值极其敏感。解决:热度相关分析统一用log1p转换后的值,或者改用中位数。报告里如果要展示原始评论数,用分位数(如 P50、P90)代替均值,并在图注里写明“评论数呈长尾分布,采用对数或分位数呈现”。
5.4 价格字段混入非数值字符导致整列失效
现象:pd.to_numeric之后价格列全是 NaN,后面价格分档全空。原因:原始价格带“元”“起”“免费”等文字,直接转数值全部失败。解决:转换前先用正则剥离非数字字符,errors="coerce"保留而不是抛异常,转换后统计非空比例,低于预期就回头检查原始值样例。我习惯在清洗后打印每列的非空率和前五个值,这一步能拦住大部分字段级问题。
5.5 可视化坐标轴未统一导致区域结论误判
现象:散点图看起来景点集中在东部,但密度表显示东西数量差不多。原因:经纬度范围没对齐,或者散点图用了自动缩放,视觉上放大了局部密集区。解决:固定坐标轴范围到数据实际经纬度边界,并在图上标注参考线;密度结论以聚合表数字为准,图只作辅助。图和表结论不一致时,永远信表,图回去改。
6. 验收与答辩:怎么证明这套分析站得住
验收这套方案,我一般按三步走。第一步查数据链路可复现:从原始文件到最终结论表,一条命令跑通,中间不依赖手工操作。具体做法是把四个模块串成一个入口脚本,跑完后检查输出目录里聚合表、结论表、图表是否齐全,行数和预期是否一致。第二步查结论可追溯:报告里每一个数字都能在聚合表里找到对应行,不能有“拍脑袋写上去”的结论。答辩时最常被问的就是“这个数怎么来的”,能当场打开聚合表指给对方看,比任何解释都管用。第三步查异常处理:故意把某列改成全空、把城市名改成乱码,看脚本是报错退出还是静默产出错误结果。合格的方案应该在关键字段缺失时给出明确警告,而不是悄悄算出一个看似正常的数。
答辩陈述有个技巧:不要按代码顺序讲,按“问题—方法—结论—局限”四段讲。先说要回答哪四个问题,再说每个问题用了什么指标和什么处理,然后给结论,最后主动说局限——比如区域标签只是相对划分、相关性是弱相关不能当因果。主动说局限反而显得可信,评委追问的空间也小了。下面这张验收清单可以直接拿去对照。
| 验收项 | 合格标准 | 检查方式 |
|---|---|---|
| 链路可复现 | 一条命令从原始数据跑到结论表 | 清空输出目录重跑 |
| 清洗可解释 | 每条清洗规则有注释和前后对比 | 抽查城市、价格、评分三列 |
| 结论可追溯 | 报告数字均能在聚合表定位 | 随机抽三个数字反查 |
| 异常有提示 | 关键字段全空时给出警告 | 人为制造缺失重跑 |
| 图表口径一致 | 图与表结论不矛盾 | 对照散点图与密度表 |
从那以后我每次交分析类方案,都会在跑完主流程后强制走一遍“破坏性测试”——把一列清空、把一类值改乱,看脚本会不会骗我。这个习惯帮我拦下过好几次静默错误,也让我在答辩时被问到边界情况能直接答上来。希望这套结构帮到你,拿去改改字段和参数就能用。
本文还有配套的精品资源,点击获取