资讯动态

Python骑行数据分析:Pandas与Matplotlib实战指南

发布时间:2026/10/9 6:30:14 来源:尧图企业网站定制
1. 骑行数据从哪来文件格式与数据结构拆解每次骑完车码表或者手机App都会自动生成一条记录但大多数人只是看一眼总里程、平均速度就把它丢在云端了。这其实有点可惜因为骑行数据里面藏着非常多的信息比如心率变化规律、功率输出曲线、爬坡时的节奏感甚至是训练状态和疲劳度的变化。我大概是从三年前开始认真对待自己的骑行记录当初就是嫌码表的官方统计太“粗”平均速度、平均心率这种指标根本看不出中途的体能波动所以才决定自己动手拿 Python 来做一套骑行数据分析流程。这篇文章不是写给纯代码大神的而是给那些会骑一点车、也想看懂数据背后的故事的人。你不需要有很强的编程基础只要装好了 Python 和常用的库会打开终端或者 PyCharm就能跟着把一套完整的骑行数据分析流程跑起来。用到的主角就是 Pandas 和 Matplotlib一个负责数据处理一个负责把结果画成图。先说数据来源这决定了后面的所有分析能不能落地。最理想的情况是直接用骑行码表或者 GPS 运动手表导出的原始记录文件。Garmin、Wahoo、Bryton 这些主流码表一般都能导出 FIT 或 GPX 文件而国产的一些App比如行者、黑鸟也支持导出 CSV 文件。FIT 文件是一种二进制格式解析起来略麻烦通常需要借助fitparse这样的第三方库GPX 则以 XML 格式存放轨迹点用xml或者pandas配合xml.etree解析也很容易而 CSV 是最简单的直接用 Pandas 的read_csv就能读进来。我之前用得最多的是 Garmin 码表它导出的 FIT 文件里包含的数据流比较全包括时间戳、经纬度、海拔、心率、速度、功率、踏频等。不过如果不是那种专业训练场景从手机 App 导出 CSV 也够用了。CSV 的好处是字段名一目了然方便新手理解数据长什么样坏处是不同 App 导出格式不一致字段名五花八门有的叫speed有的叫velocity还有的直接叫km/h。所以拿到文件以后第一件事不是急着画图而是把数据读进来仔仔细细看一眼结构。我在写这篇实战解析的时候用的是一段模拟的骑行数据。为什么要用模拟数据因为真实数据涉及个人隐私而且不同设备导出来的效果差异很大我希望能把通用流程讲清楚而不是教你只适合某一个App的“死方法”。模拟数据的设计参考了一趟典型的长距离骑游数据时间跨度大约 170 分钟包含 GPS 坐标、心率、速度、海拔、踏频这些主要字段中间还故意埋了一些脏数据比如缺测值、重复时间戳、瞬时速度突变等这样正好能演示清洗过程。读取数据的方式很简单核心代码大概长这样import pandas as pd df pd.read_csv(cycling_data.csv) print(df.head()) print(df.info())不过有个细节值得注意read_csv有很多时间相关的坑。如果一个 CSV 里时间列是字符串格式比如2023-05-01 08:30:15那么默认读进来是object类型后面想按时间排序、切片都很麻烦。所以最好在读取时就直接指定时间列甚至把 ISO 格式字符串解析为 Pandas 的datetime64类型df pd.read_csv( cycling_data.csv, parse_dates[timestamp], index_col[timestamp] )如果你的 CSV 里时间列格式不标准比如直接是一个整数字符串20230501083015那就得在读取后再用pd.to_datetime指定format参数。这一步别偷懒前面时间处理好了后面所有时间序列分析都会顺畅很多。数据里常见的字段还可以列个表方便按图索骥字段名含义单位常见异常情况timestamp记录时间戳秒或日期时间重复、乱序、缺秒latitude / longitudeGPS经纬度度漂移、零点、无效值altitude海拔米气压计漂移、负值speed即时速度公里/小时突变尖峰、零值过多heart_rate心率次/分钟缺失、针尖样刺突cadence踏频转/分钟静止时为0power功率如装备功率计瓦特缺失、负值少见很多人读进来一看有几个空值就心慌其实没必要。数据分析的过程本来就是“数据质量普查——清洗——挖掘——可视化”这样的循环没有哪批数据是完美的。而且恰恰是这些脏数据能让你真正理解 Pandas 的很多函数到底在干什么。1.1 环境准备别在安装库上浪费太多时间搞数据分析最烦的不是代码逻辑而是环境问题。我最初用 Python 写脚本的时候经常遇到在 Jupyter Notebook 里import pandas报错然后发现是当前环境没装或者装了 32 位版本导致内存崩溃。所以先说环境。推荐直接用 Anaconda 管理 Python 环境省心。Anaconda 自带 Jupyter Notebook 和 Spyder装好后只需要装pandas和matplotlib两个核心库。如果你已经用 Python 原生环境那就在终端里执行pip install pandas matplotlib如果你的网络比较慢可以用国内镜像源清华、阿里云的都行速度快很多pip install pandas matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple装好之后验证一下版本import pandas as pd import matplotlib print(pd.__version__) print(matplotlib.__version__)版本不一致可能会造成一些绘图细节差异但核心逻辑大体一样不必强求最新。我自己的习惯是pandas用 1.x 或 2.x 都行matplotlib保持 3.5 以上即可。如果你的版本太旧建议升级一下因为老版本在plot()的时间序列处理上有一些已知的坑。很多人在“安装”这步就被劝退一方面是对命令行不熟悉另一方面是以前装库踩过坑。我的建议是先创建虚拟环境比如python -m venv cycling_env # Windows 下激活 cycling_env\Scripts\activate.bat # Mac/Linux 下 source cycling_env/bin/activate在这个环境里再安装依赖以后即使把环境搞坏了删掉重建也很简单不会影响系统 Python。这一步虽然看起来啰嗦但对长期搞数据分析的人非常重要。1.2 数据探索先别急着画图用 describe 感受数据拿到数据表后我建议先用df.info()看字段类型、内存占用再用df.describe(includeall)看数值分布。这里有个小技巧describe对时间类型和 object 类型同样有效能告诉你每个字段有多少个唯一值、出现频率最高的值是什么。如果速度列出现最大值 90多公里每小时我们得想想这是不是在陡下坡如果是在平路这个值很可疑可能是 GPS 漂移或者设备故障。如果心率列最小值是 0那大概率是设备抽搐0 值要当作缺失值处理。这些判断靠的就是describe给你的极值和分位数。我常用的一段检查代码# 看看每一列的缺失情况 print(df.isnull().sum()) # 关键字段的统计描述 print(df[[speed, heart_rate, altitude, power]].describe())缺失值比例超过 2% 的列我就不会把它当作核心分析字段缺失值比例在 1% 以内的可以做填充或剔除。如果不做这一步后面画出来的速度曲线可能带着一堆锯齿样的空洞非常难看而且一些计算函数比如滚动平均会根据 NaN 值把结果变成 NaN导致整条曲线断掉。2. 用Pandas完成数据清洗脏数据是怎么拖垮图表的清洗数据是整条流程里最花时间的一步。很多教程喜欢直接跳过但从真实码表导出的数据几乎不可能干干净净我这里结合我自己的经历把最常见的几类问题说透。先把清洗流程分为四个步骤去重、排序、处理缺失值、处理异常值。实际上每步之间还有关联。比如GPS 信号不好的时候记录器可能在同一秒写入两条记录造成重复时间戳这种重复如果不先剔除后面按时间索引来resample就会报错。再比如骑车停车买水时速度变成0心率可能降到50左右这是正常现象但如果速度一直为0且持续了5分钟那可能是码表没收到GPS信号这段数据的经纬度坐标会是同一个点其实属于无效数据段。2.1 去重与排序时间轴是分析的骨架假设df已经以时间戳为索引先排序再检查索引是否有重复df df.sort_index() dup_mask df.index.duplicated(keepfirst) print(f重复时间戳数量: {dup_mask.sum()}) # 如果存在重复则保留每个时间戳的第一条记录 df df.loc[~dup_mask]有些数据源的时间戳不是索引而是普通列那就先sort_values([timestamp])再drop_duplicates(subset[timestamp])。看到dup_mask.sum()是 0也不能放松因为还存在时间戳顺序错乱的情况。排序之后你就可以顺手画一下时间间隔的直方图确认数据采样的频率。一般码表是每秒钟记录一次但也不是完全均匀偶尔会跳 2~3 秒问题不大。如果出现间隔超过 60 秒的那就要注意了那可能是记录器休眠或者 GPS 丢失分析的时候最好把这段数据单独剔除。2.2 缺失值处理不是所有空值都要填在df.isnull().sum()的统计里如果海拔列有很少的缺失我通常会选择“线性插值”因为海拔变化本身是连续的缺失值前后两点的连线可以近似还原。速度列的缺失值则不一样速度本身波动大插值可能把静止状态和骑行状态糊在一起所以更稳妥的办法是直接剔除这些缺失值所在的行。如果缺失太多则要考虑使用滚动窗口把前后 20 秒的中位数填进去。具体代码# 海拔线性插值 df[altitude] df[altitude].interpolate(methodlinear) # 心率用前后5个有效值的中位数填充避免单一尖峰 df[heart_rate] df[heart_rate].rolling(11, centerTrue, min_periods1).median() # 速度如果缺失且前后都在0附近则填0否则先保留NaN df[speed] df[speed].mask(df[speed].isna() (df[speed].shift() 0.5) (df[speed].shift(-1) 0.5), 0)这里要特别注意rolling的min_periods参数它允许窗口首尾只有很少数据也能计算。如果没有设min_periods窗口里一旦出现 NaN结果就变 NaN那填充就白做了。2.3 异常值处理识别“尖刺”而不是盲目删除异常值在骑行数据里最常见的形式是速度突然跳到 90 以上然后在下一秒恢复正常这种称为“尖刺”。对 GPS 速度来说尖刺通常因为坐标漂移两个采样点之间距离计算出现异常。对心率来说尖刺是因为心率计受到干扰比如静电跳到一个不可能的值。处理尖刺的方法是“孤点检测”对于连续速度序列如果某个点的速度比前后两个点大超过一个固定的倍数比如 3 倍或者该点的加速度大于某个物理上不可能的阈值比如 5 m/s^2就认为它是尖刺。这里我倾向用滚动中位数作为基准而不是平均值因为平均值容易被尖刺本身拉高。下面这段逻辑适合新手# 计算速度的滚动中位数窗口 med df[speed].rolling(7, centerTrue, min_periods1).median() # 标记那些与中位数差超过20 km/h的点 spike_mask (df[speed] - med).abs() 20 # 将这些尖刺替换为NaN再向前后插值 df.loc[spike_mask, speed] np.nan df[speed] df[speed].interpolate(limit3)上面利用np.nan需要先导入numpy。这套逻辑的好处是简单、容易理解也容易根据你的数据集调整阈值。如果你对自行车运动常识熟悉还可以加入规则如果速度超过 70 km/h 且功率低于 100W且不是长下坡那么大概率是数据错误。清洗完之后一定要重新检查describe()尤其看最大值和最小值是否变得合理了。如果仍然离谱再检查一下阈值设置。这个环节做得越扎实后面画图的时候越省心。2.4 数据重采样把1秒数据变成1分钟数据原始码表数据通常每秒一条但要看一小时骑行的整体趋势每秒的数据点太密了画出来的折线全是毛刺。这时就需要重采样比如把每秒的数据聚合成每 30 秒或每 1 分钟一条。Pandas 的resample就是为此设计的。一个典型的聚合逻辑是速度取平均海拔取平均心率取平均而累计爬升则对海拔的正向增量求和。下面代码演示每分钟聚合# 按分钟重采样使用平均值 minutely df.resample(1T).mean() # 对于心率也可以直接取平均但要注意缺失值 minutely[heart_rate] df[heart_rate].resample(1T).mean()resample之前必须保证索引是 DatetimeIndex且没有重复值所以我们前面才会花那么大力气去重排序。在实际操作中我还喜欢把速度单位从 km/h 保留为原始数值但在绘图时另创建一个speed_ms列用于计算力学相关的指标比如加速度这样更符合工程习惯。重采样还有个隐含的好处把数据量变小后续计算和绘图的效率都会提升尤其是当你一次要处理十几个文件的时候这个优化非常明显。3. 骑行指标计算速度、心率、功率与爬升的秘密清洗好数据之后如果直接画图其实已经能看到波形轮廓了但一个合格的数据分析流程更应该关注一些“有意义”的指标。本节聊的是怎么用基础字段组合出新的指标这些指标才是真正反映骑行表现的。3.1 爬升与坡度海拔变化的隐藏故事海拔数据清洗之后可以直接用diff()算相邻两秒的高度差。考虑到气压计漂移我们可以设置一个阈值只统计净正向爬升即所谓“累计爬升”。在 Pandas 里# 海拔差 df[alt_diff] df[altitude].diff() # 阈值0.5米以上才视为有效爬升 df[climb] df[alt_diff].clip(lower0, upperNone) total_climb df[climb].sum()这里的clip(lower0)把所有负值变成0这样只有上升段被计入。有人会问下坡后回到同样海拔为什么爬升还会累计这是行业里通用做法因为累计爬升本身描述的是“总做功”下坡那段虽然海拔下降但身体并没有获得同样多的休息所以不计负值。如果想计算某段路的坡度可以用海拔差除以水平距离。水平距离可以先由经纬度计算这里顺便说一个简化公式在低纬度小范围内可以用haversine公式但我更喜欢用geopy.distance库如果不想装额外库也可以用等距坐标近似import numpy as np # 假设经度和纬度已经转换为弧度 dlat np.radians(df[latitude].diff()) dlon np.radians(df[longitude].diff()) a np.sin(dlat/2)**2 np.cos(np.radians(df[latitude])) * np.cos(np.radians(df[latitude].shift())) * np.sin(dlon/2)**2 c 2 * np.arcsin(np.sqrt(a)) R 6371.0 # 地球半径 km df[dist_km] c * R经纬度坐标在骑行数据里其实很容易出问题尤其是逆冲 GPS 偶尔漂移几百米所以计算距离之前最好先删除那些经纬度未变的数据点。另外如果你只用码表的distance字段那是最简单的因为码表通常自己已经算好了距离但注意不同码表对距离的定义不同有的是 GPS 距离有的是轮径距离。分析的时候尽量保持字段来源一致。得到距离后坡度就是# 距离转换为米 df[dist_m] df[dist_km] * 1000 df[gradient] df[alt_diff] / df[dist_m] * 100比如海拔每米上涨、水平距离前进10米坡度就是10%。看到大于15%的坡度基本就是很陡的墙了。3.2 心率区间与训练负荷不仅仅是“心跳快慢”心率数据的价值在于反映强度。用一个非常经典的简化模型把心率分为几个区间有氧耐力区最大心率的60%-70%、节奏区70%-80%、阈值区80%-90%、无氧区90%以上。最大心率可以用公式220-年龄估算但更准确的是实测。在分析中我通常会设一个动态最大值比如取我近几年数据的最大心率如果没有就用220-年龄。我们可以用pd.cut给心率分区间max_hr 185 # 假设最大心率 bins [0, 0.6*max_hr, 0.7*max_hr, 0.8*max_hr, 0.9*max_hr, max_hr] labels [Z1, Z2, Z3, Z4, Z5] df[hr_zone] pd.cut(df[heart_rate], binsbins, labelslabels)然后就能统计每个区间的时间占比比如zone_counts df[hr_zone].value_counts() zone_time_ratio zone_counts / len(df) * 100 print(zone_time_ratio)这个百分比直接告诉你一趟骑行有没有达到训练效果。一直待在Z2区练的是基础耐力如果Z4区占比很高那就是强度课需要更长的恢复时间。更进阶的一个概念叫 TRIMP训练冲量它的公式很多这里说一个简洁版# 基于心率的TRIMP简化版 # TRIMP_expr 运动时间(分钟) * (心率储备比例) * 系数 # 这里采用简单的比例法不考虑性别加权 hr_rest 60 # 静息心率 hr_max 185 df[hr_reserve] (df[heart_rate] - hr_rest) / (hr_max - hr_rest) trimp_score df[hr_reserve].clip(lower0).mean() * (len(df) / 60)这种量化指标的意义在于你可以把不同骑行之间的负荷进行比较而不仅仅是看时长。哪怕一次只骑了40分钟如果全程心率高它的训练刺激可能比两小时的有氧骑更大。3.3 功率与踏频如果你的数据里有功率计功率计是很理想的训练工具它的数据能比较客观地反映输出。一个直接指标是“标准化功率”Normalized Power简称NP通常码表自带但如果你想自己实现一套简化版我的建议是先做 30 秒的滚动平均然后去掉异常值再取平均。简化版和真正的NP算法存在差异但作为趋势对比够用了。踏频rpm的作用是帮助判断踩踏效率。一般车手在低踏频下用力较大容易腿部疲劳高踏频对心肺压力更大。通过踏频和功率的结合可以画出“扭矩 vs 踏频”的散点图找到自己最舒服的发力区间。如果数据里没有功率也没有踏频那也完全不影响这套分析框架。用速度加心率已经能回答大多数休闲骑行者的疑问我是不是骑太快了我是不是没有休息好我的体能是不是在下降4. Matplotlib绘制第一版图表快速看清一趟骑行的全貌数据清洗与指标计算完成之后终于到了最直观的环节——画图。Matplotlib 很多人觉得难看但那往往是没用好样式和细节。我们先用最简单的plot画几个基础曲线把流程跑通再慢慢美化。先做好基本设置import matplotlib.pyplot as plt plt.rcParams[figure.figsize] (12, 6) plt.rcParams[axes.grid] True plt.rcParams[font.family] sans-serif # 中文字体按操作系统设置 plt.rcParams[font.sans-serif] [SimHei, Microsoft YaHei, PingFang SC] plt.rcParams[axes.unicode_minus] False # 解决负号显示为方块的问题再看一行最简单的速度图df[speed].plot() plt.show()刚开始可能觉得线条太密一堆锯齿但这没关系。如果只想看大致趋势可以设置艺术样式df[speed].plot(style-, alpha0.7) plt.title(Speed over time) plt.xlabel(Time) plt.ylabel(Speed (km/h))如果要展示一趟骑行我强烈建议把几个关键指标放到一张图里形成“仪表盘”效果。下面细说。4.1 单图多坐标轴速度、心率、海拔的共存问题速度、心率、海拔的单位和量级完全不同放在同一个坐标轴里低量级的就被压迫成一条直线。这时候需要用双 Y 轴也就是twinx()。经典的实现是fig, ax1 plt.subplots(figsize(14, 6)) ax1.plot(df.index, df[speed], colortab:blue, linewidth1.2, labelSpeed (km/h)) ax1.set_xlabel(Time) ax1.set_ylabel(Speed (km/h), colortab:blue) ax1.tick_params(axisy, labelcolortab:blue) ax2 ax1.twinx() ax2.plot(df.index, df[heart_rate], colortab:red, linewidth1.2, alpha0.7, labelHeart Rate (bpm)) ax2.set_ylabel(Heart Rate (bpm), colortab:red) ax2.tick_params(axisy, labelcolortab:red) plt.show()这样处理后蓝线是速度红线是心率。不过如果再加海拔又要再叠加一个轴画面会显得杂乱。更常用的方案是分成上下两个子图速度在上、海拔在下心率用颜色映射到速度线上比如速度曲线用色带的粗细或透明度来表达心率但这有点复杂。对新手来说我建议先做子图等熟悉了再做融合。4.2 用plot()背后的line参数做微调很多人过早使用plotly等交互库反而忽略了 Matplotlib 能给我们的精细控制。比如linewidth控制线宽骑行分析里 1.0~1.5 比较克制2 以上就显得粗鲁alpha控制透明度多线叠加时特别有用避免一条线盖住另一条linestyle支持虚线、点线、实线marker如果是重采样到每分钟的数据点标记可以帮你认刻度。图形交付不只是给程序跑更是给人看。尽量使用无助记符但清晰的配色比如tab10调色板它能保证通过色盲测试也算比较友好的默认配色。4.3 中文显示与清晰度让图表可以放进训练日志我在中文字体上踩过不少坑。在 Linux 服务器或者 Docker 容器里没有中文字体时plt.xlabel里的中文会显示成一个个方框。解决办法是在系统里安装中文字体例如在 Ubuntu 上sudo apt-get install fonts-noto-cjk然后在 Python 里重新设置字体路径import matplotlib.font_manager as fm zh_font fm.FontProperties(fname/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc) plt.rcParams[font.sans-serif] [zh_font.get_name()]保存图片时注意plt.savefig(cycling_overview.png, dpi150, bbox_inchestight)dpi150足够在电脑或手机上看如果是要打印建议 300。bbox_inchestight会自动裁剪留白避免四周多出大片空白。5. 从能看到耐看用子图构建骑行报告模板真正的骑行数据分析报告往往不是单张图而是一整页包含速度、心率、海拔、功率、踏频的内容。使用 Matplotlib 的subplots可以制作这样的多面板报告。下面是我的推荐结构上面一排放“速度海拔”下面一排放“心率功率”如果还有踏频再单独占一行。子图的关键是共享 X 轴时间轴这样你用鼠标点击某一段时能同时看到各指标在该时间点的联动变化虽然 Matplotlib 在静态图里不能交互但共享坐标轴可以保证图表之间严格对齐。5.1 创建子图与共享坐标轴fig, axes plt.subplots(3, 1, sharexTrue, figsize(14, 10), gridspec_kw{height_ratios: [3, 3, 2]})height_ratios可以设定不同行的高度比。速度重要占大一点踏频相对次要占小一点。第一行画速度第二行画心率第三行画海拔这是比较常规的做法。如果想让速度曲线上叠加一段“配色”可以用fill_between把速度低于某个阈值比如 20 km/h的区域涂上浅色突出慢速路段axes[0].plot(df.index, df[speed], colorsteelblue, lw1) axes[0].fill_between(df.index, 0, 20, colorgray, alpha0.15, label20km/h)海拔可以画成面积图或填充图这样更容易看出坡段axes[2].plot(df.index, df[altitude], colorgreen, lw1) axes[2].fill_between(df.index, df[altitude].min(), df[altitude], colorgreen, alpha0.2)这样整张图就变成了一个“骑行仪表盘”一眼就能看出哪段是爬坡、哪段速度快、哪段心率高。5.2 在图上标注关键信息annotate与hlines前面那些图只是把数据画出来了但看的人依然不知道“爬坡起点在哪”。我们可以使用plt.annotate把关键事件标记出来。比如我们可以检测出海拔最高的点然后用箭头标注max_alt_idx df[altitude].idxmax() max_alt_val df.loc[max_alt_idx, altitude] axes[2].annotate( fPeak {max_alt_val:.0f}m, xy(max_alt_idx, max_alt_val), xytext(max_alt_idx, max_alt_val 20), arrowpropsdict(arrowstyle-, colork), fontsize10 )再比如在平均速度位置画一条水平虚线avg_speed df[speed].mean() axes[0].axhline(yavg_speed, colorred, linestyle--, labelfAvg {avg_speed:.1f} km/h) axes[0].legend()这种水平参考线很有用因为人脑在观看曲线时需要一个参照系否则根本看不出某一时刻是高于平均还是低于平均。5.3 将 KPI 文字放到图表内用text构建报告摘要报告如果能在图表角落显示总距离、总时长、平均心率、平均速度这些数据会专业很多。我常用一个文本框bbox来实现kpi_text ( fDistance: {total_distance:.1f} km\n fDuration: {duration_min:.0f} min\n fAvg HR: {avg_hr:.0f} bpm\n fAvg Speed: {avg_speed:.1f} km/h ) axes[0].text(0.02, 0.98, kpi_text, transformaxes[0].transAxes, verticalalignmenttop, bboxdict(boxstyleround, facecolorlinen, alpha0.7))transformaxes[0].transAxes使坐标落在轴区域的比例上无论图像缩放大小都不会跑出画面。bbox加底色能在复杂背景下保持可读性。这种组合之后你就能把多张图拼成一张训练日志图可以作为朋友圈配图也可以存档做个人训练记录。每次骑行结束运行同一个脚本就能自动生成最新的一次报告这就是数据自动化的雏形。6. 多骑行数据的对比分析如何发现训练趋势单次骑行分析只能回答“这次骑得怎么样”。但骑行数据的真正价值需要时间维度两周前和今天骑同样路线速度有没有提升心率有没有下降一周的累计爬升是高还是低回答这类问题就需要把多趟骑行数据合并起来分析。6.1 批量读入多个文件并合并假设你有一个rides/目录里面有很多ride_2023_05_01.csv这样的文件。推荐写一个函数统一读取再加一个ride_id列标识来源import glob def load_ride(filepath): df pd.read_csv(filepath, parse_dates[timestamp]) df[ride_id] filepath.split(/)[-1].split(.)[0] return df all_files glob.glob(rides/*.csv) ride_list [load_ride(f) for f in all_files] # 纵向合并所有骑行记录 all_data pd.concat(ride_list, ignore_indexFalse)注意不同骑行的时间戳在坐标轴上可能重叠合并后直接用groupby按ride_id分组统计即可。千万不能直接拿合并后的时间索引做resample那会把不同天的时间点揉在一起造成逻辑混乱。6.2 按骑行次数聚合用 Pivot Table 呈现变化最简单也最实用的多骑行分析是每天或每周的平均速度和平均心率的变化趋势。先给每个骑行记录增加一个“日期”列df[date] df.index.date再对骑行进行分组统计daily_summary all_data.groupby(date).agg( total_distance_km(distance, max), avg_speed_kmh(speed, mean), avg_hr(heart_rate, mean), total_climb(altitude, lambda x: x.diff().clip(lower0).sum()) )这里用max取距离是因为单次骑行中距离字段是累积的取最后一个值才是总里程。如果你在数据清洗阶段已经按天分组就可以简化。然后画一个简单的折线图观察趋势daily_summary[avg_speed_kmh].plot(markero, linewidth1.5)如果你发现速度下降而心率上升那说明疲劳累积或者恢复不足。结合平均心率的变化能比只看速度更有判断依据。6.3 训练负荷趋势用滚动平均值消除噪音单次的指标波动很大看趋势最好使用 7 天滚动平均。Pandas 里一行daily_summary[maintenance_load] daily_summary[total_distance_km].rolling(7, min_periods3).mean()这里的min_periods允许前面数据不足7天时也能计算出部分平均值。骑行数据分析里滚动窗口的选择根据训练周期来定7天适合一周循环28天适合月度对比。时间跨度的选择也有讲究。如果你一个月只骑4次那日聚合数据点太少建议按周聚合。Pandas 的resample(W)可以按周汇总但要先确保日期列是 DatetimeIndex。6.4 多线条叠加与填充区间在对比多次骑行时如果只是把多次的速度曲线画在同一张图里线会乱成一团。我的经验是先画一条代表“平均趋势”的粗线再画一个浅色带表示最快和最慢的范围用fill_between想强调某一次骑行时用另一条明显的颜色覆盖。fig, ax plt.subplots(figsize(12, 6)) # 假设有三个速度序列 for label, s in speed_lines.items(): ax.plot(s.index, s, alpha0.3, linewidth0.8, labellabel) # 填充整体范围 lower pd.concat(list(speed_lines.values())).groupby(level0).min() upper pd.concat(list(speed_lines.values())).groupby(level0).max() ax.fill_between(lower.index, lower, upper, alpha0.2, colorgray)这样既保留了每条曲线的形态又能看出整体区间。不过要提醒骑行时速度和时间的关系不是严格对齐的不同趟骑行的同时间点不具备可比性所以这种叠加通常用在“同一路线的对比”上。如果路线不同建议用标准化时间轴比如将每次骑行按百分比归一化后再画。7. 进阶可视化从静态图到动图/交互地图PandasMatplotlib的边界基础图表能满足日常检查需求但如果你想把骑行路线发到社交平台或者想动态展示一次骑行的过程静态折线图就不够了。本节聊聊怎么用 Matplotlib 做出动图以及什么时候该换用更合适的工具。7.1 地理轨迹经纬度坐标的可视化Pandas 本身不能画地图但可以把经纬度数据整理好再用 Matplotlib 的plot画经纬度坐标。如果你的骑行区域很小直接把经纬度做横纵轴就能看到路线形状。但这样比例不对且没有地理底图来标出道路所以只适合平滑轨迹。更实用的方式是结合folium或plotly绘制交互地图。folium可以生成 HTML 文件在网页上缩放、点击效果非常好。核心代码import folium m folium.Map(location[df[latitude].mean(), df[longitude].mean()], zoom_start13) points list(zip(df[latitude], df[longitude])) folium.PolyLine(points, colorred, weight3, opacity0.8).add_to(m) m.save(ride_map.html)不过标题里说的是 Matplotlib所以我更建议先把轨迹在 Matplotlib 里画出来比如plt.figure(figsize(8, 8)) plt.plot(df[longitude], df[latitude], linewidth2) plt.xlabel(Longitude) plt.ylabel(Latitude) plt.gca().set_aspect(equal) plt.show()set_aspect(equal)很重要否则经纬度比例失真一条直线可能被拉成曲线。如果你左看右看觉得轨迹怪怪的多半是 Y 轴比例和 X 轴比例不一致导致。7.2 用 Matplotlib 保存 GIF 动图Matplotlib 虽然不能直接生成动画文件但有一个matplotlib.animation.FuncAnimation接口可以按帧更新图形。下面是一段很经典的动画示例让一个小圆点沿着速度曲线移动表示骑行时间的推进。from matplotlib.animation import FuncAnimation fig, ax plt.subplots(figsize(12, 6)) line, ax.plot([], [], lw2) point, ax.plot([], [], o, colorred) def init(): ax.set_xlim(df.index.min(), df.index.max()) ax.set_ylim(df[speed].min()-5, df[speed].max()5) return line, point def update(frame): line.set_data(df.index[:frame], df[speed].values[:frame]) point.set_data(df.index[frame-1], df[speed].values[frame-1]) return line, point ani FuncAnimation(fig, update, frameslen(df), init_funcinit, interval50) ani.save(ride_animation.gif, writerpillow)保存 GIF 需要 Pillow 库一般 Anaconda 环境自带。如果你的数据每秒一条170分钟的数据有约 10000 个点全部一帧一帧保存会非常慢而且 GIF 文件体积巨大。建议先重采样到每 10 秒一个点再生成动画控制在 1000 帧以内。动画的展示价值在于时间变化比如位置沿着地图前进时速度曲线同步增长。这个效果对于发社交媒体来说很讨喜但它更多是“演示”而不是“分析”。做数据分析时我更多使用交互式图表比如 Plotly 的scatter_mapbox可以在地图上拖动时间滑块效果比 GIF 好得多。所以如果只是图好玩用 Matplotlib 自己折腾如果有分析汇报需求还是建议学一下 Plotly。7.3 何时该换用其他库PandasMatplotlib 的优势与边界Pandas 和 Matplotlib 的长处是数据处理能力强、生态完善、画出来的图适合存档和打印。但是两者的交互能力都比较弱。如果你想拖动鼠标去看任意一分钟的详细数据那需要换用 Plotly、Bokeh 或 pyecharts。plotly的express模块几乎一行代码就能画出和 Matplotlib 类似的图并且自动支持缩放、悬停标签。对于要交给领导看的报告我会用 Plotly 快速生成 HTML 文件但对于个人训练笔记还是静态图稳妥一眼就能扫完。这里给一个选择建议如果只是想自己看用 Matplotlib 完全够如果要嵌入式地放在博客或网站里可以导出 SVG 或 PNG如果要做交互探索再学 Plotly。切忌一开始就追求大而全结果每种库都只会一点皮毛。8. 实战中容易踩的坑我的调试手记我几乎每次写新的分析脚本都会遇到问题。这里挑三个最典型的把当时的报错信息和解决过程完整讲一遍也许能帮你少走弯路。8.1 时间列解析失败parser error与Format陷阱有一次我拿到一个CSV文件打开一看确实是标准 ISO 格式的时间但pd.to_datetime就是报错ParserError: Unknown string format: 2023-05-01-08:30看了几秒才反应过来时间是2023-05-01-08:30分隔符不是空格而是短横线。这种情况最直接的办法是手动指定格式df[timestamp] pd.to_datetime( df[timestamp], format%Y-%m-%d-%H:%M )如果你对时间格式不熟悉可以用guess的思路先把前几行打印出来肉眼观察再选用对应格式。还有一次是时间列是09:30:55.123456带微秒默认也能解析但效率略慢。如果字段很多我建议先把这一列单独to_datetime然后替换原列别读表的时候指定。除了格式还要小心时区问题。如果你用了 UTC 时间记录 GPS但码表界面显示的是本地时间那么按本地时间做可视化会出现偏移。处理方式是df[timestamp] df[timestamp].dt.tz_localize(UTC).dt.tz_convert(Asia/Shanghai)或者直接读取时用utcTrue再转。如果不想输出带时区的时间索引就用.dt.tz_localize(None)去掉时区信息这样后续resample更简洁。8.2 坐标轴刻度过密时间轴标签黏在一起绘制长时间序列时Matplotlib 默认会自动生成刻度但有时候间隔太小几十个标签挤在一起完全看不清。解决方法是手动设置MaxNLocator或直接指定刻度间隔import matplotlib.dates as mdates ax.xaxis.set_major_locator(mdates.MinuteLocator(interval30)) ax.xaxis.set_major_formatter(mdates.DateFormatter(%H:%M)) plt.setp(ax.xaxis.get_majorticklabels(), rotation45)更常用的是每 30 分钟显示一个刻度正好符合一趟长骑行的读数习惯。如果你的骑行只有 20 分钟那用MinuteLocator(interval5)更合适。为了避免每次手动调整我通常写一个函数def set_time_axis(ax, df, interval_min30): ax.xaxis.set_major_locator(mdates.MinuteLocator(intervalinterval_min)) ax.xaxis.set_major_formatter(mdates.DateFormatter(%H:%M)) plt.setp(ax.xaxis.get_majorticklabels(), rotation45)rotation45是为了避开长标签彼此重叠。如果你的时间标签是05-01 08:00这种间隔可能需要更稀疏。8.3 大文件读取慢与内存优化当你把一年三百多次骑行数据合并成一个 DataFrame 时可能出现内存占用飙高分析变慢。这时候有几个常用技巧读取 CSV 时指定dtype比如把speed设为float32heart_rate设为int8能省一半内存。只读取需要的列用usecols参数。如果需要聚合运算先groupby再合并不要一开始就做笛卡尔积。一个很实际的例子是GPS 经纬度列在分析时通常用不到高精度算术float64完全可以降到float32。使用pd.to_numeric结合downcastfor col in [speed, heart_rate, altitude, power]: df[col] pd.to_numeric(df[col], downcastfloat)对latitude和longitude也这样处理能在数据量大的时候明显提速。如果文件实在太大超过内存建议使用polars或者dask但这对新手不算友好。能用压缩文件格式parquet存储中间结果是更优雅的做法读取速度快很多体积也更小。9. 代码封装与复用把分析脚本整理成工具包当你跑通了单次骑行分析会发现下一次骑行只是换了个数据文件代码几乎不用改。为了以后懒惰我们应该把分析过程封装成函数或者写成一个可复用的脚本。9.1 定义核心处理函数最简单的做法是把清洗和计算逻辑封装到一个process_ride函数里def process_ride(filepath): df pd.read_csv(filepath, parse_dates[timestamp]) df df.sort_values(timestamp).drop_duplicates(subsettimestamp) df df.set_index(timestamp) # 缺失值处理等 df[altitude] df[altitude].interpolate() # 计算距离、累计爬升等指标 df[dist_km] calculate_distance(df) df[climb] df[altitude].diff().clip(lower0) return df函数的好处是清晰每次调用返回一个已经完成基本清洗的 DataFrame。但函数不要写得过于庞大否则后面改一个参数就要跑一遍所有流程调试困难。建议拆成load_ride,clean_ride,add_features三个函数再串起来。9.2 配置文件和批处理如果你经常分析不同来源的数据最好把可变的参数放到一个配置字典里config { rolling_hr_window: 11, speed_spike_threshold: 20, climb_min_gap: 0.5, time_zone: UTC, }然后用**config传给处理函数。这样做能避免在代码里到处散落魔法数字。批处理多文件时可以用循环for f in all_files: ride process_ride(f) ride.to_parquet(f.replace(.csv, .parquet))处理完所有文件后再统一读取parquet做后续分析。这比每次都要重新清洗快得多尤其是你要反复调参数的时候。9.3 Notebook 还是脚本我的经验是探索阶段用 Jupyter Notebook因为可以一边写一边看图表直接嵌在下方方便迭代等流程稳定下来需要自动出报告就改成 Python 脚本配合 cron 或者计划任务定时执行。用 Notebook 时有几个技巧把“清洗”部分放到一个 cell“绘图”放到另一个 cell但不要在.py和.ipynb之间反复拖动容易错。不要把所有结果都打印出来尤其是 DataFrame 的前 100 行那样输出太长反而打扰。保存 Notebook 前记得清空不必要的输出文件体积会小很多。脚本化之后的执行就像跑一个简单的命令python analyze_rides.py --input rides/ --output report/这样每次骑完车直接把码表文件丢到目录运行一下就能得到一份 PDF 或 PNG 报告非常有成就感。10. 最后再说几句数据分析之外的建议写了这么多代码层面的细节回到骑行本身。数据可视化是手段不是目的。我见过有人花大量时间折腾配色却从没关心过心率区间是否合理也有人把平均速度调得特别好看却忽略了湿度、风向、路面这些环境因素。数据分析真正能帮我们做的是发现规律而不是制造焦虑。比如我在连续几周的骑行数据中发现自己的心率在爬坡开始后总是前 3 分钟快速升高然后突然降下来原因极大概率是踩踏节奏不均匀前一段过猛。通过可视化我调整了爬坡时的低档高踏频习惯下个周期的数据明显稳定了。这种收获比单纯知道“今天骑了200公里”要有意义得多。另外别忘了数据的隐私问题。你的骑行轨迹意味着你的家庭住址和日常生活习惯。如果要把数据分享到网上或在 Instagram 上晒轨迹建议先隐藏起点和终点附近几百米的数据。最后一个小建议一步步来。即使你现在只会用read_csv和plot()也足够完成一次像样的骑行分析了。不要从一开始就奢望去复现码表公司那套复杂的算法。先把自己骑得最爽的一次数据跑通生成一张能看的图再逐步加入更多指标让报告越来越丰富。这个逐步迭代的过程也是你对“数据理解”逐步加深的过程。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑