简介这是一份面向高校学生与Python初学者的旅游网站数据分析与可视化期末大作业完整源码包评审得分95分以上经过严格调试可直接运行适合作为课程设计参考、数据分析入门练手或可视化项目模板。压缩包共74个文件约13.62MB包含36个html可视化页面、10个js与8个css前端资源、3个py脚本、3个ipynb分析笔记、1个csv数据集及1份docx说明手册覆盖数据读取、清洗、统计到图表输出的完整链路。项目围绕去哪儿2020年五一旅游数据展开涵盖热门城市景点评分、各省景点等级数量对比、不同价位消费情况、地区星级分布、水球图与词云图等多维可视化并配有Flask应用入口与模板页面可直观查看交互效果。目前已有616人学习下载读者可借此掌握pandas数据处理、pyecharts图表绘制与Web端展示的完整思路快速完成同类大作业或课程汇报。1. 旅游网站数据分析与可视化一份期末大作业怎么做出生产级质感做旅游网站数据分析及可视化这个题目很多人第一反应是找一份 CSV用 pandas 读进来画几张柱状图和饼图再拼一个 HTML 就交差。但真正拿过高分的方案往往在数据清洗阶段就拉开了差距评论字段里混着表情符号和 HTML 标签价格列有「¥1,280 起」这种非结构化文本景点名称存在「故宫」和「故宫博物院」两种写法。这些问题不解决后面所有图表都是脏数据的可视化老师一眼就能看出来。这份方案要解决的核心问题是如何用 Python 把一份来源杂乱的旅游网站数据从原始字段一步步变成可交互、可解释、能讲出业务结论的可视化看板。适合正在做数据分析课程设计的学生也适合刚转行数据分析、需要一个完整项目练手的从业者。整条链路覆盖数据采集思路、清洗规则、分析维度和可视化落地每一步都有可复现的代码和参数说明。2. 数据获取与清洗从原始字段到可用数据集的六个关键操作2.1 旅游网站数据的典型来源与采集边界旅游网站的数据通常分三类结构化数据景点名称、票价、评分、评论数、半结构化数据评论正文、标签、非结构化数据图片、视频。期末大作业一般不需要真的去爬线上网站常见做法是使用公开数据集比如某旅游平台脱敏后的景点评论数据或者自己用 requests BeautifulSoup 抓取少量页面作为样本。如果选择自己采集需要注意几个边界单次请求间隔不低于 1 秒避免对目标站点造成压力只采集公开可见的景点介绍和评论摘要不碰用户个人信息采集量控制在几千条以内足够分析即可。我一般会先用 requests 拿一个页面确认返回的 HTML 结构再写解析逻辑。import requests from bs4 import BeautifulSoup import time import pandas as pd headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } def fetch_page(url): resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 return resp.text def parse_spots(html): soup BeautifulSoup(html, html.parser) items [] for card in soup.select(.spot-card): name card.select_one(.spot-name) price card.select_one(.spot-price) score card.select_one(.spot-score) items.append({ name: name.get_text(stripTrue) if name else , price_text: price.get_text(stripTrue) if price else , score_text: score.get_text(stripTrue) if score else }) return items all_data [] for page in range(1, 6): html fetch_page(fhttps://example.com/spots?page{page}) all_data.extend(parse_spots(html)) time.sleep(1.2) df pd.DataFrame(all_data) print(df.shape)这段代码的逻辑是先定义请求头模拟正常浏览器访问然后逐页抓取并解析景点卡片。time.sleep(1.2)是硬性间隔防止请求过密。select_one的选择器需要根据实际页面结构调整这里只是示例。抓下来的price_text和score_text都是字符串不能直接参与计算下一步必须做类型转换。2.2 价格、评分、评论数的清洗规则原始数据里最麻烦的是价格字段。常见格式有「¥1,280」「1280元起」「 980」「免费」四种。清洗目标是统一成浮点数单位元。评分字段可能是「4.8分」「4.8」「暂无评分」评论数可能是「1.2万条」「3456条」。import re def clean_price(text): if not text or 免费 in text: return 0.0 text text.replace(,, ).replace(¥, ).replace(, ) match re.search(r(\d\.?\d*), text) return float(match.group(1)) if match else None def clean_score(text): if not text or 暂无 in text: return None match re.search(r(\d\.?\d*), text) return float(match.group(1)) if match else None def clean_comment_count(text): if not text: return 0 text text.replace(条, ).strip() if 万 in text: num float(re.search(r(\d\.?\d*), text).group(1)) return int(num * 10000) match re.search(r(\d), text) return int(match.group(1)) if match else 0 df[price] df[price_text].apply(clean_price) df[score] df[score_text].apply(clean_score) df[comment_count] df[score_text].apply(clean_comment_count)clean_price先处理「免费」为 0再去掉千分位逗号和货币符号最后用正则提取数字。clean_score遇到「暂无」返回 None后续可以填充或剔除。clean_comment_count处理「万」单位的逻辑是先提取数字乘以 10000 再取整。这三个函数覆盖了旅游网站数据里 90% 以上的脏格式。2.3 景点名称归一化与去重同一个景点可能有多个名称变体比如「故宫」和「故宫博物院」、「西湖」和「杭州西湖」。如果不做归一化按景点分组统计时会出现同一景点被拆成两条记录的情况。常见做法是维护一个别名映射表用字典做替换。对于没有明显别名的可以用 difflib 的 SequenceMatcher 做相似度匹配但阈值要设高一点一般 0.85 以上才合并否则容易把不同景点误合。from difflib import SequenceMatcher alias_map { 故宫博物院: 故宫, 杭州西湖: 西湖, 秦始皇兵马俑博物馆: 兵马俑 } df[name_clean] df[name].replace(alias_map) def merge_similar(names, threshold0.85): unique list(set(names)) merged {} for i, n1 in enumerate(unique): if n1 in merged: continue group [n1] for n2 in unique[i1:]: if n2 in merged: continue ratio SequenceMatcher(None, n1, n2).ratio() if ratio threshold: group.append(n2) merged[n2] n1 merged[n1] n1 return merged name_mapping merge_similar(df[name_clean].tolist()) df[name_final] df[name_clean].map(name_mapping)alias_map处理已知的固定别名merge_similar处理未知的相似名称。阈值 0.85 是经验值低于这个值容易把「南山」和「南山区」这种不同层级的名称合并。合并后按name_final分组统计结果才准确。2.4 缺失值与异常值的处理策略清洗完字段后要检查缺失率和异常值。价格字段如果缺失超过 30%建议直接剔除该列不要强行填充。评分缺失可以用该景点所属城市的平均分填充但要在报告中注明。评论数为 0 的记录可能是新上线景点保留但单独标记。异常值方面价格超过 5000 的景点可能是豪华套餐或数据错误需要人工确认。评分超过 5 或低于 0 的直接置为 None。我一般会用箱线图的四分位距法做初步筛查Q1 df[price].quantile(0.25) Q3 df[price].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR df[price_outlier] (df[price] lower) | (df[price] upper) print(f价格异常记录数: {df[price_outlier].sum()})IQR 法的系数 1.5 是标准值如果数据分布偏态严重可以放宽到 3。标记为异常的记录不要直接删除先看看是不是真实的高价景点比如迪士尼尊享导览确实可能上千。2.5 用 pandas 做分组聚合的四个分析维度清洗完的数据可以支撑四个基础分析维度城市维度各城市景点数量和平均价格、评分维度评分分布和与评论数的关系、价格维度价格区间分布、时间维度如果有评论时间字段可以做月度趋势。city_stats df.groupby(city).agg( spot_count(name_final, count), avg_price(price, mean), avg_score(score, mean), total_comments(comment_count, sum) ).reset_index() city_stats city_stats.sort_values(spot_count, ascendingFalse) print(city_stats.head(10))groupby后面跟agg可以一次性算出多个统计量。reset_index把分组键变回普通列方便后续传给可视化库。排序后取前 10 个城市就是柱状图的输入数据。2.6 把清洗结果落成可复用的中间层清洗逻辑不要散落在 notebook 的各个单元格里建议封装成一个clean.py输入原始 CSV输出清洗后的 CSV。这样可视化部分只需要读清洗后的文件逻辑清晰也方便复现。def clean_pipeline(raw_path, output_path): df pd.read_csv(raw_path) df[price] df[price_text].apply(clean_price) df[score] df[score_text].apply(clean_score) df[comment_count] df[score_text].apply(clean_comment_count) df[name_clean] df[name].replace(alias_map) name_mapping merge_similar(df[name_clean].tolist()) df[name_final] df[name_clean].map(name_mapping) df.to_csv(output_path, indexFalse, encodingutf-8-sig) return df clean_pipeline(raw_spots.csv, clean_spots.csv)encodingutf-8-sig是为了 Excel 打开时不乱码。这一步做完数据层就稳定了后面所有图表都基于clean_spots.csv不会再受原始脏数据影响。3. 可视化落地从静态图表到可交互看板的实现路径3.1 选型对比matplotlib、pyecharts、plotly 怎么选期末大作业的可视化部分常见选择有三种matplotlib 适合生成静态图嵌入报告pyecharts 适合生成 HTML 交互看板plotly 适合在 notebook 里做探索性分析。如果目标是交一份能在线打开的看板pyecharts 是最省事的它生成的 HTML 自带缩放和悬浮提示不需要额外部署。工具输出格式交互能力上手难度适合场景matplotlibPNG/SVG无低报告插图pyechartsHTML缩放、悬浮、图例切换中在线看板plotlyHTML/notebook缩放、悬浮、选区中探索分析我一般会先用 plotly 在 notebook 里快速看数据分布确认分析方向后再用 pyecharts 生成最终看板。这样既保证了探索效率又保证了交付物的交互体验。3.2 用 pyecharts 生成城市景点数量柱状图pyecharts 的链式调用写起来很顺先建 Bar 对象再 add_xaxis 和 add_yaxis最后 render 成 HTML。from pyecharts.charts import Bar from pyecharts import options as opts top10 city_stats.head(10) bar ( Bar() .add_xaxis(top10[city].tolist()) .add_yaxis(景点数量, top10[spot_count].tolist()) .set_global_opts( title_optsopts.TitleOpts(title城市景点数量 Top10), xaxis_optsopts.AxisOpts(name城市, axislabel_optsopts.LabelOpts(rotate30)), yaxis_optsopts.AxisOpts(name景点数量), toolbox_optsopts.ToolboxOpts(is_showTrue) ) ) bar.render(city_spot_count.html)add_xaxis传城市名列表add_yaxis传对应的数量。rotate30防止城市名太长重叠。toolbox_opts打开右上角的下载和缩放工具这是 pyecharts 自带的不需要额外写代码。生成的 HTML 可以直接用浏览器打开也可以嵌入到更大的看板页面里。3.3 评分与评论数的散点图发现高口碑低热度景点散点图适合看两个连续变量的关系。把评分放 X 轴评论数放 Y 轴每个点是一个景点。右上角是高口碑高热度左上角是高口碑低热度这类景点值得在报告里单独提出来。from pyecharts.charts import Scatter scatter_data df[[score, comment_count, name_final]].dropna() scatter ( Scatter() .add_xaxis(scatter_data[score].tolist()) .add_yaxis( 景点, scatter_data[[comment_count, name_final]].values.tolist(), label_optsopts.LabelOpts(is_showFalse) ) .set_global_opts( title_optsopts.TitleOpts(title评分与评论数分布), xaxis_optsopts.AxisOpts(name评分, min_0, max_5), yaxis_optsopts.AxisOpts(name评论数), tooltip_optsopts.TooltipOpts(formatter{a} br/评分: {c0} br/评论数: {c1}) ) ) scatter.render(score_comment_scatter.html)label_opts(is_showFalse)隐藏每个点的标签否则几百个点会糊成一片。tooltip_opts自定义悬浮提示把景点名称也带出来。min_0, max_5固定 X 轴范围避免 pyecharts 自动缩放导致评分差异被放大。3.4 价格区间分布直方图与箱线图价格分布用直方图看集中趋势用箱线图看异常值。pyecharts 的 Histogram 需要自己分箱箱线图可以用 Boxplot。import numpy as np prices df[price].dropna() bins np.arange(0, prices.max() 200, 200) hist_data pd.cut(prices, binsbins).value_counts().sort_index() hist ( Bar() .add_xaxis([f{int(interval.left)}-{int(interval.right)} for interval in hist_data.index]) .add_yaxis(景点数量, hist_data.values.tolist()) .set_global_opts( title_optsopts.TitleOpts(title景点价格区间分布), xaxis_optsopts.AxisOpts(name价格区间(元), axislabel_optsopts.LabelOpts(rotate45)), yaxis_optsopts.AxisOpts(name景点数量) ) ) hist.render(price_histogram.html)分箱步长 200 元是根据旅游景点票价分布定的大部分景点在 0-500 之间步长太大会看不出细节太小会有很多空箱。pd.cut返回的是区间对象转成字符串才能作为 X 轴标签。3.5 用 Grid 把多张图拼成一个看板页面pyecharts 的 Grid 可以把多个图表放在同一个 HTML 页面里支持上下布局和左右布局。常见做法是第一行放柱状图和饼图第二行放散点图和直方图。from pyecharts.charts import Grid, Pie pie ( Pie() .add(, [list(z) for z in zip(top10[city].tolist(), top10[spot_count].tolist())]) .set_global_opts(title_optsopts.TitleOpts(title城市景点占比)) ) grid ( Grid(init_optsopts.InitOpts(width1200px, height800px)) .add(bar, grid_optsopts.GridOpts(pos_left5%, pos_right55%, pos_top10%, height35%)) .add(pie, grid_optsopts.GridOpts(pos_left60%, pos_right5%, pos_top10%, height35%)) .add(scatter, grid_optsopts.GridOpts(pos_left5%, pos_right55%, pos_top55%, height35%)) .add(hist, grid_optsopts.GridOpts(pos_left60%, pos_right5%, pos_top55%, height35%)) ) grid.render(dashboard.html)pos_left、pos_right、pos_top、height四个参数控制每个图表在页面中的位置和大小。百分比是相对于整个 Grid 容器的。调这几个参数是体力活建议先画草图确定每个图占多大区域再对应到百分比。3.6 看板配色与字体设置的三个实用参数pyecharts 默认配色是浅色主题如果要在投影仪上展示建议换成深色背景加亮色数据系列。主题通过InitOpts(themedark)设置。字体大小通过LabelOpts(font_size12)控制标题字体通过TitleOpts(title_textstyle_optsopts.TextStyleOpts(font_size16))控制。from pyecharts.globals import ThemeType bar_dark ( Bar(init_optsopts.InitOpts(themeThemeType.DARK, width1000px, height500px)) .add_xaxis(top10[city].tolist()) .add_yaxis(景点数量, top10[spot_count].tolist()) .set_global_opts( title_optsopts.TitleOpts( title城市景点数量 Top10, title_textstyle_optsopts.TextStyleOpts(font_size18) ), xaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(font_size12, rotate30)), yaxis_optsopts.AxisOpts(axislabel_optsopts.LabelOpts(font_size12)) ) ) bar_dark.render(city_dark.html)ThemeType.DARK是 pyecharts 内置主题不需要额外安装。width和height建议设成 1000x500 以上太小了在网页上显示会模糊。字体大小 12 是正文可读下限再小投影时看不清。4. 避坑与排查旅游网站数据分析里最容易翻车的五个地方4.1 中文乱码从 CSV 读取到 HTML 输出的编码链路现象pandas 读 CSV 时中文变成乱码或者 pyecharts 生成的 HTML 打开后中文显示为方块。原因CSV 文件可能是 GBK 编码而 pandas 默认用 UTF-8 读取。HTML 里如果没声明 charset浏览器也会用默认编码解析。解决读 CSV 时显式指定encodinggbk或encodingutf-8-sig。写 CSV 时统一用utf-8-sig。pyecharts 生成的 HTML 默认已经包含meta charsetutf-8如果还有问题检查是不是在 render 之后手动改了文件内容。4.2 价格清洗后出现 None 导致聚合报错现象df.groupby(city)[price].mean()报错提示数据类型不支持。原因clean_price对无法解析的文本返回了 Nonepandas 把整列推断为 object 类型不是数值类型。解决清洗后加一步df[price] pd.to_numeric(df[price], errorscoerce)把 None 转成 NaNNaN 在聚合时会被自动跳过。如果 NaN 太多考虑用中位数填充但要在报告中说明填充比例。4.3 pyecharts 图表在浏览器里空白现象HTML 文件能打开但页面一片空白控制台报错echarts is not defined。原因pyecharts 生成的 HTML 依赖在线 CDN 加载 echarts.js如果网络环境无法访问 CDN图表就渲染不出来。解决在InitOpts里指定js_host为本地路径或者用pyecharts的离线资源模式。常见做法是把 echarts.min.js 下载到本地然后在 HTML 模板里替换 CDN 地址。如果只是本地演示确保浏览器能访问外网即可。4.4 分组聚合时城市字段有空值现象groupby(city)的结果里有一个空字符串的组数量还不少。原因原始数据里城市字段缺失清洗时没有处理pandas 默认把 NaN 排除在分组外但空字符串会被当成一个有效分组。解决清洗阶段加df[city] df[city].replace(, np.nan)然后再df.dropna(subset[city])。如果缺失比例高考虑用景点名称里的关键词推断城市比如名称含「西湖」的归到杭州。4.5 散点图点太多导致浏览器卡顿现象散点图有几千个点HTML 打开后拖动卡顿悬浮提示延迟明显。原因pyecharts 默认渲染所有数据点数据量大时 SVG 或 Canvas 压力大。解决对散点图做抽样比如按评论数排序后取前 500 个或者用Scatter的large_threshold参数开启大数据量模式。如果必须展示全部点改用HeatMap或聚合后的气泡图减少渲染元素数量。5. 进阶技巧让看板从「能看」变成「能讲」5.1 用数据故事线组织看板布局看板不是图表堆砌而是有叙事逻辑的。我一般按「整体概况 → 城市对比 → 价格分布 → 口碑分析 → 结论建议」五段来排。第一屏放核心指标卡总景点数、平均价格、平均评分第二屏放城市柱状图和饼图第三屏放价格直方图和散点图。这样老师或观众从上往下看自然跟着你的分析思路走。指标卡可以用 pyecharts 的Pie或Bar模拟也可以用 HTML 直接写。如果追求简单用Markdown或HTML块在 Grid 上方加一行大字即可。5.2 给图表加注释和参考线pyecharts 支持MarkLineOpts和MarkPointOpts。比如在价格直方图上加一条平均价格的参考线在散点图上标出评分 4.5 以上的区域。from pyecharts import options as opts bar_with_markline ( Bar() .add_xaxis(top10[city].tolist()) .add_yaxis( 景点数量, top10[spot_count].tolist(), markline_optsopts.MarkLineOpts( data[opts.MarkLineItem(type_average, name平均值)] ) ) .set_global_opts(title_optsopts.TitleOpts(title城市景点数量与平均值参考线)) ) bar_with_markline.render(bar_markline.html)type_average会自动计算平均值并画一条虚线。name是图例里显示的文字。参考线能让观众一眼看出哪些城市高于平均、哪些低于平均比单纯看柱子高低更直观。5.3 导出高清图片用于报告pyecharts 生成的 HTML 可以用浏览器截图但分辨率不稳定。更好的方式是用snapshot功能或者直接用 matplotlib 重新画一遍静态图设置dpi300导出 PNG。import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams[font.sans-serif] [SimHei] matplotlib.rcParams[axes.unicode_minus] False fig, ax plt.subplots(figsize(10, 6), dpi300) ax.bar(top10[city], top10[spot_count], color#4C72B0) ax.set_title(城市景点数量 Top10, fontsize16) ax.set_xlabel(城市, fontsize12) ax.set_ylabel(景点数量, fontsize12) plt.xticks(rotation30) plt.tight_layout() plt.savefig(city_bar_300dpi.png)dpi300是印刷级分辨率插入 Word 或 PDF 报告不会模糊。SimHei是中文字体Windows 自带Mac 上换成Arial Unicode MS。axes.unicode_minusFalse解决负号显示为方块的问题。5.4 一个我踩过的坑别在最后一天才跑全流程我做过几次类似的大作业血泪经验是清洗脚本和可视化脚本一定要在交稿前三天完整跑一遍从原始 CSV 到最终 HTML中间不要手动改任何文件。因为一旦某个环节的路径或编码变了最后一天根本来不及排查。我现在的习惯是写一个run_all.sh把清洗和渲染串起来每次改完代码就从头跑一次确保整条链路可复现。#!/bin/bash set -e python clean.py python render_dashboard.py echo 看板已生成: dashboard.htmlset -e让脚本在任一步骤报错时立即停止不会带着错误继续往下跑。这个习惯帮我省了很多次通宵排查的时间。希望帮到你。本文还有配套的精品资源点击获取