☰
Python旅游推荐数据分析可视化:从CSV到交互看板实战
2026/10/18 19:08:10 网站建设 项目流程

简介:这是一套面向计算机相关专业毕设学生与Python项目实战学习者的旅游推荐数据分析可视化系统,采用python+Django+mysql+协同过滤算法构建,可直接作为毕业设计、课程设计或期末大作业使用。系统分为用户端与管理员端:用户可完成登录注册、个人信息管理、景区浏览、评分收藏及个性化推荐;管理员可对景区信息、景区类型分类及用户资料进行增删改查与行为记录管理,功能链路完整。压缩包共422个文件,约9.36MB,以174个js、59个css、40个json、27个py、18个html及1个sql数据库脚本为主,另含图片、字体与说明文档,前端资源与后端逻辑分层清晰。项目经过严格调试,确保可运行,并附带数据库与部署说明,便于快速搭建环境、理解协同过滤推荐流程与Django项目结构。目前已有135人学习下载,适合需要完整源码参考与实战练习的读者。

1. 旅游推荐数据分析可视化:从一份 CSV 到能讲故事的看板

手里拿到一份旅游景点数据,字段无非是景点名称、城市、评分、热度、门票价格、评论数这些,看起来平平无奇。但真正做过的人都知道,把这份数据变成一张能回答“哪个城市值得去”“什么季节人少价低”“高评分景点是不是一定贵”的看板,中间要跨过清洗、聚合、推荐打分、可视化四道坎。这个 python 项目实战之旅游推荐数据分析可视化,核心就是用 pandas 做数据加工、用推荐算法给景点排个序、再用可视化把结论摆出来,最后打包成能跑起来的工程。它适合两类人:一类是刚学完 pandas 和 matplotlib、想找个完整项目练手的;另一类是想把数据分析结果做成可交付看板、给非技术同事看的。下面我按自己实际搭这套流程的顺序,把每一步的参数、坑和判断标准讲清楚。

2. 数据清洗与字段设计:脏数据不处理,后面全是玄学

旅游数据最大的特点是来源杂。爬来的、导出的、手工整理的混在一起,同一个城市可能写成“杭州”“杭州市”“浙江杭州”,评分列里混着“4.5分”“4.5”“暂无评分”。如果不在第一步统一,后面 groupby 出来的结果会让你怀疑人生。我一般会先做字段盘点,再定清洗规则,最后落成一份干净的宽表。

2.1 先看清字段类型和缺失分布

拿到数据别急着 dropna,先跑一遍 info 和缺失率,心里有数再动手。下面这段是标准开场:

import pandas as pd import numpy as np # 读入原始数据,注意编码,旅游数据常见 utf-8 和 gbk 两种 df = pd.read_csv("travel_raw.csv", encoding="utf-8") # 看字段类型、非空数量 print(df.info()) # 缺失率排序,优先处理缺失高的列 missing = df.isnull().mean().sort_values(ascending=False) print(missing[missing > 0]) # 看评分列到底长什么样,决定怎么转数值 print(df["score"].astype(str).str.strip().value_counts().head(20))

逻辑说明:info()告诉你哪些列是 object、哪些是 float,object 类型的数值列基本都要转。缺失率排序是为了决定策略——缺失超过 60% 的列直接丢,30% 到 60% 的考虑用中位数或众数填,低于 30% 的按业务填。评分列先转成字符串再看取值分布,是因为直接astype(float)遇到“暂无评分”会整列报错,先看清有哪些异常值再写替换规则。

参数说明:encoding一定要试,读出来乱码就换 gbk 或 utf-8-sig。value_counts()后面加head(20)是防止取值太多刷屏,旅游数据里评分通常就几种写法,看前 20 个足够。

2.2 城市名和评分列的标准化

城市名不统一是旅游数据的老毛病。我的做法是建一张映射表,而不是写一堆 if-else。评分列则用正则把数字抠出来。

# 城市名标准化:去掉“市”“省”后缀,统一到标准城市名 city_map = { "杭州市": "杭州", "浙江杭州": "杭州", "苏州市": "苏州", "江苏苏州": "苏州", "成都市": "成都", "四川成都": "成都", } df["city"] = df["city"].astype(str).str.strip() df["city"] = df["city"].replace(city_map) # 兜底:去掉末尾的“市”字 df["city"] = df["city"].str.replace(r"市$", "", regex=True) # 评分标准化:抽出数字,无法解析的置为 NaN df["score"] = pd.to_numeric( df["score"].astype(str).str.extract(r"(\d+\.?\d*)")[0], errors="coerce" ) # 用中位数填充缺失评分,比均值抗极端值 df["score"] = df["score"].fillna(df["score"].median()) # 门票价格同样处理,注意“免费”要转成 0 df["price"] = df["price"].astype(str).str.replace("免费", "0") df["price"] = pd.to_numeric( df["price"].str.extract(r"(\d+\.?\d*)")[0], errors="coerce" ).fillna(0)

逻辑说明:replace(city_map)是精确替换,适合已知的几种写法;str.replace(r"市$", "")是正则兜底,处理映射表没覆盖到的。评分用extract抽数字而不是replace去字符,是因为评分里可能混着“分”“/5”等多种后缀,正则更稳。errors="coerce"让无法解析的值变成 NaN 而不是抛异常,这是批量清洗的关键参数。门票价格里“免费”必须先转成 0 再抽数字,否则会被当成缺失。

参数说明:fillna(median())用中位数而不是均值,是因为旅游评分常有少量极低分拉偏均值。价格缺失填 0 要谨慎,如果数据里缺失代表“未知”而非“免费”,填 0 会低估均价,这种情况我一般单独标记一列price_missing。

2.3 派生字段:热度分和性价比分

原始字段往往不够用,推荐和可视化都需要派生指标。热度分把评论数和评分揉在一起,性价比分把评分和价格揉在一起。

# 热度分:评论数取对数后归一化,再和评分加权 df["log_comments"] = np.log1p(df["comments"]) df["heat"] = ( (df["log_comments"] - df["log_comments"].min()) / (df["log_comments"].max() - df["log_comments"].min()) ) * 0.6 + (df["score"] / 5) * 0.4 # 性价比分:评分高且价格低得分高,价格加 1 防止除零 df["value_score"] = df["score"] / np.log1p(df["price"] + 1) # 保存清洗结果,后续步骤直接读这份 df.to_csv("travel_clean.csv", index=False, encoding="utf-8-sig")

逻辑说明:评论数跨度可能从几十到几十万,直接归一化会被极端值压扁,取log1p后再归一化分布更均匀。热度分给评论数 0.6 权重、评分 0.4,是因为评论数更能反映真实到访量。性价比分用score / log(price+1),价格取对数是为了让几十块和几百块的差距不被过度放大,加 1 是防止价格为 0 时除零。

参数说明:权重 0.6 和 0.4 不是固定的,如果数据里评分区分度低,可以把评分权重降到 0.3。log1p等价于log(1+x),专门处理含 0 的数据。保存时用utf-8-sig是为了 Excel 打开不乱码,这是血泪经验。

3. 推荐逻辑落地:不用协同过滤也能做出可解释的推荐

很多人一听推荐就想到协同过滤,但旅游场景下用户行为数据往往拿不到,硬上协同过滤只会得到一堆无法解释的结果。我的做法是基于内容的加权打分,规则透明、可调、可解释,给非技术同事看也能讲明白。这一章把打分函数、排序、Top-N 输出讲透。

3.1 加权打分函数的设计

推荐的核心是一个打分函数,输入一个景点行,输出一个分数。权重放在配置里,方便调。

# 推荐权重配置,集中管理方便调参 WEIGHTS = { "score": 0.35, # 评分权重 "heat": 0.30, # 热度权重 "value_score": 0.20, # 性价比权重 "price": -0.15, # 价格权重,负号表示越贵分越低 } def normalize(series): """min-max 归一化,处理全等列的除零""" rng = series.max() - series.min() if rng == 0: return series * 0 return (series - series.min()) / rng def recommend_score(df, weights=WEIGHTS): """对每个景点计算加权推荐分""" score = pd.Series(0.0, index=df.index) for col, w in weights.items(): score += normalize(df[col]) * w return score df["rec_score"] = recommend_score(df)

逻辑说明:normalize里判断rng == 0是为了防止某一列所有值相同时除零,返回全 0 而不是报错。权重用字典管理,改权重不用动函数体。价格权重是负数,因为价格越高推荐分应该越低,归一化后乘负权重即可。pd.Series(0.0, index=df.index)初始化保证索引对齐,避免后续相加时错位。

参数说明:权重之和不必等于 1,因为归一化后每列都在 0 到 1 之间,权重只决定相对重要性。如果想让价格影响更大,把 -0.15 改成 -0.25,同时把评分权重降到 0.30 保持平衡。归一化用 min-max 而不是 z-score,是因为推荐分需要落在固定区间方便比较。

3.2 按城市和预算做条件推荐

实际用的时候,用户往往带着条件来:想去某个城市、预算有限、要高分。这时候在打分基础上加过滤条件。

def top_recommend(df, city=None, max_price=None, min_score=None, top_n=10): """条件筛选 + 推荐分排序,返回 Top-N""" sub = df.copy() if city: sub = sub[sub["city"] == city] if max_price is not None: sub = sub[sub["price"] <= max_price] if min_score is not None: sub = sub[sub["score"] >= min_score] # 按推荐分降序,取前 N sub = sub.sort_values("rec_score", ascending=False).head(top_n) return sub[["name", "city", "score", "price", "heat", "rec_score"]] # 示例:杭州预算 200 以内、评分 4 分以上的前 10 个 result = top_recommend(df, city="杭州", max_price=200, min_score=4.0, top_n=10) print(result.to_string(index=False))

逻辑说明:先 copy 再筛选,避免修改原 DataFrame。三个条件都是可选的,用is not None判断,这样同一个函数能应对“只看城市”“只看预算”“全都要”多种场景。排序后head(top_n)取前 N,最后只返回展示需要的列,减少输出噪音。

参数说明:max_price用<=而不是<,因为预算通常包含边界。min_score同理用>=。top_n默认 10,看板场景下一般展示 10 到 20 条,太多用户看不完。如果筛选后结果为空,函数返回空 DataFrame,调用方要判断一下再展示,否则看板会出现空白区域。

3.3 城市维度的聚合推荐

除了单景点推荐,还常需要“哪个城市综合最值得去”。这就要按城市聚合,再对城市打分。

# 按城市聚合:平均评分、平均价格、景点数、总热度 city_stats = df.groupby("city").agg( avg_score=("score", "mean"), avg_price=("price", "mean"), spot_count=("name", "count"), total_heat=("heat", "sum"), ).reset_index() # 城市推荐分:评分和热度为主,景点数作为丰富度加分 city_stats["city_score"] = ( normalize(city_stats["avg_score"]) * 0.4 + normalize(city_stats["total_heat"]) * 0.4 + normalize(city_stats["spot_count"]) * 0.2 ) city_rank = city_stats.sort_values("city_score", ascending=False) print(city_rank.head(10).to_string(index=False))

逻辑说明:groupby后agg一次算出四个指标,比多次 groupby 高效。城市推荐分里评分和热度各占 0.4,景点数占 0.2,是因为景点多的城市选择余地大,但太多也可能意味着质量参差,所以权重给低一些。reset_index()把 city 从索引变回列,方便后续操作和导出。

参数说明:agg里用元组("score", "mean")指定列和聚合函数,这是 pandas 的标准写法。如果某城市只有一个景点,avg_score就是该景点评分,样本太少时结论不稳,可以在展示时加一列spot_count让用户自己判断。城市推荐分同样用 min-max 归一化,保证不同量纲的指标能相加。

4. 可视化看板:让图表替你说结论

数据分析的终点是让人看懂。可视化不是把数据画出来就完事,而是要回答具体问题:哪些城市评分高、价格和评分什么关系、热度分布如何。这一章讲三类核心图表和它们的参数调优。

4.1 城市评分与价格的对比图

最常被问的就是“哪个城市好玩又不贵”。用横向条形图对比城市平均评分,用颜色映射平均价格,一张图回答两个问题。

import matplotlib.pyplot as plt import matplotlib # 中文字体设置,否则中文显示成方块 matplotlib.rcParams["font.sans-serif"] = ["SimHei"] matplotlib.rcParams["axes.unicode_minus"] = False # 取评分前 15 的城市 top_city = city_rank.head(15).sort_values("avg_score") fig, ax = plt.subplots(figsize=(10, 8)) # 颜色映射价格,cmap 用 RdYlGn_r 表示价格越高越红 sc = ax.barh( top_city["city"], top_city["avg_score"], color=plt.cm.RdYlGn_r(normalize(top_city["avg_price"])) ) ax.set_xlabel("平均评分") ax.set_title("城市平均评分与价格分布(颜色越红价格越高)") # 加颜色条说明 sm = plt.cm.ScalarMappable(cmap="RdYlGn_r", norm=plt.Normalize(top_city["avg_price"].min(), top_city["avg_price"].max())) plt.colorbar(sm, ax=ax, label="平均价格") plt.tight_layout() plt.savefig("city_score_price.png", dpi=150)

逻辑说明:barh画横向条形图,城市名在 y 轴更好读。颜色用RdYlGn_r反向色带,价格低是绿色、高是红色,符合直觉。sort_values("avg_score")让条形从下到上递增,视觉上更整齐。ScalarMappable是给颜色条用的,因为条形图本身不直接生成 colorbar。

参数说明:figsize=(10, 8)是 15 个城市的合适尺寸,城市多了要加高。dpi=150保证导出图片清晰,看板用足够。字体必须设SimHei或系统里有的中文字体,否则中文全是方块,这是新手最常见的翻车点。tight_layout()防止标签被裁掉。

4.2 评分与价格的散点图

想看评分和价格到底有没有关系,散点图最直接。再加一条趋势线,结论一目了然。

fig, ax = plt.subplots(figsize=(10, 6)) # 散点,透明度 0.5 防止重叠点看不清 ax.scatter(df["price"], df["score"], alpha=0.5, s=20, c="steelblue") # 拟合一条一次趋势线 z = np.polyfit(df["price"], df["score"], 1) p = np.poly1d(z) x_line = np.linspace(df["price"].min(), df["price"].max(), 100) ax.plot(x_line, p(x_line), "r--", label=f"趋势线 斜率={z[0]:.4f}") ax.set_xlabel("门票价格") ax.set_ylabel("评分") ax.set_title("景点评分与价格关系") ax.legend() plt.tight_layout() plt.savefig("score_price_scatter.png", dpi=150)

逻辑说明:alpha=0.5让重叠的点颜色加深,能看出密度。s=20控制点大小,太大糊成一片。np.polyfit做一次多项式拟合,斜率正负直接告诉你价格和评分是正相关还是负相关。趋势线用红色虚线,和蓝色散点区分开。

参数说明:斜率接近 0 说明价格和评分基本无关,这时候“贵的一定好”就不成立。如果斜率明显为正,说明高价景点评分普遍偏高,但要注意可能是高价景点本身筛选过。np.linspace生成 100 个点画平滑线,点太少线会折。

4.3 热度分布直方图

热度分布能看出数据是不是集中在少数头部景点,这对推荐策略有影响。

fig, ax = plt.subplots(figsize=(10, 6)) # 热度分 20 个区间,加密度线 ax.hist(df["heat"], bins=20, color="skyblue", edgecolor="white", density=True) ax.set_xlabel("热度分") ax.set_ylabel("密度") ax.set_title("景点热度分布") # 标出中位数 median_heat = df["heat"].median() ax.axvline(median_heat, color="red", linestyle="--", label=f"中位数={median_heat:.2f}") ax.legend() plt.tight_layout() plt.savefig("heat_dist.png", dpi=150)

逻辑说明:density=True让 y 轴变成密度而不是计数,方便比较不同样本量的分布。bins=20是经验值,数据量几千条时 20 个区间粒度合适。axvline标中位数,比标均值更抗极端值。edgecolor="white"让柱子之间有分隔,视觉更清晰。

参数说明:如果分布明显右偏(大部分景点热度低、少数极高),说明推荐要重点处理长尾,不能只看头部。bins太少看不出形状,太多锯齿明显,20 到 30 之间试。中位数和均值差距大就说明有极端值,这时候用中位数描述“典型景点”更准。

5. 避坑与排查:这些错我基本都犯过

做这类项目,代码写出来只是开始,跑通和跑对是两回事。下面五条是我踩过的坑,每条都按现象、原因、解决写清楚,照着排查能省不少时间。

5.1 中文显示成方块

现象:图表里所有中文标题、轴标签都变成一个个方块,英文和数字正常。原因:matplotlib 默认字体不含中文字形,系统找不到就画方块。解决:在画图前设置matplotlib.rcParams["font.sans-serif"] = ["SimHei"],如果系统没有 SimHei,换成Microsoft YaHei或WenQuanYi Micro Hei。设完还不行就清一下 matplotlib 缓存,缓存路径用matplotlib.get_cachedir()查。另外axes.unicode_minus要设成 False,否则负号也会显示异常。

5.2 归一化遇到全等列除零

现象:推荐分算出来全是 NaN,或者报ZeroDivisionError。原因:某一列所有值相同,max - min等于 0,归一化时除零。解决:在 normalize 函数里判断rng == 0就返回全 0 序列。这个坑在测试数据上不容易发现,因为测试数据往往有差异,真实数据里某一列全相同的情况不少见,比如所有景点都来自同一个城市时城市列就没差异。

5.3 价格缺失填 0 导致均价失真

现象:某城市平均价格算出来特别低,点进去看发现很多景点价格是 0。原因:原始数据里价格缺失被fillna(0)填成了 0,但缺失其实代表“未知”而不是“免费”。解决:区分“免费”和“未知”,免费在清洗时显式转成 0,未知保留 NaN,聚合时用mean()会自动跳过 NaN。如果业务上必须填,就单独加一列标记缺失,展示时注明。这个坑的教训是:填充值之前先想清楚缺失的含义。

5.4 groupby 后索引丢失导致合并错位

现象:按城市聚合后想和原表合并,结果数据对不上或者报索引错误。原因:groupby后城市变成了索引,直接合并时索引和列对不齐。解决:聚合后加reset_index()把分组列变回普通列。如果忘了加,可以用merge时指定left_on和right_index=True补救,但不如一开始就 reset 干净。这个坑在链式操作里特别隐蔽,因为中间结果不打印就看不出来。

5.5 图片保存被裁切

现象:保存的图片边缘标签或标题被切掉一半。原因:savefig默认按画布大小保存,超出部分被裁。解决:保存前调plt.tight_layout()自动调整边距,或者在savefig里加bbox_inches="tight"参数。两个一起用最稳。如果图例放在图外,tight_layout可能还是不够,这时候用bbox_inches="tight"更可靠。导出看板图片时 dpi 至少 150,屏幕上看 100 也行,但打印或放大就糊了。

6. 部署与交付:让看板在别人机器上也能跑

代码在自己机器上跑通不算完,交付出去别人能跑起来才算。这一章讲依赖管理、目录结构和两个进阶技巧,最后说一个我自己的习惯。

6.1 依赖锁定与目录结构

最常见的交付翻车是“在我机器上好好的”。根因是依赖版本不一致。我的做法是生成 requirements.txt 并锁定版本。

# 生成依赖清单,只导出实际用到的包 pip freeze > requirements.txt # 别人拿到后一键安装 pip install -r requirements.txt

逻辑说明:pip freeze导出当前环境所有包及版本,别人安装时版本一致,行为就一致。如果环境里包太多,可以只列核心依赖:pandas、numpy、matplotlib。目录结构我一般这样组织:data/放原始和清洗后数据,src/放清洗、推荐、可视化三个脚本,output/放图表,根目录放main.py串起流程。这样别人一看就知道从哪进。

参数说明:requirements.txt 里版本号用==锁定,不用>=,因为>=在不同时间装到的版本可能不同。如果某个包对版本特别敏感,在文件里加注释说明。Python 版本也建议在 README 里写清楚,3.8 和 3.11 在 pandas 行为上偶有差异。

6.2 用 argparse 让脚本可配置

硬编码路径和参数是交付大忌。用 argparse 把常用参数暴露出来,别人不用改代码就能跑。

import argparse def parse_args(): parser = argparse.ArgumentParser(description="旅游推荐数据分析") parser.add_argument("--input", default="data/travel_raw.csv", help="原始数据路径") parser.add_argument("--output", default="output/", help="图表输出目录") parser.add_argument("--top-n", type=int, default=10, help="推荐展示条数") parser.add_argument("--city", default=None, help="指定城市,不填则全量分析") return parser.parse_args() if __name__ == "__main__": args = parse_args() # 后续流程用 args.input、args.top_n 等 print(f"读取 {args.input},输出到 {args.output}")

逻辑说明:argparse把路径、条数、城市都变成命令行参数,别人用python main.py --city 杭州 --top-n 20就能定制。default给合理默认值,不传参也能跑。type=int保证 top-n 是整数,传字符串会报错提示。help写清楚每个参数含义,--help就能看到。

参数说明:--city默认 None,代码里判断if args.city再过滤。--output结尾带斜杠,拼接文件名时用os.path.join更稳。如果参数多,可以分组用add_argument_group,但四五个参数没必要。

6.3 一个我坚持的习惯

我做完任何数据分析项目,都会留一个check.py,里面就干一件事:读清洗后的数据,打印行数、列数、关键列的缺失率和取值范围。交付前跑一遍,确认数据没在某个环节被意外过滤掉。这个习惯帮我抓到过好几次“筛选条件写错导致结果少了一半”的问题。数据分析的结论再漂亮,数据本身错了就全白搭。希望帮到你。

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

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

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

立即咨询