1. 拿到一份"三无"旅游数据时,我为什么先做字段体检而不是直接画图
很多人拿到一份旅游景点数据,第一反应是打开绘图库,先画个柱状图看看哪个城市景点多。我早年也这么干过,结果图是画出来了,汇报的时候被问了一句"你这个数据里有多少条是重复的、有多少条地址是空的",当场卡壳。从那以后我养成了一个习惯:任何一份数据到手,先做字段体检,再谈可视化。
所谓"三无"数据,就是没有字段说明、没有数据字典、没有采集时间标注。这次拿到的这份某年河北省旅游景点数据就是典型的三无产品——它可能是从某个公开数据源导出的,字段名是拼音缩写或者英文简写,行数看着不少,但质量未知。这种情况下直接画图,等于在流沙上盖楼。
字段体检要回答四个问题:有多少行、有多少列、每列是什么类型、每列的缺失和重复情况如何。这四个问题不搞清楚,后面所有的分析都是空中楼阁。我见过太多人跳过这一步,最后在"景点数量排名"的图里发现某个城市数量异常高,一查才发现是同一景点被采集了七八次。
具体操作上,我习惯用 pandas 的info()、describe()、isnull().sum()三件套先过一遍。info()看类型和非空计数,describe()看数值列的分布,isnull().sum()看缺失。对于这份旅游数据,重点要关注的是:景点名称列有没有重复、城市/地区列有没有空值、经纬度列是不是数值类型、门票价格列有没有混入"免费""暂无"这类文本。
import pandas as pd df = pd.read_csv("hebei_tourism.csv", encoding="utf-8") print(df.shape) print(df.info()) print(df.isnull().sum()) print(df.duplicated(subset=["景点名称"]).sum())这里有个坑要提醒:编码问题。国内很多数据源导出的是 GBK 或 GB18030 编码,直接read_csv会报UnicodeDecodeError。我的做法是先试 utf-8,失败就换 gb18030,再失败就用chardet探测。别小看这一步,编码错了后面所有中文列都是乱码,分析无从谈起。
字段体检还有一个容易被忽略的点:列名本身可能带空格或不可见字符。比如"景点名称 "和"景点名称"在肉眼看来一样,但 pandas 会当成两列。我一般会用df.columns = df.columns.str.strip()统一清理一遍,避免后续按列名取值时莫名其妙报 KeyError。
体检做完,你会对这份数据有一个基本判断:它能不能支撑"哪个城市景点最多""门票价格分布如何""景点类型占比"这类分析。如果关键列缺失严重,比如城市列空了三分之一,那就要考虑是补全还是放弃这个维度。先判断数据能不能用,再决定怎么用,这是数据处理的基本纪律。
2. 清洗这份旅游数据时,我踩过的四类脏数据和处理策略
字段体检只是摸底,真正花时间的是清洗。这份旅游景点数据里,我遇到了四类典型的脏数据,每一类的处理方式都不一样,处理错了会直接影响后续可视化的结论。
2.1 景点名称重复:是同一景点还是同名不同地
第一类问题是景点名称重复。比如"某某山"在河北可能不止一个地方有,直接按名称去重会把不同城市的景点误删。我的策略是组合去重:用"景点名称+城市"作为联合主键判断重复,而不是单看名称。
df["联合键"] = df["景点名称"].astype(str).str.strip() + "_" + df["城市"].astype(str).str.strip() df = df.drop_duplicates(subset=["联合键"], keep="first")keep="first"保留第一条,是因为通常第一条是原始记录,后面的可能是采集重复。但如果数据里有更新时间列,应该保留最新的那条。这里没有时间列,所以保留第一条是合理选择。
2.2 门票价格混入文本:正则提取比强制转换靠谱
第二类问题是门票价格列。这一列里混着"80元""免费""暂无""60-120元"等各种格式。直接astype(float)会报错,用errors="coerce"又会把"免费"变成 NaN,丢失信息。
我的做法是分两步:先用正则提取数字,再单独标记"免费"和"暂无"。
import re def parse_price(x): x = str(x).strip() if "免费" in x: return 0.0 nums = re.findall(r"\d+\.?\d*", x) if not nums: return None return float(nums[0]) df["门票价格_数值"] = df["门票价格"].apply(parse_price)对于"60-120元"这种区间,我取的是下限。为什么取下限而不是平均值?因为旅游决策里,游客看到的价格区间通常以下限作为"起步价"参考,取下限更符合实际感知。这是一个业务判断,不是技术判断,但恰恰是这种判断决定了分析结果的可信度。
2.3 城市列的空值与别名:统一映射是必须的
第三类问题是城市列。河北有11个地级市,但数据里可能出现"石家庄""石家庄市""石家庄地区"三种写法,还有的直接空着。空值如果占比不高,可以考虑用地址列反推;如果占比高,这个维度就只能放弃。
别名统一我用字典映射:
city_map = { "石家庄市": "石家庄", "石家庄地区": "石家庄", "唐山市": "唐山", # ... 其余城市同理 } df["城市"] = df["城市"].replace(city_map)这里有个经验:映射字典要一次性建全,不要边做边补。我一般会先df["城市"].unique()把所有取值打印出来,人工过一遍,再写映射。否则做到一半发现漏了一个别名,前面的分组统计全要重跑。
2.4 经纬度异常:超出河北范围的直接标记
第四类问题是经纬度。河北的经纬度大致范围是东经113°到120°,北纬36°到43°。超出这个范围的坐标,要么是采集错误,要么是景点在外地但被归到了河北。我的处理是标记出来,不直接删,因为可能是数据源本身的问题,删了会掩盖问题。
df["坐标异常"] = ~( df["经度"].between(113, 120) & df["纬度"].between(36, 43) ) print(df[df["坐标异常"]][["景点名称", "城市", "经度", "纬度"]])标记出来之后,我会人工看一眼这些异常记录。如果只是个别几条,删掉;如果成片异常,那说明数据源有问题,需要在报告里说明。
清洗这一步的核心原则是:每一步都要可追溯。我习惯在清洗过程中记录每一步删了多少行、改了多少值,最后汇总成一张清洗日志表。这样别人问你"数据怎么从5000条变成4200条的",你能一条条说清楚。没有清洗日志的分析,是不可复现的分析。
3. 从景点数量到门票分布:我常用的五个分析切面
数据清洗完,才轮到分析。这份河北旅游景点数据,我通常会从五个切面切入,每个切面回答一个具体问题,而不是笼统地"分析一下"。
3.1 城市维度:景点数量排名与密度
第一个切面是城市维度的景点数量。用groupby("城市").size()就能得到排名。但光看数量不够,还要看密度——用景点数量除以该城市面积,才能反映旅游资源的集中程度。
city_stats = df.groupby("城市").agg( 景点数量=("景点名称", "count"), 平均票价=("门票价格_数值", "mean") ).sort_values("景点数量", ascending=False)这里要注意:平均票价要用清洗后的数值列,不能用原始文本列。而且平均票价对异常值敏感,如果有个别景点票价几百元,会拉高平均值。所以我会同时算中位数,两个数差得远就说明分布偏斜。
3.2 门票价格维度:免费景点占比与价格分档
第二个切面是门票价格。我关心两个数:免费景点占比和价格分档分布。免费景点占比反映旅游惠民程度,价格分档反映景点定位。
分档我一般按 0元、1-50元、51-100元、100元以上四档。分档边界不是拍脑袋定的,而是参考了国内景区门票的常见定价区间:50元以下多为城市公园类,50-100元多为自然景区,100元以上多为大型主题景区或5A景区。
bins = [-1, 0, 50, 100, float("inf")] labels = ["免费", "1-50元", "51-100元", "100元以上"] df["价格档位"] = pd.cut(df["门票价格_数值"], bins=bins, labels=labels) price_dist = df["价格档位"].value_counts()3.3 景点类型维度:自然景观与人文景观的比例
第三个切面是景点类型。如果数据里有类型列,直接分组;如果没有,就要从景点名称里做关键词匹配。比如名称含"山""湖""河""森林"的归为自然景观,含"寺""庙""塔""纪念馆""遗址"的归为人文景观。
natural_kw = ["山", "湖", "河", "森林", "公园", "峡谷", "溶洞"] human_kw = ["寺", "庙", "塔", "纪念馆", "遗址", "故居", "博物馆"] def classify(name): name = str(name) if any(k in name for k in natural_kw): return "自然景观" if any(k in name for k in human_kw): return "人文景观" return "其他" df["景点类型"] = df["景点名称"].apply(classify)这种关键词分类法准确率不是100%,但胜在快速可解释。如果要做严谨分析,应该人工标注一批样本做验证,算出准确率再决定是否采用。
3.4 地理分布维度:散点图看景点聚集带
第四个切面是地理分布。用经纬度画散点图,能直观看到景点是集中在某几个城市,还是沿某条山脉或河流分布。河北的景点分布有个特点:太行山沿线和沿海地区是两个明显的聚集带。
画散点图时,我会把城市用不同颜色区分,这样一眼能看出哪个城市的景点在空间上更分散。如果数据量不大,还可以叠加城市中心点做参照。
3.5 名称词频维度:从景点名看旅游宣传偏好
第五个切面比较有意思:对景点名称做词频统计。把名称分词后统计高频词,能看出旅游宣传的偏好。比如"生态""文化""古镇""红色"这些词出现的频率,反映了当地旅游资源的宣传重点。
from collections import Counter import jieba words = [] for name in df["景点名称"].dropna(): words.extend(jieba.cut(str(name))) word_freq = Counter(words).most_common(30)分词结果里会有"的""山""景区"这类无意义词,需要建一个停用词表过滤掉。这一步做完,往往能发现一些数据本身没告诉你的信息。
这五个切面不是必须全做,而是根据数据质量和分析目的选择。数据支撑哪个切面就做哪个,不要为了凑分析维度硬做。比如经纬度缺失严重,地理分布这个切面就该放弃。
4. 可视化选型:什么数据配什么图,我踩过的坑
分析做完,最后是可视化。可视化最容易犯的错是"一张图打天下"——不管什么数据都用柱状图。实际上,数据类型决定图表类型,选错了图,信息传达效率会大打折扣。
4.1 城市排名用横向条形图而不是纵向柱状图
城市名称是中文,纵向柱状图的 x 轴标签会挤在一起或者倾斜,可读性差。横向条形图(barh)让城市名水平排列,一目了然。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False city_stats["景点数量"].plot(kind="barh", figsize=(10, 6)) plt.xlabel("景点数量") plt.title("各城市景点数量排名") plt.tight_layout() plt.show()这里必须提醒:matplotlib 默认不支持中文,不设置font.sans-serif会显示方块。axes.unicode_minus = False是解决负号显示问题的,虽然这份数据用不上,但养成习惯没坏处。
4.2 价格分布用直方图加核密度曲线
门票价格是连续数值,用直方图看分布形态,叠加核密度曲线看趋势。如果免费景点多,直方图在0处会有一个高峰,这是正常现象,不要以为是数据错误。
import seaborn as sns sns.histplot(df["门票价格_数值"].dropna(), bins=30, kde=True) plt.xlabel("门票价格(元)") plt.title("门票价格分布") plt.show()bins 的数量有讲究:太少看不出分布细节,太多会显得毛刺。一般用sqrt(n)或者 Sturges 公式估算。这份数据几千条,30个 bins 是合理的。
4.3 类型占比用饼图还是环形图
类型占比这类构成数据,饼图和环形图都行。但我更推荐环形图,因为中间的空洞可以放总数标注,信息密度更高。不过如果类别超过5个,饼图就会显得杂乱,这时候应该改用条形图。
type_counts = df["景点类型"].value_counts() plt.pie(type_counts, labels=type_counts.index, autopct="%1.1f%%") plt.title("景点类型占比") plt.show()4.4 地理分布用散点图加透明度
经纬度散点图,如果点重叠严重,要设置alpha透明度,否则密集区域会变成一团黑。颜色可以按城市映射,但城市多了颜色会难区分,这时候按类型映射更合适。
plt.scatter(df["经度"], df["纬度"], alpha=0.5, s=20) plt.xlabel("经度") plt.ylabel("纬度") plt.title("景点地理分布") plt.show()4.5 词频用词云还是条形图
词频可视化,词云好看但信息不精确,条形图精确但不够直观。我的做法是两个都做:词云放在报告开头吸引眼球,条形图放在后面提供精确数据。词云要注意中文字体路径,否则也是方块。
from wordcloud import WordCloud wc = WordCloud(font_path="SimHei.ttf", width=800, height=400).generate_from_frequencies(dict(word_freq)) plt.imshow(wc) plt.axis("off") plt.show()可视化选型的核心原则是:让读者用最少的认知成本获取最多的信息。图不是越花哨越好,而是越清晰越好。我见过有人用3D饼图展示占比,结果因为透视变形,读者根本判断不出哪块更大,这就是典型的为了好看牺牲了信息传达。
5. 这份数据还能怎么挖:三个进阶方向和我的实操建议
基础分析做完,如果还想深挖,这份数据还有三个进阶方向可以尝试。这些方向不一定都要做,但知道有这些可能性,能帮你在拿到类似数据时快速判断它的价值上限。
5.1 用聚类做城市旅游画像
把每个城市的景点数量、平均票价、免费占比、类型比例作为特征,做 KMeans 聚类,可以把城市分成几类。比如"高数量低票价型""低数量高票价型""均衡型"等。这样比单纯排名更有洞察力。
from sklearn.preprocessing import StandardScaler from sklearn.cluster import KMeans features = city_stats[["景点数量", "平均票价"]].fillna(0) scaled = StandardScaler().fit_transform(features) kmeans = KMeans(n_clusters=3, random_state=42).fit(scaled) city_stats["聚类标签"] = kmeans.labels_聚类前必须标准化,否则数量级大的特征会主导距离计算。random_state固定是为了结果可复现,这在分析报告里很重要。
5.2 用文本分析挖景点描述的隐藏信息
如果数据里有景点描述列,可以做情感分析或主题建模。比如描述里"适合亲子""适合徒步"这类词,能帮游客按需求筛选景点。这比单纯按城市和价格筛选更贴近实际使用场景。
5.3 用时间维度做趋势分析(如果有的话)
如果数据里有开放时间或采集时间,可以做时间趋势。但这份数据没有时间列,所以这个方向做不了。没有时间列就不要硬做趋势分析,这是基本纪律。
最后分享几个我在做这类分析时的实操建议。第一,所有图表都要有标题和轴标签,不要指望读者自己猜。第二,颜色不要超过7种,超过人眼就难区分了。第三,分析结论要能追溯到具体数据,不要写"河北旅游资源丰富"这种空话,要写"石家庄景点数量最多,达XX个,占全省XX%"。第四,代码要能一键复现,把清洗、分析、可视化串成一个脚本,别人拿到数据就能跑出同样的结果。
这份河北旅游景点数据,从字段体检到清洗到分析到可视化,完整走一遍大概需要半天到一天。时间主要花在清洗上,分析和可视化反而快。这也是数据工作的常态:八成时间在清洗,两成时间在分析。理解了这一点,就不会觉得清洗枯燥了,因为你知道每一步清洗都在为后面的结论打地基。