旅游景点数据分析实战:从脏数据到可复现分析流水线的完整指南

发布时间:2026/11/2 15:43:22
旅游景点数据分析实战:从脏数据到可复现分析流水线的完整指南

简介:面向数据分析初学者与旅游行业从业者的实战型项目资料,围绕去哪儿网国庆景点数据展开,覆盖从数据清洗、统计分析到可视化呈现的完整流程,帮助读者理解如何用数据洞察游客分布、门票销量与景区星级偏好,并支撑定价、营销等业务决策。压缩包共7个文件,约79KB,以5个html可视化页面、1个xlsx原始数据表和1个py分析脚本为主,html用于呈现热力图、柱状图与饼状图结果,xlsx存放结构化景点数据,py脚本承担预处理与分析计算。已有1385人学习下载。读者可据此复现各省份景点分布热力图、门票销售额TOP20柱状图、景区星级比例饼状图等图表,并参考脚本中的缺失值、异常值处理思路,快速搭建一套可复用的旅游数据分析小项目。

1. 旅游景点数据分析实战:一份能直接跑通的分析包到底长什么样

拿到一份标着“旅游景点数据分析实战”的资源包,多数人第一反应是翻目录看有没有现成结论,结果翻完发现全是脚本、字段说明和几张半成品图表,心里就凉了半截。我一开始也这样,直到把它当成一套可复用的分析骨架来拆,才发现价值不在结论,而在从原始字段到可解释指标的那条链路。这份资源面向的是想用真实业务口径练手的数据分析从业者,尤其是手里有景点客流、票务、评价类数据却不知道怎么串起来的人。它解决的不是“教你画图”,而是“给你一套能改参数、能换数据、能复现的景点分析流程”。数据分析这四个字在这里不是泛泛的口号,而是落到字段清洗、指标定义、分组对比和异常排查上的具体动作。适合谁?适合已经会写基础 pandas、但一遇到真实脏数据就卡住的新手,也适合想快速搭一套景点分析模板的熟手。

2. 拆开资源包:字段口径与指标设计先立住

2.1 先看清数据长什么样,再谈分析

景点类数据最常见的形态是“一行一条记录”,但记录粒度差别很大:有的按天按景点汇总客流,有的按票种按订单明细,有的干脆是点评文本加评分。资源包里的字段说明通常不会写得太细,我一般先做一件事——把每个字段的取值分布和空值率拉出来,判断它到底是维度字段还是度量字段。维度字段决定分组方式,度量字段决定能算什么指标。这一步不做,后面所有图表都是空中楼阁。

import pandas as pd df = pd.read_csv("scenic_raw.csv", encoding="utf-8") # 先看整体规模和字段类型 print(df.shape) print(df.dtypes) # 空值率与唯一值数量,快速区分维度/度量 profile = pd.DataFrame({ "null_rate": df.isna().mean().round(4), "nunique": df.nunique(), "sample": df.iloc[0] }) print(profile.sort_values("null_rate", ascending=False))

这段代码的逻辑是先建立“数据体检表”:null_rate告诉你哪些字段缺失严重、能不能用;nunique帮你判断字段是低基数维度(如景点名称、票种)还是高基数标识(如订单号)。参数上,encoding要按实际文件改,遇到乱码先试gbk;sample取第一行只是给人看的,真正判断要靠分布。常见翻车点是直接把订单号当维度去 groupby,结果每个组只有一条记录,图表全是噪点。

2.2 指标不是拍脑袋,先对齐业务口径

景点分析里最容易吵架的三个指标是客流量、客单价和好评率。客流量到底算入园人次还是订单数,客单价是含退票还是不含,好评率的分母是全部评价还是有效评价——这些口径不先定死,后面所有对比都不可信。我的做法是在脚本顶部集中写一份口径字典,所有计算都引用它,改口径只改一处。

# 口径配置集中管理,避免散落在各处 METRIC_CONFIG = { "visitor_col": "ticket_count", # 入园人次字段 "order_col": "order_id", # 订单唯一标识 "amount_col": "paid_amount", # 实付金额 "refund_flag": "is_refund", # 退票标记 1=退 "rating_col": "score", # 评分字段 "valid_rating_min": 1, # 有效评分下限 } def calc_core_metrics(df, cfg): valid = df[df[cfg["refund_flag"]] == 0] visitors = valid[cfg["visitor_col"]].sum() orders = valid[cfg["order_col"]].nunique() revenue = valid[cfg["amount_col"]].sum() rated = valid[valid[cfg["rating_col"]] >= cfg["valid_rating_min"]] good_rate = (rated[cfg["rating_col"]] >= 4).mean() return { "visitors": visitors, "orders": orders, "avg_ticket": revenue / visitors if visitors else 0, "good_rate": round(good_rate, 4), }

逻辑说明:先剔除退票记录再算指标,避免退款金额污染收入口径;avg_ticket用人次做分母而不是订单数,这是景点场景和电商场景的关键差别。参数上,valid_rating_min控制有效评分下限,有的数据用 0 分表示未评价,不排除会把好评率拉低。这里没有写死任何景点名或平台名,换一份同结构数据直接能跑。

2.3 分组维度怎么选,决定图表有没有信息量

景点数据的分组维度通常有三类:时间(日/周/节假日)、空间(景区/区域/入口)、属性(票种/渠道/客群)。资源包里如果只给了一个date字段,别急着按天画折线,先看时间跨度。跨度小于三个月,按天画出来全是锯齿;跨度超过一年,按天画又太密。我一般先算时间跨度再决定粒度,这一步用几行代码就能判断。

df["date"] = pd.to_datetime(df["date"]) span_days = (df["date"].max() - df["date"].min()).days # 按跨度自动选粒度:短跨度按天,长跨度按月 if span_days <= 90: df["period"] = df["date"].dt.date elif span_days <= 730: df["period"] = df["date"].dt.to_period("M").astype(str) else: df["period"] = df["date"].dt.to_period("Q").astype(str) trend = df.groupby("period").agg( visitors=("ticket_count", "sum"), orders=("order_id", "nunique") ).reset_index() print(trend.head())

这段的关键是span_days这个判断阈值,90 天和 730 天不是金科玉律,但能避免“按天画一年”这种典型误用。to_period("M")转成月份字符串是为了后续排序稳定,直接对 datetime 做 groupby 有时会遇到时区或格式问题。分组维度选错,后面再漂亮的图也只是把噪声放大。

3. 从清洗到可视化:一条能复现的分析流水线

3.1 脏数据的四类典型问题与处理顺序

景点数据的脏,通常集中在四类:日期格式不统一、金额带货币符号、景点名称有别名、重复订单。处理顺序有讲究——先统一类型,再处理文本,最后去重。顺序反了,去重时字段还没标准化,会把同一景点当成两个。我习惯把清洗写成独立函数,每步打印前后行数,方便回溯。

import re def clean_scenic_data(df): before = len(df) # 1. 日期统一 df["date"] = pd.to_datetime(df["date"], errors="coerce") df = df.dropna(subset=["date"]) # 2. 金额去符号转数值 df["paid_amount"] = ( df["paid_amount"].astype(str) .str.replace(r"[^\d.]", "", regex=True) .replace("", "0") .astype(float) ) # 3. 景点名称去空格、统一全半角 df["scenic_name"] = df["scenic_name"].astype(str).str.strip() # 4. 按订单号去重,保留最新记录 df = df.sort_values("date").drop_duplicates( subset=["order_id"], keep="last" ) print(f"清洗前 {before} 行,清洗后 {len(df)} 行") return df

逻辑说明:errors="coerce"把无法解析的日期变成 NaT 再删掉,比直接报错更稳;金额正则[^\d.]会保留数字和小数点,但要注意千分位逗号会被去掉,这是符合预期的。去重保留last是因为后到的记录往往修正过。参数上,subset用订单号而不是整行,整行去重对字段顺序敏感,容易漏。这一步跑完如果行数掉得离谱,先别继续,回头查日期解析失败率。

3.2 客流趋势与同比环比怎么算才不误导

趋势图谁都会画,但景点场景有个坑:节假日效应会让同比失真。比如今年春节在二月、去年在一月,直接按月同比会把增长算错。稳妥做法是同时给“按月趋势”和“按节假日标记的趋势”,后者需要一张节假日对照表。资源包里如果没有,我一般用简化的周末/非周末标记先顶上,至少能看出工作日和周末的差异。

df["is_weekend"] = df["date"].dt.weekday >= 5 # 按月汇总并计算环比 monthly = df.groupby(df["date"].dt.to_period("M")).agg( visitors=("ticket_count", "sum") ).reset_index() monthly["mom"] = monthly["visitors"].pct_change().round(4) # 周末 vs 工作日对比 week_split = df.groupby("is_weekend")["ticket_count"].mean().round(2) print(monthly.tail(6)) print(week_split)

pct_change()算环比,第一个月会是 NaN,这是正常的,别用 0 填充,否则图表会误导。is_weekend用weekday >= 5判断,周一到周五是 0 到 4。如果数据里有明确的节假日字段,优先用它,周末标记只是退而求其次。这里要提醒一句,环比在数据首月缺失时,画图要么从第二个月开始,要么在图注里说明,别让读者以为首月零增长。

3.3 可视化不是堆图表,先定“一张图回答一个问题”

资源包里的图表脚本往往一次画好几张,我建议拆开用,每张图只回答一个问题:趋势图回答“整体在涨还是跌”,箱线图回答“不同景点离散程度差多少”,热力图回答“星期几和哪个入口最忙”。以景点间对比为例,用箱线图比柱状图更能看出异常值。

import matplotlib.pyplot as plt top_scenics = df["scenic_name"].value_counts().head(8).index subset = df[df["scenic_name"].isin(top_scenics)] fig, ax = plt.subplots(figsize=(10, 5)) subset.boxplot(column="ticket_count", by="scenic_name", ax=ax, rot=45) ax.set_title("Top 8 景点单日客流分布") ax.set_ylabel("入园人次") plt.suptitle("") plt.tight_layout() plt.savefig("scenic_box.png", dpi=150)

value_counts().head(8)先筛出记录最多的景点,避免小样本景点把箱线图拉花;by指定分组列,rot=45防止景点名重叠。plt.suptitle("")是去掉 pandas 默认加的冗余标题,这个细节很多人不知道,图出来总带一行莫名其妙的文字。保存用dpi=150兼顾清晰度和文件大小。图表不是越多越好,一张图能说清一个对比就够了。

4. 避坑与排查:五个真实会卡住你的地方

4.1 日期解析静默失败,趋势图直接少一半

现象:趋势图时间轴只到某个月就断了,后面数据凭空消失。原因:pd.to_datetime遇到混合格式(如2024/1/5和2024-01-05混用)时,部分行变成 NaT,被dropna悄悄删掉。解决:解析后先算失败率,超过 5% 就停下来看原始值,必要时用format="mixed"或分格式解析。

raw_dates = df["date"].copy() parsed = pd.to_datetime(raw_dates, errors="coerce") fail_rate = parsed.isna().mean() print(f"日期解析失败率: {fail_rate:.2%}") if fail_rate > 0.05: print(raw_dates[parsed.isna()].head(10))

4.2 金额字段混入“免费”字样,求和直接报错

现象:paid_amount求和时报TypeError或结果异常大。原因:字段里混了“免费”“-”这类文本,astype(float)直接崩,或者被错误转成 NaN 后求和忽略。解决:先做正则清洗再转数值,转换后用isna().sum()确认没有意外缺失,别用fillna(0)掩盖问题。

4.3 景点别名导致分组翻倍

现象:同一个景点在分组结果里出现两次,比如“某湖景区”和“某湖景区(东门)”。原因:名称字段没有标准化,入口信息混进了景点名。解决:建立别名映射表,用replace统一,映射表单独存一个字典,别在代码里散落 if-else。

ALIAS_MAP = { "某湖景区(东门)": "某湖景区", "某湖景区(西门)": "某湖景区", } df["scenic_name"] = df["scenic_name"].replace(ALIAS_MAP)

4.4 好评率分母用错,结论完全反过来

现象:某景点好评率算出来只有 60%,但人工看评价明明大多是好評。原因:把未评价记录(评分为 0 或空)算进了分母。解决:先过滤有效评分再算比例,有效下限写进口径配置,别在多个脚本里各写各的。

4.5 同比环比在首月出现 inf 或异常值

现象:环比列出现inf或极大值。原因:上月值为 0 时做除法。解决:计算前把 0 替换为 NaN,或者用pct_change(fill_method=None)并单独标注首月,图表上对缺失月份留白而不是画成 0。

提示:这五条里,日期解析和金额清洗是最容易“静默出错”的,不会报错但结果全错,建议每次换数据都先跑一遍体检代码。

5. 进阶用法:把一次性分析变成可复用模板

走到这一步,脚本能跑通、图能出来,但离“下次换份数据还能用”还差一层。我的习惯是把整条链路收成一个配置驱动的入口:数据路径、字段映射、口径参数全放一个字典,主函数只负责按顺序调用清洗、指标、分组、出图。这样下次拿到新景点数据,改配置就行,不用翻脚本找硬编码。

def run_pipeline(config): df = pd.read_csv(config["path"], encoding=config.get("encoding", "utf-8")) df = clean_scenic_data(df) metrics = calc_core_metrics(df, config["metric"]) trend = build_trend(df, config["time_granularity"]) save_charts(trend, config["output_dir"]) return {"metrics": metrics, "rows": len(df)} CONFIG = { "path": "scenic_raw.csv", "encoding": "utf-8", "metric": METRIC_CONFIG, "time_granularity": "auto", "output_dir": "./out", } result = run_pipeline(CONFIG) print(result)

参数说明:time_granularity设成auto时走前面按跨度判断的逻辑,也可以强制day或month;output_dir统一管理图片输出,避免散落在当前目录。这个入口的价值在于可验证——每次跑完打印行数和核心指标,和上次对比,行数突变或指标跳变就是信号。

验证方法我一般用“双跑对比”:同一份数据跑两次,结果必须完全一致;再拿一份手工算过的小样本(比如 20 行)跑一遍,核对客流量和客单价是否对得上。小样本核对是最便宜的黑匣子检测,比盯着大图看半天靠谱。

验证项做法通过标准
可复现性同数据跑两次指标完全一致
口径正确20 行手工样本客流量/客单价对得上
清洗完整性对比清洗前后行数掉行有合理解释
图表可读换人看一张图能说出图在回答什么

血泪经验是,我曾经因为没做小样本核对,把一个“退票未剔除”的口径错误带进了汇报,图很漂亮但数字全偏。从那以后我每次换数据源,都强制先跑一遍 20 行手工样本核对,再跑全量。这个习惯比任何高级模型都值钱。希望帮到你。

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