☰
旅游景点数据分析实战:从数据清洗到热度排名的完整链路
2026/11/2 11:35:21 网站建设 项目流程

简介:这份资源面向具备一定Python基础、希望用真实数据走通完整分析流程的学习者,以去哪儿网国庆景点数据为样本,串联数据清洗、统计分析与可视化呈现,帮助解决从原始表格到业务洞察之间缺乏实战案例的问题。压缩包共7个文件,约79KB,包含5个html可视化页面、1个xlsx数据源和1个py分析脚本,分别对应图表成果展示、结构化原始数据与可复用的数据处理代码。目前已有1385人学习。读者可借助脚本理解缺失值、异常值处理与字段转换思路,对照热力图、柱状图、饼状图等成果掌握各省份景点分布、门票销量排行与星级比例的表达方式,并据此梳理从数据收集、清洗到报告呈现的完整链路,为旅游场景的运营分析与数据驱动决策积累可迁移的实践经验。

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

很多人拿到“旅游景点数据分析”这类资源包,第一反应是翻目录看有没有现成的可视化大屏,结果发现真正值钱的东西藏在数据清洗脚本和字段字典里。我拆过的这份实战资源,核心不是炫酷图表,而是一条从原始景点评论、门票价格、客流统计到指标口径统一的完整链路。它解决的是“数据拿回来一堆,却不知道怎么对齐字段、怎么定义热度、怎么排除刷评”这类落地问题。适合两类人:一是刚接手文旅类数据项目、需要快速搭出分析框架的从业者;二是想拿真实业务场景练手数据分析流程的学习者。资源里包含多份结构化数据表、清洗与指标计算脚本、分析报告模板,格式以 CSV、Python 脚本和 Markdown 说明为主,拿到手就能按章节复现。

2. 数据表结构与字段口径:先把“景点热度”定义清楚

2.1 资源里的四张核心表各管什么

这份资源的数据层不是一张大宽表,而是拆成四张互相引用的表,这个设计本身就值得说。第一张是景点基础表,字段包括景点编号、名称、所属区域、等级、开放时间、门票基准价;第二张是客流日表,按景点编号和日期记录入园人次、瞬时承载量、天气标签;第三张是评论明细表,包含评论编号、景点编号、评分、评论文本、评论时间、用户标识;第四张是价格变动表,记录不同渠道、不同日期的实际售价。四张表通过景点编号关联,评论表还额外带用户标识用于去重。

我一般会先看字段字典再动手,因为这类资源最容易翻车的地方就是同名不同义。比如“客流”在日表里是入园人次,在承载量字段里是瞬时值,直接相加会得到毫无意义的数字。资源附带的字段说明文档里明确标注了每个字段的统计周期和单位,这一步不能跳过。常见做法是先把四张表各抽 5 行出来,人工核对一遍关联键是否唯一、日期格式是否统一、价格字段有没有带货币符号。

2.2 热度指标的计算口径与参数选择

资源里给出的热度指标不是简单的评论数排名,而是加权计算。核心公式在脚本里体现为:热度 = 评论数量归一化值 × 0.4 + 平均评分归一化值 × 0.3 + 近 30 天客流归一化值 × 0.3。三个权重写在配置文件里,可以改。这个口径的好处是把“有人气”和“口碑好”分开看,避免一个刷评景点靠评论量冲上榜首。

参数上要注意两个点。一是归一化方法用的是 min-max,资源脚本里对每个指标单独做,不是全局做,这样不同量纲的指标才能放到一个公式里。二是近 30 天客流的窗口是相对数据最新日期往前推,不是相对今天,这个细节在脚本注释里有写,但很容易被忽略。我建议第一次跑的时候把三个权重都设成 1/3 做对照,看看排名变化是否符合业务直觉,再决定是否调整。

import pandas as pd import numpy as np # 读取四张核心表,日期列统一解析 spots = pd.read_csv("data/spots_base.csv", parse_dates=["open_date"]) flow = pd.read_csv("data/flow_daily.csv", parse_dates=["stat_date"]) reviews = pd.read_csv("data/reviews_detail.csv", parse_dates=["review_time"]) price = pd.read_csv("data/price_change.csv", parse_dates=["price_date"]) # 评论表按景点聚合:评论数与平均分 review_agg = reviews.groupby("spot_id").agg( review_cnt=("review_id", "count"), avg_score=("score", "mean") ).reset_index() # 客流取数据最新日期往前30天 latest_date = flow["stat_date"].max() window_start = latest_date - pd.Timedelta(days=30) flow_recent = flow[flow["stat_date"] >= window_start] flow_agg = flow_recent.groupby("spot_id")["visitor_cnt"].sum().reset_index() # 三表关联后做 min-max 归一化 df = spots.merge(review_agg, on="spot_id", how="left") \ .merge(flow_agg, on="spot_id", how="left") df[["review_cnt", "avg_score", "visitor_cnt"]] = df[ ["review_cnt", "avg_score", "visitor_cnt"] ].fillna(0) for col in ["review_cnt", "avg_score", "visitor_cnt"]: col_min, col_max = df[col].min(), df[col].max() df[col + "_norm"] = (df[col] - col_min) / (col_max - col_min + 1e-9) # 权重从配置读取,这里写死便于演示 w1, w2, w3 = 0.4, 0.3, 0.3 df["heat_score"] = ( df["review_cnt_norm"] * w1 + df["avg_score_norm"] * w2 + df["visitor_cnt_norm"] * w3 ) df.sort_values("heat_score", ascending=False, inplace=True)

这段脚本的逻辑是先聚合再关联,避免在明细层做多对多连接导致数据膨胀。fillna(0)处理的是没有评论或没有客流记录的景点,这类景点热度自然偏低,但不能直接丢弃,否则排名会失真。归一化分母加了1e-9是防止某个指标全部相同导致除零。权重三个参数建议在配置文件里维护,不要写死在脚本里,后面做敏感性分析会方便很多。

3. 评论数据清洗:刷评识别与文本预处理的可执行步骤

3.1 刷评识别的三条规则与阈值

评论表是这类资源里最脏的部分。资源脚本里内置了三条刷评识别规则,我拆开说。第一条是时间聚集规则:同一景点在 10 分钟内出现超过 5 条评论,且这些评论的用户标识互不相同,标记为疑似刷评。第二条是文本重复规则:评论文本去掉标点后完全相同的条数超过 3 条,整组标记。第三条是评分极端规则:同一用户标识在一天内对超过 8 个不同景点给出满分评价,该用户的所有评论降权处理。

阈值不是拍脑袋定的,资源说明里给了调整建议:时间窗口从 10 分钟调到 30 分钟会显著增加误伤,因为节假日真实客流高峰也会出现短时集中评论;文本重复条数从 3 调到 5 会漏掉一部分模板化刷评。我一般先用默认阈值跑一遍,把标记结果抽样 50 条人工看一眼,再决定是否调整。这里没有后悔药,阈值调错会导致后续热度排名整体偏移。

# 规则一:时间聚集检测 reviews_sorted = reviews.sort_values(["spot_id", "review_time"]) reviews_sorted["time_gap"] = reviews_sorted.groupby("spot_id")["review_time"].diff().dt.total_seconds() reviews_sorted["burst_flag"] = ( (reviews_sorted["time_gap"] <= 600) & (reviews_sorted.groupby("spot_id")["time_gap"].transform(lambda s: (s <= 600).sum()) > 5) ) # 规则二:文本重复检测 reviews["text_clean"] = reviews["content"].str.replace(r"[^\w]", "", regex=True) dup_cnt = reviews.groupby(["spot_id", "text_clean"])["review_id"].transform("count") reviews["dup_flag"] = dup_cnt > 3 # 规则三:用户极端评分检测 user_daily = reviews.groupby(["user_id", reviews["review_time"].dt.date]).agg( spot_cnt=("spot_id", "nunique"), full_score_cnt=("score", lambda s: (s == 5).sum()) ).reset_index() extreme_users = user_daily[ (user_daily["spot_cnt"] > 8) & (user_daily["full_score_cnt"] == user_daily["spot_cnt"]) ]["user_id"] reviews["extreme_user_flag"] = reviews["user_id"].isin(extreme_users)

三条规则的输出是三个布尔标记列,资源脚本最后用“任一标记为真即剔除”的保守策略。这个策略偏严,适合对口碑准确性要求高的场景;如果只是做客流趋势分析,可以只剔除时间聚集和文本重复两类,保留极端用户评论但降权。transform的用法是为了在不改变行数的前提下把组内统计值广播回每一行,比groupby后merge更省事。

3.2 文本预处理与关键词抽取的最小流程

清洗完刷评后,评论文本还要做一轮预处理才能用于关键词分析。资源里的流程是:去除非中文字符和标点、按停用词表过滤、用 jieba 分词、保留长度大于等于 2 的词、统计词频。停用词表资源里附带了一份基础版,包含常见虚词和平台水词。关键词抽取没有用复杂的模型,就是词频排序加人工归类,把高频词映射到“交通”“门票”“排队”“景色”“服务”五个维度。

这个最小流程的好处是可解释性强,业务方看得懂。我试过在词频基础上加 TF-IDF,效果提升有限,因为评论语料本身短,区分度不高。参数上唯一要调的是分词后保留词的最小长度,设成 2 会保留“排队”“门票”这类双字词,设成 3 会漏掉大量有效信息。停用词表建议按实际语料补充,资源里的基础版对文旅场景覆盖不够,比如“打卡”“出片”这类词是否保留要看分析目标。

import jieba stopwords = set(open("config/stopwords.txt", encoding="utf-8").read().splitlines()) def extract_keywords(text_series, topn=30): word_freq = {} for text in text_series.dropna(): words = jieba.lcut(text) for w in words: w = w.strip() if len(w) >= 2 and w not in stopwords and not w.isdigit(): word_freq[w] = word_freq.get(w, 0) + 1 return sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:topn] # 只对清洗后的有效评论做关键词抽取 valid_reviews = reviews[~reviews[["burst_flag", "dup_flag", "extreme_user_flag"]].any(axis=1)] keywords = extract_keywords(valid_reviews["content"])

any(axis=1)是三个标记列任一为真就排除,和前面说的保守策略一致。not w.isdigit()过滤纯数字词,避免年份、价格数字混进关键词。词频统计用字典累加而不是Counter,是为了在循环里同时做长度和停用词过滤,少一次遍历。这套流程跑一遍万级评论大概几秒,不需要上分布式。

4. 客流与价格联动分析:把时间序列对齐再谈相关性

4.1 日期对齐与缺失值填充策略

客流日表和价格变动表的日期粒度不一致,前者是每天都有记录,后者只在价格调整时才有行。资源脚本的做法是先按景点和日期生成完整日期骨架,再把价格表左连接到骨架上,用前向填充把最近一次价格带到每一天。这个处理的前提是价格在两次调整之间保持不变,资源说明里确认了这一点。如果实际业务里价格每天浮动,这个策略就不适用,得改成按渠道分别处理。

缺失值填充要分字段看。客流字段缺失通常意味着当天闭园或数据未上报,资源脚本用 0 填充并在报告里单独标注闭园天数,不参与日均客流计算。价格字段缺失用前向填充,但骨架第一天如果就没有价格记录,会保留空值而不是用均值填,因为均值填充会扭曲价格弹性分析。我一般会在填充后统计每个景点的填充比例,超过 30% 的景点单独列出来人工确认,避免黑匣子式处理。

# 生成完整日期骨架 date_range = pd.date_range(flow["stat_date"].min(), flow["stat_date"].max(), freq="D") skeleton = pd.MultiIndex.from_product( [spots["spot_id"].unique(), date_range], names=["spot_id", "stat_date"] ).to_frame(index=False) # 客流左连接骨架,缺失填0并标记 flow_full = skeleton.merge(flow, on=["spot_id", "stat_date"], how="left") flow_full["is_closed"] = flow_full["visitor_cnt"].isna() flow_full["visitor_cnt"] = flow_full["visitor_cnt"].fillna(0) # 价格前向填充 price_full = skeleton.merge(price, on=["spot_id", "price_date"], how="left") price_full = price_full.sort_values(["spot_id", "stat_date"]) price_full["actual_price"] = price_full.groupby("spot_id")["actual_price"].ffill()

MultiIndex.from_product生成的是笛卡尔积骨架,景点数乘日期数,数据量不大时没问题。is_closed标记列很关键,后面算日均客流时要排除闭园日,否则分母偏大会拉低均值。ffill是按景点分组后前向填充,不能全局填充,否则会把 A 景点的价格带到 B 景点。价格骨架第一天为空的情况在脚本里没有额外处理,我建议加一步检查:price_full.groupby("spot_id")["actual_price"].apply(lambda s: s.isna().sum()),看哪些景点开头就缺价。

4.2 价格弹性与客流峰值的交叉分析

对齐之后可以做价格与客流的联动分析。资源里的做法不是直接算相关系数,而是先按价格分箱,再看每个价格区间内的日均客流,这样能看出非线性关系。分箱用等频分箱而不是等宽分箱,因为价格分布通常右偏,等宽分箱会导致高价区间样本过少。分箱数设成 5,资源脚本里写的是pd.qcut,参数duplicates="drop"防止价格重复值导致分箱失败。

客流峰值分析用的是滚动 7 天均值加同比,同比基期是去年同月同日,资源数据覆盖两年所以能做。同比计算要注意日期对齐,去年是闰年的话 2 月 29 日要特殊处理,脚本里用DateOffset(years=1)自动处理了。我一般会把价格分箱结果和客流峰值结果放在同一张图上对照看,但资源里只给了两张独立表,合并展示需要自己加一步。

# 价格等频分箱 price_full["price_bin"] = pd.qcut( price_full["actual_price"], q=5, duplicates="drop" ) # 每个价格区间的日均客流(排除闭园日) elastic = price_full[~price_full["is_closed"]].groupby( ["spot_id", "price_bin"], observed=True )["visitor_cnt"].mean().reset_index() # 客流7天滚动均值与同比 flow_full = flow_full.sort_values(["spot_id", "stat_date"]) flow_full["visitor_ma7"] = flow_full.groupby("spot_id")["visitor_cnt"].transform( lambda s: s.rolling(7, min_periods=1).mean() ) flow_full["visitor_yoy"] = flow_full.groupby("spot_id")["visitor_cnt"].transform( lambda s: s / s.shift(365) - 1 )

observed=True在分箱后分组时加上,避免生成大量空组合。min_periods=1让滚动均值在序列开头也能出值,不至于前 6 天全是空。同比用shift(365)是简化处理,严格按日历年对齐应该用日期索引重采样,但资源数据日期连续,shift(365)够用。如果数据有缺日,shift(365)会错位,这时候要改用reindex补齐日期后再算。

5. 避坑与排查:这类分析包最容易翻车的五个地方

5.1 关联键类型不一致导致静默丢数

现象是四张表关联后行数比预期少了一大截,但脚本不报错。原因是景点编号在基础表里是字符串,在客流表里被读成了整数,merge时类型不匹配,匹配不上的行被静默丢弃。解决方法是读表时统一指定dtype={"spot_id": str},或者在关联前对两边都做astype(str)。我现在的习惯是关联后立刻打印左右表行数和结果行数,三者对不上就停下来查键类型。

5.2 日期解析格式混用导致时间窗口错位

现象是近 30 天客流窗口算出来的结果明显偏大或偏小。原因是部分日期列是YYYY-MM-DD格式,部分是YYYY/MM/DD,parse_dates对混合格式解析失败后把整列变成了字符串,后续日期比较变成了字符串比较。解决方法是在read_csv里加dayfirst=False并显式指定date_format,或者读入后统一用pd.to_datetime(..., format="mixed")转换。转换完抽查dtype是不是datetime64,不是就说明没转成功。

5.3 归一化在过滤后重算导致排名漂移

现象是剔除刷评后重新算热度,排名和剔除前差异巨大,但评论数只少了百分之几。原因是归一化的 min 和 max 是在过滤后的数据上重新算的,分母变了,所有景点的归一化值都跟着变。解决方法是归一化的参考范围固定在清洗前的全量数据上算好,过滤只影响参与排名的行,不影响归一化参数。这个坑很隐蔽,因为每一步单独看都合理,合起来就偏了。

5.4 前向填充跨景点串价

现象是某个景点的价格曲线出现了另一个景点的价格台阶。原因是ffill没有按景点分组,直接对整列做了前向填充,排序后相邻行属于不同景点时就把上一个景点的价格带过来了。解决方法是groupby("spot_id")之后再ffill,并且填充前确认排序键是["spot_id", "stat_date"]。填充后按景点抽查价格序列,看有没有突变点。

5.5 同比计算遇到缺失日期直接错位

现象是同比列出现大量异常值,比如同比涨了十几倍。原因是shift(365)假设日期连续无缺失,实际数据里某些景点有整段日期缺失,shift后对到了错误的日期。解决方法是先把日期骨架补齐再算同比,或者改用按日期索引的reindex加pct_change。资源脚本默认数据完整,没有做这层防护,实际用的时候要自己补。

6. 从跑通到可信:用敏感性分析验证你的热度排名

资源包跑通只是第一步,排名能不能拿去汇报,取决于它对参数变动的敏感程度。我一般会做三组敏感性测试。第一组是权重扰动:把三个权重在 0.2 到 0.5 之间做网格组合,看排名前 10 的景点是否稳定。如果某个景点在权重微调后就掉出前 10,说明它的热度依赖单一指标,汇报时要单独说明。第二组是刷评阈值扰动:时间窗口从 5 分钟调到 30 分钟,文本重复条数从 3 调到 5,看剔除比例和排名变化,剔除比例超过 15% 就要警惕误伤。第三组是归一化方法替换:min-max 换成 z-score,看排名相关性,相关系数低于 0.8 说明排名对归一化方法敏感,结论要谨慎。

from itertools import product # 权重网格敏感性测试 results = [] for w1, w2, w3 in product([0.2, 0.3, 0.4, 0.5], repeat=3): if abs(w1 + w2 + w3 - 1.0) > 1e-6: continue score = (df["review_cnt_norm"] * w1 + df["avg_score_norm"] * w2 + df["visitor_cnt_norm"] * w3) top10 = df.assign(score=score).nlargest(10, "score")["spot_id"].tolist() results.append({"w": (w1, w2, w3), "top10": top10}) # 统计每个景点进入前10的频率 from collections import Counter freq = Counter() for r in results: freq.update(r["top10"]) stable_spots = [k for k, v in freq.items() if v >= len(results) * 0.8]

这段代码的核心是product生成权重组合后过滤掉和不为 1 的组合,然后统计每个景点进入前 10 的频率。频率大于等于 80% 组合数的景点视为稳定,可以放心汇报;频率在 50% 到 80% 之间的要标注“排名受权重影响”;低于 50% 的建议不放进主结论。nlargest比sort_values加head快,数据量大时差别明显。

还有一个我踩过的坑:敏感性分析用的df必须是清洗后的同一份数据,不能每次循环重新读表重新清洗,否则刷评剔除结果可能因为随机性(如果有采样步骤)而变化,导致敏感性测试本身不可比。从那以后我每次做这类分析,都先把清洗结果落盘成中间文件,敏感性测试只读中间文件,保证输入完全一致。希望这份拆解能帮你在拿到类似资源包时,少走几步弯路,先把字段口径和清洗规则吃透,再谈排名和结论。

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

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

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

立即咨询