1. 项目缘起与整体设计思路
1.1 为什么选旅游景点数据做练手项目
做数据处理练手,选数据集比选工具重要得多。我试过拿公开的电商订单、气象记录、城市交通流量来练,最后发现旅游景点数据是最适合入门到进阶过渡的题材。原因很直接:它的字段结构天然混合了数值型、分类型、文本型和地理型数据,一张表里就能把数据清洗、聚合、分组、可视化全流程跑一遍,不用来回换数据集。
这次拿到的是一份某年河北省旅游景点数据,原始字段大概包含景点名称、所在城市、景区等级、门票价格、游客评分、评论数量、开放时间、景点类型等。数据量不算大,几千条量级,但脏数据一点不少——价格字段混着“免费”“旺季80淡季60”这种文本,评分有空白也有“暂无评分”,城市名有“石家庄”“石家庄市”“石家庄 市”三种写法。这种“半脏不脏”的状态其实最贴近真实业务场景,比那种完全干净的玩具数据集有价值得多。
这个项目能做什么?说白了就是回答几个实际问题:河北哪些城市的旅游资源最集中?5A和4A景区的门票定价有没有明显分层?游客评分和评论数之间是不是正相关?不同类型的景点(自然风光、历史人文、主题乐园)在评分上有没有系统性差异?这些问题用Excel也能凑合答,但一旦涉及分组聚合、多条件筛选、交叉分析,Python的效率优势就出来了。
适合谁来参考?如果你已经会写基本的Python循环和函数,但一碰到pandas的groupby就发怵,或者matplotlib画出来的图总是丑得没法放进汇报材料,那这个项目的完整流程正好可以照着走一遍。我下面会把每一步的操作意图、参数选择和踩过的坑都写清楚,你拿自己的数据替换也能跑通。
1.2 技术栈选型与背后的取舍逻辑
工具选型上我没有追求“全而新”,而是按“够用、稳定、可复现”三个标准来定。核心组合是pandas + numpy + matplotlib + seaborn,地理分布那块如果要做地图就加pyecharts或plotly。
先说pandas。有人会问为什么不直接用SQL或者Excel Power Query。SQL适合数据在数据库里的场景,但这份数据是Excel/CSV文件,导入导出反而多一道手续;Power Query做清洗可以,但一旦要做复杂的自定义聚合和可视化联动,还是得回到代码。pandas的read_excel和read_csv能直接吃文件,groupby+agg的组合能覆盖90%的分析需求,这是它不可替代的地方。
numpy在这里主要做数值计算兜底,比如价格字段清洗完后做分箱、算分位数,用np.percentile比手写排序取中位数稳得多。matplotlib是绘图底座,seaborn建在它上面,做统计类图表(箱线图、小提琴图、回归图)代码量能少一半。我个人的习惯是:探索阶段用seaborn快速出图看分布,最终出报告时用matplotlib精细调样式,两者配合着来。
地理可视化这块要单独说。如果只是看各城市景点数量的对比,普通柱状图就够了;但如果想在地图上按城市位置标点、用颜色深浅表示数量,就得用pyecharts的Geo图或者plotly的scatter_mapbox。pyecharts生成的是HTML文件,分享方便,但样式定制不如matplotlib灵活;plotly交互性强,但数据量大时渲染会卡。我的建议是:先想清楚这张图给谁看、在什么场景下看,再决定用哪个工具,不要为了炫技硬上地图。
提示:不要一上来就装一堆用不到的库。先把pandas、matplotlib、seaborn、openpyxl这四个装好,跑通全流程后再按需加库。环境越干净,出问题越好排查。
2. 数据清洗与核心细节解析
2.1 字段类型识别与缺失值处理策略
拿到数据第一步不是急着画图,而是把每个字段的“真实类型”和“业务含义”对齐。我习惯先用df.info()和df.head(20)扫一遍,再用df.describe(include='all')看数值和分类字段的分布。这一步能快速暴露三类问题:类型错位、缺失值、异常值。
以门票价格为例,原始列里混着“免费”“60”“80元”“旺季100淡季50”这些值。直接astype(float)会报错,得先做文本清洗。我的处理逻辑是分三步走:第一步,把“免费”“免票”统一替换成0;第二步,用正则提取字符串里的第一个数字,比如“旺季100淡季50”提取出100,“80元”提取出80;第三步,对提取结果做合理性检查,价格小于0或大于1000的标记为异常,人工复核。
import pandas as pd import re def clean_price(x): if pd.isna(x): return None x = str(x).strip() if x in ['免费', '免票', '免费开放']: return 0.0 nums = re.findall(r'\d+\.?\d*', x) if nums: return float(nums[0]) return None df['price_clean'] = df['门票价格'].apply(clean_price)这段代码的关键在于re.findall只取第一个数字。为什么?因为大部分“旺季X淡季Y”的写法里,旺季价格更能代表景区的定价水平,而且取第一个数字比取平均值更符合业务直觉。当然这是基于常见实践的判断,如果你的分析目标是淡季客流,那就该取第二个数字。
缺失值处理要分字段看。评分字段的缺失不能简单用均值填充,因为“暂无评分”本身可能意味着新景区或冷门景区,填充均值会掩盖这个信息。我的做法是保留缺失,在分析时单独标注“无评分景区”的占比。评论数量的缺失则可以用0填充,因为没评论约等于评论数为0,这个逻辑是通的。
城市名称的标准化是个体力活但必须做。“石家庄”“石家庄市”“石家庄 市”要统一成“石家庄”,否则groupby会把它们当成三个不同的城市。我一般用str.replace配合正则去掉“市”字和空格,再对照一份标准城市列表做校验。
2.2 异常值识别与处理的分寸感
异常值处理最忌讳“一刀切”。门票价格里出现一个9999元的记录,可能是数据录入错误,也可能是某个特殊体验项目的真实价格。我的原则是:先标记,再判断,不轻易删除。
用IQR方法可以快速定位异常值:
Q1 = df['price_clean'].quantile(0.25) Q3 = df['price_clean'].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 1.5 * IQR upper = Q3 + 1.5 * IQR outliers = df[(df['price_clean'] < lower) | (df['price_clean'] > upper)]跑完之后不要急着drop,先看看这些异常值对应的景点名称。如果发现是知名景区的高价体验项目,那就保留;如果是明显的数据错误(比如价格是负数),那就修正或删除。我踩过的坑是:有一次直接把所有IQR之外的记录删了,结果把一个真实的高端度假区数据也删掉了,导致后续分析里那个城市的平均票价明显偏低。
评论数量字段也有类似问题。有些景区的评论数特别高,可能是热门打卡地,也可能是数据采集时把多个平台的评论重复累加了。这时候可以结合评分一起看:如果评论数极高但评分很低,大概率是数据问题;如果评论数和评分都高,那就是真实的热门景区。
注意:清洗完的数据一定要另存一份,不要在原表上直接改。我习惯用
df_clean = df.copy()开头,这样随时能回到原始数据对照。
3. 实操过程与核心分析环节
3.1 城市维度聚合:谁才是河北旅游的“头部城市”
城市维度的分析是最直观的切入点。用groupby按城市聚合,算每个城市的景点数量、平均票价、平均评分、总评论数,一张表就能看出资源分布。
city_stats = df_clean.groupby('city').agg( 景点数量=('name', 'count'), 平均票价=('price_clean', 'mean'), 平均评分=('rating', 'mean'), 总评论数=('comments', 'sum') ).sort_values('景点数量', ascending=False)跑出来的结果通常呈现明显的“二八分布”:少数几个城市集中了大部分景点,其余城市数量很少。这时候不要只盯着数量看,要结合票价和评分一起解读。比如某个城市景点数量多但平均评分低,可能是同质化严重;另一个城市景点少但评分高,可能是精品路线。
我一般会再做一步“密度”计算:用景点数量除以该城市的行政区面积,得到每万平方公里景点数。这个指标能排除城市面积差异的干扰,更公平地比较旅游资源密度。面积数据可以从公开的统计年鉴里找,手动补一个小字典就行。
area_dict = {'石家庄': 14530, '保定': 22185, '张家口': 36800, ...} city_stats['景点密度'] = city_stats['景点数量'] / city_stats.index.map(area_dict) * 10000这一步的实操心得是:面积数据不用追求特别精确,量级对就行。因为我们的目的是做相对比较,不是做精确的地理测算。
3.2 景区等级与票价的交叉分析
景区等级(5A、4A、3A等)和票价之间的关系是很多人关心的点。直觉上5A应该比4A贵,但实际数据往往不是这么简单。
用箱线图看分布最直观:
import seaborn as sns import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False sns.boxplot(x='level', y='price_clean', data=df_clean, order=['5A', '4A', '3A', '2A']) plt.title('各等级景区门票价格分布') plt.xlabel('景区等级') plt.ylabel('门票价格(元)') plt.show()从经验来看,5A景区的票价中位数确实高于4A,但5A的分布范围也更宽——有免费的5A(比如某些红色旅游景点),也有两三百的5A。4A景区反而集中在50到120这个区间,分布更紧凑。3A及以下景区里免费的比例明显更高。
这个发现的实际意义是:如果你在做旅游产品定价参考,不能只看等级,还要看景区类型。自然风光类的5A往往比人文类的5A票价高,因为前者有索道、观光车等附加消费。
我还会算一个“性价比”指标:评分除以票价。这个指标虽然粗糙,但能快速筛出“便宜又好玩”的景区。计算时要注意票价为0的情况,直接返回一个很大的值或者单独标记。
3.3 评分与评论数的相关性验证
评分和评论数之间的关系,我一开始以为是正相关——好评多所以评论多。但实际跑出来,散点图往往呈现“漏斗形”:评论数少的景区评分差异很大(有5.0也有3.0),评论数多的景区评分集中在4.0到4.8之间。
sns.scatterplot(x='comments', y='rating', data=df_clean, alpha=0.5) plt.xscale('log') plt.title('评论数与评分关系') plt.xlabel('评论数(对数刻度)') plt.ylabel('评分') plt.show()用对数刻度是因为评论数的分布跨度太大,从个位数到几万都有。线性刻度会把大部分点挤在左边看不清。
这个现象的解释是:评论数少的景区样本量小,评分容易走极端;评论数多的景区经过大数定律的“平滑”,评分趋于稳定。所以看评分时一定要结合评论数,一个只有3条评论的5.0分景区,参考价值远不如一个有3000条评论的4.5分景区。
实操中我会加一条“置信度”规则:评论数少于50的景区,评分仅供参考,不纳入排名类分析。这个阈值可以根据数据量调整,数据量大就提高到100,数据量小就降到30。
3.4 可视化图表的组合与排版
单张图能说明一个问题,但一份完整的分析报告需要多张图组合叙事。我的排版逻辑是:先总后分,先宏观后微观。
第一组图:城市景点数量柱状图 + 城市平均评分柱状图,并排摆放,一眼看出“量”和“质”的分布是否一致。
第二组图:景区等级与票价的箱线图 + 景区类型的饼图,展示结构特征。
第三组图:评分与评论数的散点图 + 票价与评分的散点图,展示变量间关系。
fig, axes = plt.subplots(2, 2, figsize=(14, 10)) sns.barplot(x=city_stats.index[:10], y=city_stats['景点数量'][:10], ax=axes[0,0]) axes[0,0].set_title('景点数量Top10城市') axes[0,0].tick_params(axis='x', rotation=45) sns.boxplot(x='level', y='price_clean', data=df_clean, ax=axes[0,1]) axes[0,1].set_title('各等级票价分布') sns.scatterplot(x='comments', y='rating', data=df_clean, alpha=0.4, ax=axes[1,0]) axes[1,0].set_xscale('log') axes[1,0].set_title('评论数与评分') type_counts = df_clean['type'].value_counts() axes[1,1].pie(type_counts.values, labels=type_counts.index, autopct='%1.1f%%') axes[1,1].set_title('景区类型占比') plt.tight_layout() plt.savefig('analysis_overview.png', dpi=150, bbox_inches='tight') plt.show()bbox_inches='tight'这个参数一定要加,否则保存的图片边缘会被裁掉。dpi=150是打印和屏幕显示的平衡点,再高文件太大,再低看着模糊。
提示:中文字体问题几乎是每个人都会踩的坑。Windows上用SimHei,Mac上用Arial Unicode MS,Linux上如果没有中文字体就下载一个思源黑体放到matplotlib的字体目录里。设置完字体后记得把
axes.unicode_minus设为False,否则负号显示成方块。
4. 常见问题与排查技巧实录
4.1 数据读取阶段的典型报错
问题一:Excel文件读取时列名错位。有些Excel文件前几行是标题说明,不是真正的表头。pd.read_excel默认从第一行读,结果列名变成“Unnamed: 0”。解决办法是用skiprows参数跳过前几行,或者用header参数指定表头行号。
df = pd.read_excel('data.xlsx', skiprows=2, header=0)问题二:CSV文件中文乱码。这是编码问题,常见的有utf-8、gbk、gb18030三种。我一般先试utf-8,报错就换gbk,再不行就gb18030。pd.read_csv('data.csv', encoding='gbk')。
问题三:数字列被读成字符串。比如价格列里有“暂无”这种文本,pandas会自动把整列识别为object类型。这时候不要急着转换,先按前面说的清洗逻辑处理文本,再统一转float。
4.2 分组聚合时的常见陷阱
陷阱一:groupby后索引丢失。默认情况下groupby的键会变成索引,后续操作不方便。加as_index=False可以保留为普通列。
city_stats = df_clean.groupby('city', as_index=False).agg(...)陷阱二:agg函数对多列应用不同聚合方式。用字典传参时,键是列名,值是聚合函数。但要注意列名必须存在,否则会报KeyError。
df_clean.groupby('city').agg({ 'name': 'count', 'price_clean': 'mean', 'rating': 'mean' })陷阱三:mean()自动忽略NaN。这是pandas的默认行为,大部分时候是好事,但如果你想知道缺失比例,就得用lambda x: x.isna().mean()单独算。
4.3 可视化阶段的样式调整
问题一:x轴标签重叠。城市名太长时,柱状图的标签会挤在一起。解决办法是旋转45度或90度:plt.xticks(rotation=45, ha='right')。ha='right'让标签右对齐,视觉上更整齐。
问题二:图例位置遮挡数据。默认图例在右上角,经常挡住柱子。用plt.legend(loc='upper left', bbox_to_anchor=(1, 1))把图例移到图外。
问题三:颜色太多显得乱。seaborn的默认调色板在分类多的时候会显得花哨。我一般用palette='Set2'或palette='muted',颜色柔和且区分度够。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读取Excel列名错位 | 表头不在第一行 | 打开文件看前几行 | 用skiprows或header参数 |
| 中文显示为方块 | 未设置中文字体 | 检查rcParams | 设置SimHei或思源黑体 |
| groupby结果为空 | 分组键有空格或大小写差异 | 打印unique值 | 先做strip和lower标准化 |
| 散点图点重叠严重 | 数据量大且值集中 | 看数据分布 | 加alpha透明度或改用hexbin |
| 保存图片边缘被裁 | 未设置bbox_inches | 打开图片检查 | 加bbox_inches='tight' |
| 价格转换报错 | 文本中含非数字字符 | 打印原始值 | 用正则提取数字后再转换 |
4.5 几个让我印象深刻的踩坑经历
有一次做城市聚合,发现“秦皇岛”的景点数量异常少,但印象里秦皇岛旅游资源很丰富。排查后发现原始数据里写的是“秦皇岛市”,而我的标准城市列表里是“秦皇岛”,groupby时被分成了两组。这种问题在数据清洗阶段不暴露,一到聚合就现形。后来我养成了一个习惯:groupby之前先打印分组键的unique值,肉眼扫一遍有没有明显重复。
还有一次画箱线图,发现5A景区的票价箱体特别扁,以为数据有问题。后来才意识到是因为5A景区里有几个免费景点把整体分布拉低了,箱体自然就扁。这不是数据错误,而是真实分布特征。所以看图时不要急着下结论,先想想业务上是否合理。
评论数用对数刻度这个技巧,是我在做了好几次散点图都觉得“看不出东西”之后才悟出来的。线性刻度下,评论数上万的点和评论数十的点挤在一起,完全看不出趋势。换成对数刻度后,分布形态立刻清晰了。这个经验可以推广到所有跨度大的数值字段:价格、人口、流量,都值得试试对数刻度。
5. 分析结论的呈现与后续扩展方向
5.1 如何把分析结果写成可读的结论
跑完代码只是第一步,把结果翻译成业务语言才是交付价值的地方。我的写法是“数据+解读+建议”三段式。比如:
石家庄景点数量最多(XX个),但平均评分在省内排名中游(X.X分),说明资源集中但品质参差不齐。建议优先提升现有景区的服务质量和游客体验,而非继续扩张数量。
这种写法比单纯罗列数字有用得多。读者不需要自己从数字里找规律,你直接告诉他规律是什么、意味着什么。
图表的选择也要服务于结论。如果结论是“5A和4A票价差异不大”,那就放箱线图;如果结论是“自然风光类景区评分普遍高于人文类”,那就放分组柱状图或小提琴图。每张图都要有明确的“使命”,不要为了凑版面放图。
5.2 这个项目还能怎么往下做
当前的分析停留在描述性统计层面,往下走有几个方向。
一是做预测。用线性回归或随机森林,以票价、等级、类型、城市为特征,预测评分或评论数。这个方向能回答“什么样的景区更容易获得高评分”这类问题。特征工程时要注意把分类变量做独热编码,数值变量做标准化。
二是做文本分析。如果数据里有景区简介或游客评论的文本字段,可以用jieba分词加词频统计,看看游客最关注什么——是“风景”“服务”还是“性价比”。这个方向能把分析从结构化数据扩展到非结构化数据。
三是做时间序列。如果数据包含不同年份的游客量或收入,可以画趋势线、算同比增长率,看旅游市场的变化趋势。这个方向对数据要求更高,但分析价值也更大。
四是做交互式看板。用pyecharts或plotly dash把图表串起来,做成可以筛选城市、等级、类型的交互页面。这个方向适合需要反复查看数据的场景,但开发成本比静态图表高不少。
我个人建议先把描述性分析做扎实,再往预测或文本方向扩展。因为描述性分析是地基,地基不稳,后面的模型再花哨也站不住。而且很多业务问题,描述性分析就能回答,不一定非要上机器学习。
5.3 给后来者的几条实操建议
第一,数据清洗的时间要留够。我做过统计,一个完整的数据分析项目,清洗占60%的时间,分析和可视化各占20%。新手最容易低估清洗的复杂度,结果做到一半发现数据有问题,返工成本很高。
第二,每一步都保存中间结果。df.to_csv('step1_clean.csv')这种操作看起来笨,但出问题时能快速定位是哪一步引入的错误。我习惯用带序号的文件名,step1、step2、step3,一目了然。
第三,图表先求清楚再求好看。很多人一上来就调颜色、调字体、加阴影,结果数据本身都没看明白。我的顺序是:先用默认样式出图看趋势,确认分析方向对了,再花时间调样式。
第四,结论要经得起反问。每次写完一条结论,自己问一句“有没有可能是别的原因”。比如“A城市评分低是因为景区质量差”,有没有可能是“A城市景区数量多,拉低了平均分”?多想一层,结论的可靠性就高一层。
第五,代码要能复现。不要用df.dropna(inplace=True)这种原地修改,也不要在notebook里跳着执行单元格。从头到尾按顺序跑一遍能出结果,才算真正完成。我一般会在最后加一个“一键运行”的检查,把所有步骤串起来跑一次,确认没有遗漏。
这个项目我前后做了大概一周,中间返工了两次,一次是城市名没标准化,一次是价格字段清洗逻辑有漏洞。但正是这些返工让我对pandas的groupby和apply有了更深的理解。如果你也在做类似的数据分析练手项目,我的建议是不要怕慢,把每一步都想清楚再动手,比急着出图有价值得多。