骑行数据可视化:Pandas+Matplotlib实战解析
打开Strava或码表App导出一份骑行记录CSV里面的时间戳、心率、海拔、速度数据密密麻麻堆在一起想知道上周到底骑了多远、心率区间分布怎样、爬坡时输出稳不稳定光靠肉眼盯表格实在不直观。我当时也纠结过这个问题后来直接用Python撸了一套骑行数据可视化分析流程核心就两个库Pandas负责清洗和聚合Matplotlib负责把结果画成能直接看懂的图。这篇文章就把这套流程完整拆开讲一遍从数据导入、清洗、聚合到图表输出每个环节都有可复现的代码和参数选择逻辑适合刚学Pandas和Matplotlib的Python初学者也适合想把手表、码表里的历史数据盘活起来的骑行爱好者。1. 项目概述与整体思路1.1 骑行数据长什么样为什么需要分析大多数骑行App导出的数据都是CSV或FIT文件CSV最常见按行记录每次GPS采样的数据。典型的字段包括时间戳、纬度、经度、海拔高度、心率、速度、踏频、功率等。以我平时导出的数据为例大概长这样recorded_at,latitude,longitude,altitude,heart_rate,speed,cadence,power 2026-05-14 07:23:11,31.2304,121.4737,8.2,98,0.0,0,0 2026-05-14 07:23:21,31.2305,121.4737,8.5,101,5.3,62,120这个文件通常不是专门给人类看的而是由GPS设备每秒或每几秒生成一条记录。一次两小时的骑行会产生几千行数据一个月下来就是几万行。靠Excel打开勉强能看但要做趋势分析、区间统计、异常排查Excel的体验就很吃力了。Pandas在这里的核心价值是把零散的CSV变成一个可查询、可聚合、可清洗的DataFrame。Matplotlib的价值则是把处理后的结果以折线图、直方图、散点图的形式呈现出来让数据自己说话。两者配合起来一条完整的链路就是采集数据 → 读入Pandas → 清洗整理 → 按需聚合 → 交给Matplotlib输出图表。1.2 技术选型为什么是PandasMatplotlib市面上做数据可视化的方案很多ECharts、Plotly、Seaborn都有人用但我最终选择了PandasMatplotlib的组合原因很实在。第一Pandas是数据处理事实上的标准工具。ECharts虽然图表交互性强、视觉效果很炫我之前做农产品价格可视化项目时也用过FlaskECharts但它本质上是前端框架数据清洗和聚合还是要靠后端Python来处理。Pandas对CSV、Excel、JSON各种格式的读取支持非常成熟尤其是时间序列数据的处理能力比如resample按分钟/小时/天重采样、rolling滑窗计算这些在骑行数据分析里几乎每天都在用。第二Matplotlib虽然没有ECharts的交互效果但胜在可控性强、输出稳定。骑行数据的图表大多数是用于个人复盘和报告输出不一定需要网页交互。Matplotlib绘制的PNG、SVG、PDF可以随意嵌入笔记、PPT而且图的每个视觉元素都能精细控制比如线条粗细、坐标轴刻度密度、色系搭配这对做一份专业性强的骑行报告非常有用。第三Pandas和Matplotlib天然兼容。DataFrame直接调用plot()方法就能出图也可以把清洗好的Series、DataFrame直接传给Matplotlib的plot()函数坐标轴标签、图例、时间格式化都能自动处理大半开发效率高于分开处理。我在实际项目里也试过Seaborn。它的确能画更漂亮的统计图比如histplot、kdeplot但这些图在骑行场景里的需求度不高骑行分析更需要的是多子图联动、时间轴对齐、自定义配色这些功能Matplotlib完全够用。所以最终这个项目维持在PandasMatplotlib的轻量组合上不引入过多依赖跑起来也快。2. 数据准备与预处理难点2.1 从CSV读入数据的正确姿势拿到一份骑行数据CSV第一步是读入Pandas。这里有几个容易踩坑的细节我一个个说。我最开始做这个项目时直接用了最简洁的方式pd.read_csv(ride.csv)结果发现时间列被读成了字符串对象。字符串对象做不了时间运算更没法重采样聚合必须转成datetime类型。import pandas as pd df pd.read_csv(ride.csv, parse_dates[recorded_at])parse_dates参数可以让Pandas在读取时就直接把指定列解析为datetime类型这是最省事的用法。如果CSV里时间格式比较特殊比如带时区、带毫秒parse_dates解析不了可以改成pd.to_datetime()手动转换df[recorded_at] pd.to_datetime(df[recorded_at], format%Y-%m-%d %H:%M:%S)另外建议在读入后立刻检查几件事列名是否符合预期、每列的数据类型是否合理、是否有全空列。可以用df.info()快速查看我每次拿到新数据源都会先跑这一行成本极低能避免后续一堆诡异报错。print(df.info())骑过一次车的人都知道GPS信号不是每时每刻都稳定的。进隧道、高楼密集区、树荫浓密路段都会丢星丢星期间测出来的速度、位置数据是乱跳的。这类异常数据如果不过滤掉画出来的速度曲线会出现大量脉冲毛刺严重干扰判断。如何处理先看数据分布用describe()理解每一列的均值、标准差、最小最大值。print(df[[speed, heart_rate, altitude]].describe())一般来说骑行速度的最大值如果飙到80km/h以上而实际路线是城市平路那基本可以判定是GPS漂移导致的异常值。心率最大超过200、功率突然跳到2000瓦同样是可疑信号。我的做法是对于明显的物理极限异常值直接剔除对于轻微的抖动用滑窗平滑而不是全部删掉。# 剔除明显异常速度60km/h 且持续仅1秒的采样点 df df[df[speed] 60] # 用5个点的滑动平均做平滑减小GPS抖动 df[speed_smooth] df[speed].rolling(window5, centerTrue).mean()rolling(window5, centerTrue)的意思是取当前采样点前后各2个点共5个点的均值作为该点的平滑后的值。centerTrue让窗口中心对齐当前点这样平滑后的曲线不会产生整体相位偏移。这是做速度、功率这类高频噪声较多的数据时非常常用的手段。缺失值处理也需要分类讨论。心率传感器偶尔会丢包采集器可能在某个时间段连续几秒没有记录心率。处理方式通常有三种直接删除缺失行、向前填充、线性插值。我倾向于用interpolate()线性插值因为骑行过程中的心率变化是连续的用前后值线性估计缺失值视觉上不会产生陡峭的跳变。df[heart_rate] df[heart_rate].interpolate(methodlinear)如果缺失值太多比如心率丢失了70%的数据那这段数据就不适合做心率区间分析了建议整段丢弃或标记为无效区段而不是强行填充。数据分析的第一原则是诚实填充不能创造数据。3. 核心分析维度和可视化方案设计3.1 单次骑行分析心率、速度、海拔三图联动单次骑行分析是所有骑行者最常做的复盘。骑完一趟100公里的路线不想只想看总用时和平均速度还想知道什么时候在上坡、心率有没有爆掉、哪段路速度掉得最厉害。我的做法是画一个三行子图的联动图从上到下依次是速度、心率、海拔。三个图共享同一个X轴时间Y轴各自独立。这样一眼就能看出“海拔上升 → 速度下降 → 心率上升”的因果关系链。import matplotlib.pyplot as plt fig, axs plt.subplots(3, 1, figsize(14, 10), sharexTrue) axs[0].plot(df[recorded_at], df[speed_smooth], color#1f77b4, linewidth1.2) axs[0].set_ylabel(Speed (km/h)) axs[0].grid(alpha0.3) axs[1].plot(df[recorded_at], df[heart_rate], color#d62728, linewidth1.2) axs[1].set_ylabel(Heart Rate (bpm)) axs[1].grid(alpha0.3) axs[2].plot(df[recorded_at], df[altitude], color#2ca02c, linewidth1.0) axs[2].set_ylabel(Altitude (m)) axs[2].set_xlabel(Time) axs[2].grid(alpha0.3) plt.tight_layout() plt.savefig(ride_single_analysis.png, dpi150)这里一个关键细节是sharexTrue让三个子图共享X轴。因为速度、心率、海拔的采样时间完全一致共享X轴后缩放或平移一个子图时其他子图会同步方便对比。figsize(14, 10)是经过测试的尺寸太小的图看不清趋势细节太大又不方便放到报告里。3.2 长期趋势分析周骑行里程与累计时长统计单次分析解决的是“这一趟骑得怎么样”的问题长期分析解决的是“我这段时间训练量是否合理”的问题。周骑行里程和累计骑行时长的变化趋势能直观反映出训练负荷。首先要用Pandas把每次骑行的汇总数据整理成一张总表然后按周聚合。# 假设已有rides表包含每次骑行的日期、距离、时长、爬升 rides[date] pd.to_datetime(rides[date]) rides.set_index(date, inplaceTrue) # 按周统计里程和时长 weekly rides.resample(W).agg({ distance_km: sum, duration_min: sum, elevation_gain: sum }).reset_index() print(weekly.head())resample(W)是Pandas里最实用的时间序列聚合操作W代表按周一周从周日到周六。也可以改成MS按月初生成月度统计。聚合之后画图就简单了左边画里程柱状图右边画时长折线图用双Y轴展示。fig, ax1 plt.subplots(figsize(12, 5)) ax1.bar(weekly[date], weekly[distance_km], color#4C72B0, alpha0.8, labelWeekly Distance (km)) ax1.set_ylabel(Distance (km)) ax2 ax1.twinx() ax2.plot(weekly[date], weekly[duration_min], color#C44E52, markero, linewidth1.5, labelWeekly Duration (min)) ax2.set_ylabel(Duration (min)) plt.title(Weekly Riding Overview) plt.tight_layout() plt.savefig(weekly_overview.png, dpi150)双Y轴图在这类分析里效果很好因为里程和时长的量纲完全不同放在同一Y轴会导致某一条曲线被压扁。使用twinx()创建共享X轴的第二个Y轴两个指标的波动都能完整呈现。3.3 心率区间分布与速度-心率散点图骑行训练里心率区间是非常核心的指标。不同心率区间对应不同的训练强度有氧基础、无氧耐力、最大摄氧量区间等。用直方图统计一次骑行中各心率区间的时长占比比看平均心率更有意义。# 定义心率区间 bins [0, 120, 140, 160, 180, 220] labels [120, 120-140, 140-160, 160-180, 180] df[hr_zone] pd.cut(df[heart_rate], binsbins, labelslabels) zone_counts df[hr_zone].value_counts().sort_index() zone_percent zone_counts / zone_counts.sum() * 100 print(zone_percent)pd.cut()用来将连续的心率值按区间切分生成一个分类变量。然后用value_counts()统计每个区间的采样点数量除以总采样数得到百分比。这个占比就近似等于在各心率区间内骑行的时间占比。画图时我习惯用饼图或横向柱状图。饼图直观柱状图更精确看个人偏好。zone_percent.plot(kindbarh, color[#55A868, #4C72B0, #C44E52, #8172B2, #CCB974]) plt.xlabel(Percentage (%)) plt.title(Heart Rate Zone Distribution) plt.tight_layout() plt.savefig(hr_zone_distribution.png, dpi150)速度-心率散点图则可以用来观察不同速度下的心率反应。散点图的每个点代表一个采样时刻X轴是速度Y轴是心率。如果发现同样的速度下心率越来越高说明疲劳累积明显。plt.scatter(df[speed_smooth], df[heart_rate], s2, alpha0.4, color#1f77b4) plt.xlabel(Speed (km/h)) plt.ylabel(Heart Rate (bpm)) plt.title(Speed vs Heart Rate) plt.tight_layout() plt.savefig(speed_hr_scatter.png, dpi150)alpha0.4和s2是调节散点密集程度的两个参数。骑行数据动辄几千个点透明度不给低一点的话图形会糊成一团黑色。调低点的大小和透明度后点的密度分布反而更清晰密集区域颜色更深稀疏区域较浅等于额外显示了一层密度信息。4. 完整实操流程与关键代码实现4.1 环境准备与依赖安装这个项目只依赖Pandas和Matplotlib安装非常简单。我比较推荐用清华源加速下载尤其是国内网络环境下直接从官方PyPI拉取大包会慢得让人怀疑人生。pip install pandas matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple安装之前可以先确认Python环境是否已有了这两个库import pandas as pd import matplotlib.pyplot as plt print(pd.__version__) print(matplotlib.__version__)如果报ModuleNotFoundError说明还没安装。用上面那行pip命令装好就行。注意不要在系统级Python环境里乱装一堆包强烈建议用虚拟环境python -m venv venv否则以后项目多了依赖版本冲突会非常痛苦。我早期吃过这个亏全局环境里装了一堆包最后Pandas和NumPy版本对不上被迫重装环境损失了一个下午。4.2 中文字体配置解决图里汉字变方块的问题Matplotlib默认字体不支持中文不加配置画出来的图坐标轴和标题里的中文全变成小方块。这个坑几乎所有入门者都踩过但解法很简单。import matplotlib.pyplot as plt # 查看当前系统支持的字体 # 建议选支持中文的字体如SimHei、Microsoft YaHei plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei, PingFang SC] plt.rcParams[axes.unicode_minus] False # 解决负号显示为方块的问题axes.unicode_minus也必须设置否则坐标轴上的负号会显示成乱码或方块。这个配置全局生效放在脚本最前面执行一次即可。如果你的系统里没有中文字体比如某些精简版Linux服务器可以手动下载一个开源中文字体如思源黑体放到Matplotlib的字体目录具体位置用matplotlib.get_cachedir()和matplotlib.get_data_path()查询。不过我更多时候是直接在macOS或Windows本地跑原生就带中文字体很少需要折腾服务器端。4.3 横坐标日期标签太密集怎么处理骑行数据的X轴是时间如果绘图范围是三个月Matplotlib默认会用AutoDateLocator自动选刻度但有时候它选出的刻度密度很高标签全挤在一起完全看不清。这时候需要手动控制刻度密度。import matplotlib.dates as mdates fig, ax plt.subplots(figsize(14, 6)) ax.plot(weekly[date], weekly[distance_km], markero) # 设置X轴为主刻度每隔两周显示一次 ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval2)) # 设置日期标签格式 ax.xaxis.set_major_formatter(mdates.DateFormatter(%Y-%m-%d)) plt.xticks(rotation45) plt.tight_layout() plt.savefig(weekly_distance_with_dates.png, dpi150)mdates.WeekdayLocator(interval2)意思是每两周放一个主刻度DateFormatter控制标签显示格式为“年-月-日”。最后plt.xticks(rotation45)把标签旋转45度避免相邻标签重叠。这三个组合基本能解决90%的时间标签过密问题。如果想让X轴更清爽还可以把日期格式简化成“%m-%d”只显示月份和日期。这个视图表用途而定如果只看趋势而非具体日期简化后视觉压力小很多。5. 实战中遇到的典型问题与排查记录5.1 时间解析报错Parsing dates failed我一开始处理Strava导出的数据时遇到过ValueError: time data ... does not match format的报错。后来检查发现文件里有几行时间格式和大多数行不一样比如某一次GPS冷启动后记录的时间多了毫秒后缀为2026-05-14 07:23:11.234而大部分行没有毫秒。pd.to_datetime()在格式不一致时就会报错。后来我换成formatmixed参数Pandas 2.0支持或者干脆用errorscoerce把解析失败的行置为NaT再处理df[recorded_at] pd.to_datetime(df[recorded_at], errorscoerce) df df.dropna(subset[recorded_at])这样即使有个别脏数据也不会中断整体流程。做数据处理时errorscoerce是每个Pandas使用者都应该记住的参数它把解析异常变成缺失值让管道继续跑下去最后再统一清理。5.2 图表布局混乱子图标题重叠、图例覆盖数据画单次骑行分析的三联图时最开始我没用tight_layout()结果三张子图之间的标题和Y轴标签互相叠在一起丑得没法看。plt.tight_layout()的作用是自动调整子图间距让各子图的标题、标签、注释都能完整显示不重叠。这是Matplotlib最省心的布局方案。如果子图数量多或结构复杂tight_layout()调整效果有限可以用plt.subplots_adjust(hspace0.3, wspace0.2)手动设置子图之间的高度和宽度间距。我在项目里踩过最深的一个坑是图例legend()默认放在右上角结果正好压住了速度曲线的最高段导致关键峰值被遮挡。后来习惯性在调用legend()时显式指定位置ax.legend(locupper left, framealpha0.5)framealpha0.5让图例背景半透明即使遮挡也不会完全盖住数据曲线。5.3 大数据量绘图卡顿几万行数据画出来太慢如果你累计了一整个骑行季的数据几万行不算夸张。Matplotlib直接画几万个点的散点图虽然不至于崩溃但渲染会有明显卡顿。更关键的是图里点的密度非常大之后视觉上根本区分不出细节反而失去了分析价值。我的解决方案有两个。第一对于散点图用rasterizedTrue将图层栅格化为位图这样向量图中的散点部分不会过度消耗渲染资源plt.scatter(df[speed_smooth], df[heart_rate], s2, alpha0.4, rasterizedTrue)第二对采样点进行降采样。Pandas的sample()或resample()都能做到。比如心率数据每秒钟采一次样画图时降采样到每10秒一个点视觉趋势基本不变渲染速度却快很多df_downsampled df.resample(10S, onrecorded_at).mean().dropna()降采样到10秒后要注意某些短时间的峰值可能会被平均抹平所以降采样适合看整体趋势不适合做精确的峰值分析。5.4 数据清洗顺序不规范导致结果失真我还犯过一个比较隐蔽的逻辑错误先做了缺失值插值再过滤异常速度值。结果在插值时把GPS漂移造成的异常速度也一起“平滑”进了相邻点导致过滤异常值时部分正常数据被误删。现在我的清洗顺序严格遵守下面这个流程先读入并检查列类型。过滤明显物理不可达的异常值如速度超过安全上限。再对滤波后的数据做缺失值插值。最后做滑窗平滑。这个顺序的核心逻辑是先把脏数据和真实数据彻底分离再做填充和平滑避免把噪声当成信号传播到周围点。再次强调这个顺序能避免很多数据分析中的隐性错误。6. 进阶扩展往量化分析方向走的机会骑行的数据可视化做完之后很多人会问下一步还能做什么。我个人的经验是两个方向关联分析和预测建模。关联分析是找数据之间的关系比如速度和功率的线性回归、心率和速度在不同坡度下的变化规律。这些在Pandas里用corr()就能做简单版本重一点的可以上scipy.stats或sklearn.linear_model做回归拟合。预测建模则是基于历史骑行数据用机器学习模型预测合理的骑行时间、训练后的恢复周期等。不过这一步的主要工作量不在建模而在特征工程——三维速度向量、坡度变化率、训练负荷累积量等特征构造和清洗占据了绝大部分时间。Pandas的rolling、shift、groupby操作几乎可以覆盖所有特征工程的实现需求这也是Pandas在数据科学链路中不可替代的原因之一。以我目前的使用体验PandasMatplotlib这一套组合在处理骑行数据这种中量级时间序列数据时性能和灵活性都足够。真要跑到上百万条记录再考虑DuckDB或polars也不迟现阶段完全没必要为了“大数据”而大动干戈。工具选型服务于实际场景能用剪刀解决的切削问题不必上电锯。做数据分析更重要的是养成好习惯拿到数据先看info()和describe()清洗前后都保留一份原始备份每一步操作都明确知道原因。这些习惯比我在这里写的任何具体代码都管用。希望这篇分享能帮你在自己的骑行数据里挖出点有意思的规律哪怕只是为了搞清楚“上个月到底为什么那么累”这套流程也够用了。