
经常会接到一类需求用Python做旅游景点数据分析最好还能带一个推荐系统。这个题目听起来不算难但真正落地时你会发现它横跨了爬虫、数据清洗、指标体系、协同过滤、可视化、报告写作一整条链路。很多同学卡在“数据有了却不知道怎么定义热门”“代码跑完了报告却写不出来”这两个地方。这篇文章就是把我实际做这类项目时的完整思路、代码结构和踩坑经验拆开讲适合正在做课程设计、毕业设计或者想练手数据分析项目的人参考。项目包含源码、万字技术报告和配套讲解整体采用“数据采集—数据仓库—数据指标—数据分析—推荐算法—可视化—报告输出”的闭环设计可以直接复现也可以按你自己的景点数据扩展。1. 项目定位与整体设计思路1.1 项目要解决的真实问题旅游景点数据分析并不是简单地把景点名称、评分、评论数拉出来画几个柱状图就完事。它的背后有三个很实际的问题第一游客端的信息过载。打开任何一个旅游平台都能看到成千上万个景点评分和评论数参差不齐甚至存在刷分现象。作为普通用户很难判断哪个景点“真的值得去”。所以这个项目需要一个可解释的“热门度指数”帮用户从大量数据中快速筛选。第二景区运营端的洞察需求。景区管理者想知道游客到底关心什么、什么时间热度最高、哪些主题标签更吸引人。这些不能靠感觉要靠数据分析。第三学习者需要一个“完整项目闭环”。网上碎片化教程很多但很少有人把一个项目从爬虫到推荐到报告完整串起来。旅游景点数据分析恰好在难度和复杂度之间平衡得很好——数据容易获取模型不必太深可视化和报告的可写性又很强。针对这三个问题这个项目的最终交付包含四个部分可运行的Python源码、用于二次开发的样例数据集、一份结构完整的万字分析报告以及配套的讲解视频脚本。换句话说不只是“跑通代码”还要让读者理解每个模块为什么这么设计。1.2 技术选型为什么Python全家桶是稳妥答案我在设计这个项目时技术栈几乎没有犹豫用的是Python生态最主流的一套组合数据采集requestsBeautifulSoup必要时用Selenium兜底抓动态页面数据清洗与处理pandasNumPy指标计算与统计分析pandasSciPy推荐算法scikit-learn的cosine_similarity不依赖重型深度学习框架可视化Matplotlib、Plotly、WordCloud数据存储SQLite轻量且支持SQL查询简单Web交互可选Flask或Streamlit用于展示推荐结果。这套选型不是我拍脑袋决定的而是基于几个实际考虑。第一门槛适中。requests和BeautifulSoup只要会基本Python语法就能上手pandas是数据分析的事实标准遇到问题搜一下基本都有答案。如果换成Java或C爬虫、数据处理和可视化的代码量都会成倍增加而且对初学者不友好。第二生态完整。从数据采集到报告生成Python都有成熟的库支撑。比如在生成报告时可以用python-docx把分析结论自动填入Word模板在生成图表时Matplotlib支持中文字体配置后输出质量完全够用。第三灵活性和性能的平衡。虽然Python在性能上不如Go或Java但旅游景点的数据量通常只有几十万条pandas完全能处理。推荐算法用向量化矩阵运算跑一次也只要几秒。没必要为了“高性能”去引入分布式框架。另一个设计上的关键选择是不把推荐系统做成黑盒子。网上很多教程直接用surprise库调一个SVD几行代码就出结果但这样你根本不知道内部发生了什么。在这个项目里我会自己实现基于用户的协同过滤再用scikit-learn计算相似度这样代码逻辑清晰写报告时也能把每一步的原理讲明白。1.3 项目目录结构与交付物规划一个容易失控的环节就是代码目录混乱。刚开始做这个项目时我曾经把爬虫、清洗、画图全写在一个.py文件里结果改了爬虫的字段命名后面所有代码都要跟着改。后来我把项目重新组织成下面这个结构复盘时发现复用性和可移植性都好了很多tourism_analysis/ ├── data/ │ ├── raw/ # 原始爬取数据JSON/CSV │ ├── clean/ # 清洗后数据 │ └── tourism.db # SQLite数据库 ├── crawler/ │ ├── spider.py # 景点基础信息爬虫 │ ├── comment_spider.py # 评论数据爬虫 │ └── config.py # 请求头、延时、重试配置 ├── analysis/ │ ├── preprocessing.py # 数据清洗主逻辑 │ ├── indicator.py # 热门指数计算 │ └── statistics.py # 分组统计、相关分析 ├── recommend/ │ ├── user_cf.py # 基于用户的协同过滤 │ ├── content_based.py # 基于内容的推荐 │ └── hybrid.py # 混合推荐 ├── visualization/ │ ├── chart_settings.py # 中文字体、配色统一设置 │ └── plot_charts.py # 所有图表输出 ├── webapp/ │ ├── app.py # Flask可视化入口可选 │ └── templates/ ├── output/ │ ├── figures/ # 图表输出目录 │ └── report/ # 报告和附件 ├── docs/ │ ├── 技术报告.md │ └── 讲解脚本.md └── requirements.txt每个模块只负责自己的事通过data/tourism.db传递数据。这样爬虫改版不影响分析模块推荐算法调参也不会让可视化代码爆炸。对写报告也有利每一章对应一个代码模块描述起来非常自然。交付物方面我最终整理成三个包源码包上面的目录结构附带requirements.txt和README.md资料包清洗后的数据样例、全部图表输出、几张可参考的可视化大图文档包万字技术报告Word/PDF双版本、讲解PPT和讲解视频脚本。下面按数据链路一步步说每步都会写出关键代码思路和运行时的注意事项。2. 数据采集爬虫设计与合规处理2.1 数据源选择与字段设计做景点数据分析首先得定义“景点”的范围。这里我没有把所有POI兴趣点都算进去只保留了旅游平台上真正对外售票或对外开放的景区类POI包括自然风光、历史文化、主题乐园、城市公园等常见类型。数据源选用公开的旅游平台网页不涉及用户登录后的私有数据也不碰任何被明确禁止采集的内容。采集字段需要从分析目标倒推。为了让后续“热门指数”和推荐算法真正有用我设计了这样一张表字段名说明示例spot_id景点唯一IDspot_001234spot_name景点名称天湖风景区city所在城市A市district所在区域某区latitude纬度30.123456longitude经度120.123456category景点类型自然风光tags主题标签亲子游,山水star_level景区等级5A/4A/3Aticket_price成人票价80.0open_time开放时间08:00-17:00rating综合评分4.6review_count评论数8320comment_id评论IDc_000001comment_content评论文本风景很美适合散步comment_time评论时间2024-05-01user_id评论用户IDu_000123user_city用户所在城市B市评论数据是推荐系统的重要原料。如果没有用户对景点的显式评分就把用户对景点的评论行为当成一种隐式反馈评论过代表用户去过好评率高的信息也可以转换成分值。后面推荐模块会用到。2.2 爬虫实现的关键点与反爬应对我见过很多初学者一上来就开几十个线程暴力抓取结果没跑几分钟IP就受限代码白写。这个项目里我刻意控制数据采集频率用“慢而稳”的方式抓取公开页面。核心爬虫流程是这样的先从城市列表页拿到所有景点的详情页URL逐个访问详情页解析景点基础信息再通过评论翻页接口拿到该景点的多页评论每请求一个页面后随机休眠1到3秒设置User-Agent轮换。下面是一段精简后的爬虫骨架import time import random import requests from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Accept-Language: zh-CN,zh;q0.9, } def safe_get(url, retry3): for attempt in range(retry): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: return resp.text elif resp.status_code in (403, 429): time.sleep(5 * (attempt 1)) except requests.RequestException as e: time.sleep(2 * (attempt 1)) return None def crawl_spot_detail(spot_url): html safe_get(spot_url) if not html: return None soup BeautifulSoup(html, html.parser) # 请根据目标页面结构调整选择器 name soup.select_one(.spot-name).text.strip() rating float(soup.select_one(.rating).text.strip()) review_count int(soup.select_one(.review-count).text.strip()) time.sleep(random.uniform(1, 3)) return { spot_name: name, rating: rating, review_count: review_count, }为什么每次请求后要随机休眠而不是固定2秒因为固定延时容易被识别为程序行为随机延时更接近人工操作。更重要的是在爬取过程中我始终遵循“单线程低频率”数据量控制在合理范围内。这个项目最终收集了10个城市、约1200个景点、34万条有效评论已经足够做统计分析完全没有必要把所有景点都爬一遍。反爬并不是用破解手段而是把自己伪装成一个正常用户。以下几点实测有效请求头里的Referer尽量带上上一级页面不要用同一个Session连续访问大量页面遇到验证码或滑块页面时直接跳过不要尝试自动破解把爬到的数据先存成JSON或CSV避免因中断丢失。还要注意合规问题。爬虫部分只采集公开可见的、不需要登录的信息不绕过权限限制不对目标站点造成压力不用于商业盈利。如果你只是做课程项目和写报告这样的边界足够安全了。2.3 数据清洗与入库原始爬取的数据一定不能直接用。我遇到过的情况包括评论数里带“万”字、评分字段有“好评”“中评”等文本、经纬度有的为空、评论时间格式不统一、同一景点出现多次重复。清洗步骤我用pandas完成脚本逻辑如下import pandas as pd def clean_raw_data(df): # 去重同一个spot_id和comment_id组合只保留一条 df df.drop_duplicates(subset[spot_id, comment_id]) # 统一时间格式 df[comment_time] pd.to_datetime(df[comment_time], errorscoerce) df df.dropna(subset[comment_time]) # 清洗评分为数值 df[rating] pd.to_numeric(df[rating], errorscoerce) df df.dropna(subset[rating]) # 缺失经纬度时用景区名称补查若仍为空则删除 df df.dropna(subset[latitude, longitude]) # 过滤异常票价 df[ticket_price] df[ticket_price].fillna(0).astype(float) df df[df[ticket_price] 0] return df清洗之后我把所有数据写入SQLite。选择SQLite而非直接存CSV的原因在于后续分析经常要按城市、景区、时间分组JoinSQL一句GROUP BY就能完成比在pandas里反复merge要直观而且SQLite是单文件交付给别人的时候把.db文件直接发过去环境不用额外配置。建表语句也很简单CREATE TABLE IF NOT EXISTS spots ( spot_id TEXT PRIMARY KEY, spot_name TEXT, city TEXT, latitude REAL, longitude REAL, category TEXT, tags TEXT, rating REAL, review_count INTEGER, ticket_price REAL ); CREATE TABLE IF NOT EXISTS comments ( comment_id TEXT PRIMARY KEY, spot_id TEXT, user_id TEXT, comment_time TEXT, comment_content TEXT, rating REAL );数据入库后先做几个简单的SELECT验证一下总数、去重率和覆盖时间范围确保没有离谱问题再进入分析阶段。3. 热门景点怎么定义指标体系与数据分析3.1 热度指数公式不看单一指标很多人做景点排行时直接按评论数排序这样做的问题很大。评论数多不代表口碑好比如一个景区评论数超多但平均评分只有3.5分它的“热度”里面负面成分居多。反过来一些新晋的小众景点评分很高评论数却只有几百条直接排在最后也不合理。我把热度定义为一个综合指数结合评论规模、评分高低、好评倾向、以及关注趋势。计算公式如下hot_score 0.4 * normalize(review_count) 0.3 * normalize(rating) 0.2 * normalize(positive_review_rate) 0.1 * normalize(monthly_attention_index)其中normalize采用的是MinMaxScaler按极值归一化到0到1区间。为什么归一化因为review_count可能是几千到几万而rating只有3到5量纲完全不同直接加权等于只看评论数。本项目的实际计算中每个指标都做一次MinMaxScaler让每个维度在[0,1]内可比。举个例子假设某景区A数据为评论数8000条评分4.6分好评率92%每月搜索和收藏的Attention指数为750。另一个景区B评论数4000条评分4.8分好评率95%Attention指数为300。经过归一化并加权后A的hot_score可能高于B因为评论规模的权重更大。这说明“热门”本质上是“关注人数”和“口碑”的博弈。这里要注意一个细节归一化时要固定训练数据范围不能每次计算都重新计算全局最小最大值否则同一景区的热度值会随数据规模变化而不稳定。我习惯在建立指标时先对全量数据fit一次再把结果保存下来后续新增景点时用同一组MinMax参数计算。3.2 五个核心分析维度热门指数只是一个聚合值项目要真正有深度还需要从多个维度拆解。这一部分我建议从以下五个方面展开每个方面都能写出一小节约2000字以上的报告内容。第一城市维度。按城市聚合所有景点的平均热度并统计每个城市的高热度景点数量。得到的结果可以回答“哪个城市更值得作为旅行目的地”。要注意的是城市平均热度会被景点总数稀释所以同时看“中位热度”和“top景点集中度”更有意义。第二景点类型和主题标签。把景点按自然风光、历史文化、主题乐园、城市公园、海滨沙滩等类型分组再交叉主题标签找出“亲子游胜地”“古建筑爱好者必去”等细分结论。文本类标签可以通过Counter统计词频再用词云展示。第三时间维度。按评论时间绘制月度趋势可以直接观察到旺季、淡季和节假日前后的波动。这时候pandas的resample非常方便。代码看起来很简单trend df.set_index(comment_time).resample(M).size()但分析结论要结合业务——比如某类景点在暑假达到峰值另一类在秋季才是最佳热度窗口。时间维度的意义是为推荐系统加上“季节性因子”9月推荐海滨和避暑景点就不太合适。第四价格维度。把票价分成“免费”“0-50元”“50-100元”“100-200元”“200元以上”几个区间统计每个区间的平均评分和评论数。通常会发现免费景点的评论量最高但高评分和价格区间的关系并不是线性。这个结论对后续基于内容的推荐很有用它说明价格会对用户选择产生显著影响。第五用户画像和评论情绪。评论数据里的user_city可以拼出跨区域游客流向比如“某海滨城市的游客最喜欢去周边古城”。再利用简单的关键词字典或SnowNLP对评论文本做情感倾向判断好评率就是一个直接指标。评论内容还能提取高频词比如“排队”“体验到”“拍照好”等。3.3 用可视化回答业务问题可视化不是把图表全部堆出来而是每个图都要对应一个“问题”。根据项目报告的叙事线我最终保留了几张核心图热度Top15景点横向条形图用于展示头部效应城市热度对比柱状图标注平均评分与热度指数双轴景区类型热度箱线图展示不同景区的热度分布差异月度评论量趋势折线图标注假期峰值价格区间与评分关系散点图主题标签Top10条形图和词云用户所在城市流向地图当有多城市数据时推荐结果展示表解释候选排序依据。画图有一个容易踩的坑Matplotlib默认不显示中文所有中文标签都会变成方块。解决办法是在画图前统一设置字体import matplotlib import matplotlib.pyplot as plt matplotlib.rcParams[font.sans-serif] [Microsoft YaHei, SimHei, PingFang SC] matplotlib.rcParams[axes.unicode_minus] False建议把所有图表的中文字体和颜色配置写成单独的chart_settings.py长项目中统一调用而不是在每个绘图脚本里重复写。色彩上选择同一套色系别在第一个图用蓝色、第二个图突然用荧光绿报告整体观感会很差。4. 推荐算法从协同过滤到混合推荐4.1 构造用户-景点评分矩阵推荐系统的输入数据是用户对景点的反馈。这个项目中评论数据里没有显式的1到5星打分所以我采用了一种常见做法把用户的一次评论行为转化为4.0分基础值如果评论文本为正好评则加0.5再叠加评论时间新鲜度权重。也可以简单点有评论记1无评论记0再按好评率加权。不管采用哪种方式最终要形成一个“用户ID x 景点ID”的评分矩阵大多数格子是空的。这里用到pandas.pivot_tablerating_matrix df.pivot_table( indexuser_id, columnsspot_id, valuesscore, aggfuncmean, fill_value0 )矩阵的稀疏度非常高很多用户只评论过一两个景点。如果直接把这样稀疏的矩阵扔进协同过滤结果往往不理想。因此我设置了一个过滤条件只保留评论数不少于5条的用户以及评论数不少于30条的景点。这个阈值可以减少冷启动噪音同时保留足够样本。4.2 基于用户的协同过滤UserCFUserCF的核心假设是如果两个用户对若干景点的评分相似那么他们对其他景点的评分也会相似。实现分为三步计算用户两两之间的余弦相似度找到目标用户最相似的K个用户用这K个用户的评分加权预测目标用户对未游玩景点的评分。记用户u和v的评分向量为相似度公式为sim(u, v) (u · v) / (|u| * |v|)Python代码里直接调用sklearn.metrics.pairwise.cosine_similarity比自己手写循环要快得多from sklearn.metrics.pairwise import cosine_similarity user_sim_matrix cosine_similarity(rating_matrix) user_sim_df pd.DataFrame(user_sim_matrix, indexrating_matrix.index, columnsrating_matrix.index)预测用户u对景点i评分的公式为pred(u, i) sum(sim(u, v) * r(v, i)) / sum(sim(u, v))只看该用户top-K相似用户中对景点i有评分的用户加权。我选择K10经过实际测试在测试集上的RMSE最低如果K太小预测方差大K太大相似度低的用户噪声会被引入。4.3 基于内容的推荐补充UserCF有个很明显的问题新用户没有足够行为无法计算相似用户。所以我加入了基于内容的推荐利用景点的特征来扩充推荐覆盖面。构建景点特征向量的方法把类型、主题标签、价格区间、城市、星级等字段做OneHot编码。比如“自然风光”“亲子游”“门票免费”“A市”“4A”这几个特征变成一组0/1向量。再为每个用户维护一个“用户偏好向量”计算方式是用户历史评分景点的特征向量的加权平均。推荐时计算用户偏好向量和所有未游玩景点特征向量的余弦相似度取相似度最高的作为候选。这个思路实现起来不难def content_based_recommend(user_id, top_n10): user_vec user_profile.loc[user_id].values.reshape(1, -1) sims cosine_similarity(user_vec, spot_feature_matrix)[0] sims pd.Series(sims, indexspot_feature_matrix.index) return sims.sort_values(ascendingFalse).head(top_n)基于内容的推荐有一个额外价值推荐结果“好解释”。比如因为用户去过天湖景区且偏好自然风光和亲子标签所以推荐了同样带有这些标签的青云山风景区。报告里写这类逻辑能明显提高答辩的说服力。4.4 混合推荐与冷启动处理为了让效果更稳定我的最终方案是加权混合final_score 0.6 * user_cf_score 0.4 * content_score混合前需要先分别把两种算法的得分归一化到同一个量纲。我在代码里对两个得分各自做了rank转换再按权重相加避免一个算法天然分数高导致另一个完全失效。冷启动场景分别在“新用户”和“新景点”上处理新用户无行为直接用热门榜推荐也就是热度指数Top10外加推荐两组不同主题的景点新景点无评论用基于内容的相似景点召回再叠加该景点的基础热度分。评测方面不能只看一两个例子。我把评论行为按时间划分前70%作训练集后30%作测试集然后计算Precision10和Recall10。我项目里的UserCF单独Precision10大概是0.18加入内容推荐混合后提升到0.22提升不算大但覆盖度明显更好。这说明混合推荐不是万能但增加了结果多样性。5. 万字报告与讲解视频的组织方法5.1 技术报告怎么写出逻辑深度很多学员对我说代码都跑通了但一写报告就卡住。核心原因是没有建立“问题驱动”的写作框架。这个项目的技术报告建议按七章来写摘要用一段话说清楚做了什么、用了什么数据、得到了什么核心结论研究背景与意义回答为什么要做景点数据分析与推荐数据获取与预处理说明爬虫字段、清洗规则、数据质量统计热门景点指标体系解释为什么不自创评分法而采用分位数归一化加权数据分析结果围绕城市、类型、时间、价格、用户画像给出图表结论推荐算法设计与实现给出数学模型、代码结构、评测指标结论与展望提3个确实存在的不足比如数据规模有限、缺乏实时更新、未引入深度学习。正式写的时候每一小节都用“提出问题—设计方法—展示过程—得出结论—给出图表”这个五段法。不要大段复制代码而是贴关键代码片段并解释它的作用。引用别人的公式要注明来源答辩时很加分。“万字”并不是硬凑字数。实测按上述章节写每个章节写600到1000字加上表格和附录很容易超过1万字。关键是每一段都要承接上面的内容不能东一榔头西一棒子。5.2 讲解视频的脚本结构这份项目配套的讲解视频并不是照着报告念而是按“讲给感兴趣的同学听”的逻辑组织。我使用的时间分配是前3分钟讲背景用“你去一个陌生城市怎么选景点”这个例子引出数据价值接下来7分钟讲数据爬取展示一两个页面和爬虫运行过程再花10分钟讲热门指数和可视化结论播放预先录好的图表动画中间15分钟讲推荐算法先在白板上画UserCF流程再运行代码给结果最后5分钟讲解完整Web页面演示和QA。视频脚本和报告最大的不同是“要有悬念”。我会在脚本里写先抛出“为什么评论数多的景点排名不一定靠前”再一步步揭示热度指数公式。这样的讲解方式能让听的人愿意看下去。5.3 资料包与图片素材管理“支持资料、图片参考”这个需求本质上是希望交付物齐全、能直接用于汇报和二次修改。我整理资料包时用了一个固定规范readme.md里写清环境版本、运行顺序、数据说明所有图表按fig01_top15_heat.png这种带序号和含义的格式命名原始数据、清洗数据、数据库分开目录加了README解释每份数据为什么保留报告和PPT都放一个docs/目录同时导出PDF和Word版本如果用户需要定制别的城市或景点只需要替换data/raw里的源数据然后重新执行analysis和recommend两个步骤即可代码不用改。图片素材不只是图表还包括爬虫运行截图、数据库表图、推荐系统界面截图。这会让报告更有“现场感”。但注意截图里不要露出个人账号信息和敏感数据。6. 常见问题与排查技巧实录6.1 爬虫请求失效和IP受限这是项目里最常碰到的问题。症状是爬虫跑了几分钟后连续返回403或429甚至页面提示需要验证。我在实际调试中采用了三个应对方案请求头部加入Referer和Accept字段模拟完整浏览器请求每个页面请求后停顿1到3秒并且使用随机数代替匀速给请求加指数退避重试第一次失败后休眠2秒第二次休眠4秒第三次休眠8秒。如果连续多次失败我会放弃当前景点记录到error_log.txt等全量爬完之后再单独补抓。这样能保住已有数据不因为一个景点卡死整个流程。6.2 中文乱码与经纬度问题用requests拿到HTML后中文出现乱码的常见原因是响应编码识别错误。解决办法是resp.encoding resp.apparent_encoding或者在解析前用BeautifulSoup判断charset。如果评论JSON接口返回的是Unicode字符串json.loads会自动转义通常没问题。经纬度缺失也很常见。有的景点详情页只给出“某某路某某号”的文字地址但分析可视化需要经纬度。我的处理方式是把经纬度缺失的景点名导出为CSV再通过地图API的地理编码接口补一次坐标。注意申请API的每日免费额度控制请求量不要大批量调用。6.3 推荐结果全一样的调试思路协同过滤代码跑完发现给所有用户推荐的Top10几乎一模一样。我排查过这个问题原因通常是数据太稀疏导致大多数用户的相似用户都是一堆“零向量”预测分数全被热门景点主导。解决办法有几个提高UserCF的最低评论数阈值减少“僵尸用户”对评分矩阵做中心化让用户评分减去自身均值消除用户打分习惯差异在相似度矩阵中排除全零行降低UserCF权重增加基于内容的推荐比例。我后来把混合权重从0.8/0.2调成0.6/0.4推荐结果的多样性明显改善。6.4 环境配置和报告查重的小技巧代码运行环境建议使用Python 3.9到3.11装依赖时用pip install -r requirements.txt。我在requirements.txt里锁住了主要包的大版本比如pandas2.0,3.0避免新版numpy不兼容导致报错。报告查重是很多人担心的点。我的经验是不依赖降重软件而是每张图表都亲手写分析结论用自己的表达重新解释一遍数据。不要复制景点百科和平台介绍那些内容重复率极高。在附录中把数据采集和处理的过程写细致查重率会自然降下来。最后再分享一个小技巧做这类数据分析项目一定要保留一个“过程记录本”。我在根目录放了PROJECT_LOG.md每天记录改了什么、遇到什么报错、最后怎么解决。报告里的“难点与解决”部分直接从这里整理非常真实也能让导师或听众相信项目是亲手做出来的。整个项目折腾下来的最大体会就是旅游数据分析的难点不在算法高深而在于把每一个环节的“为什么”讲清楚。数据来源可靠、指标定义有依据、推荐过程可解释报告自然就有说服力。