☰
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踏频转/分钟静止时为0
power功率(如装备功率计)瓦特缺失、负值(少见)

很多人读进来一看,有几个空值就心慌,其实没必要。数据分析的过程本来就是“数据质量普查——清洗——挖掘——可视化”这样的循环,没有哪批数据是完美的。而且恰恰是这些脏数据,能让你真正理解 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(include='all')看数值分布。这里有个小技巧: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(keep='first') 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(method='linear') # 心率:用前后5个有效值的中位数填充(避免单一尖峰) df['heart_rate'] = df['heart_rate'].rolling(11, center=True, min_periods=1).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, center=True, min_periods=1).median() # 标记那些与中位数差超过20 km/h的点 spike_mask = (df['speed'] - med).abs() > 20 # 将这些尖刺替换为NaN,再向前后插值 df.loc[spike_mask, 'speed'] = np.nan df['speed'] = df['speed'].interpolate(limit=3)

上面利用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(lower=0, upper=None) total_climb = df['climb'].sum()

这里的clip(lower=0)把所有负值变成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'], bins=bins, labels=labels)

然后就能统计每个区间的时间占比,比如:

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(lower=0).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='-', alpha=0.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'], color='tab:blue', linewidth=1.2, label='Speed (km/h)') ax1.set_xlabel('Time') ax1.set_ylabel('Speed (km/h)', color='tab:blue') ax1.tick_params(axis='y', labelcolor='tab:blue') ax2 = ax1.twinx() ax2.plot(df.index, df['heart_rate'], color='tab:red', linewidth=1.2, alpha=0.7, label='Heart Rate (bpm)') ax2.set_ylabel('Heart Rate (bpm)', color='tab:red') ax2.tick_params(axis='y', labelcolor='tab: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', dpi=150, bbox_inches='tight')

dpi=150足够在电脑或手机上看,如果是要打印,建议 300。bbox_inches='tight'会自动裁剪留白,避免四周多出大片空白。

5. 从"能看"到"耐看":用子图构建骑行报告模板

真正的骑行数据分析报告,往往不是单张图,而是一整页包含速度、心率、海拔、功率、踏频的内容。使用 Matplotlib 的subplots可以制作这样的多面板报告。下面是我的推荐结构:上面一排放“速度+海拔”,下面一排放“心率+功率”,如果还有踏频,再单独占一行。

子图的关键是共享 X 轴时间轴,这样你用鼠标点击某一段时,能同时看到各指标在该时间点的联动变化(虽然 Matplotlib 在静态图里不能交互,但共享坐标轴可以保证图表之间严格对齐)。

5.1 创建子图与共享坐标轴

fig, axes = plt.subplots(3, 1, sharex=True, figsize=(14, 10), gridspec_kw={'height_ratios': [3, 3, 2]})

height_ratios可以设定不同行的高度比。速度重要,占大一点;踏频相对次要,占小一点。第一行画速度,第二行画心率,第三行画海拔,这是比较常规的做法。

如果想让速度曲线上叠加一段“配色”,可以用fill_between把速度低于某个阈值(比如 20 km/h)的区域涂上浅色,突出慢速路段:

axes[0].plot(df.index, df['speed'], color='steelblue', lw=1) axes[0].fill_between(df.index, 0, 20, color='gray', alpha=0.15, label='<20km/h')

海拔可以画成面积图或填充图,这样更容易看出坡段:

axes[2].plot(df.index, df['altitude'], color='green', lw=1) axes[2].fill_between(df.index, df['altitude'].min(), df['altitude'], color='green', alpha=0.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( f'Peak {max_alt_val:.0f}m', xy=(max_alt_idx, max_alt_val), xytext=(max_alt_idx, max_alt_val + 20), arrowprops=dict(arrowstyle='->', color='k'), fontsize=10 )

再比如,在平均速度位置画一条水平虚线:

avg_speed = df['speed'].mean() axes[0].axhline(y=avg_speed, color='red', linestyle='--', label=f'Avg {avg_speed:.1f} km/h') axes[0].legend()

这种水平参考线很有用,因为人脑在观看曲线时需要一个参照系,否则根本看不出某一时刻是高于平均还是低于平均。

5.3 将 KPI 文字放到图表内:用text构建报告摘要

报告如果能在图表角落显示总距离、总时长、平均心率、平均速度这些数据,会专业很多。我常用一个文本框bbox来实现:

kpi_text = ( f"Distance: {total_distance:.1f} km\n" f"Duration: {duration_min:.0f} min\n" f"Avg HR: {avg_hr:.0f} bpm\n" f"Avg Speed: {avg_speed:.1f} km/h" ) axes[0].text(0.02, 0.98, kpi_text, transform=axes[0].transAxes, verticalalignment='top', bbox=dict(boxstyle='round', facecolor='linen', alpha=0.7))

transform=axes[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_index=False)

注意:不同骑行的时间戳在坐标轴上可能重叠,合并后直接用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(lower=0).sum()) )

这里用max取距离,是因为单次骑行中距离字段是累积的,取最后一个值才是总里程。如果你在数据清洗阶段已经按天分组,就可以简化。

然后画一个简单的折线图,观察趋势:

daily_summary['avg_speed_kmh'].plot(marker='o', linewidth=1.5)

如果你发现速度下降而心率上升,那说明疲劳累积或者恢复不足。结合平均心率的变化,能比只看速度更有判断依据。

6.3 训练负荷趋势:用滚动平均值消除噪音

单次的指标波动很大,看趋势最好使用 7 天滚动平均。Pandas 里一行:

daily_summary['maintenance_load'] = daily_summary['total_distance_km'].rolling(7, min_periods=3).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, alpha=0.3, linewidth=0.8, label=label) # 填充整体范围 lower = pd.concat(list(speed_lines.values())).groupby(level=0).min() upper = pd.concat(list(speed_lines.values())).groupby(level=0).max() ax.fill_between(lower.index, lower, upper, alpha=0.2, color='gray')

这样既保留了每条曲线的形态,又能看出整体区间。不过要提醒,骑行时速度和时间的关系不是严格对齐的,不同趟骑行的同时间点不具备可比性,所以这种叠加通常用在“同一路线的对比”上。如果路线不同,建议用标准化时间轴,比如将每次骑行按百分比归一化后再画。

7. 进阶可视化:从静态图到动图/交互地图(Pandas+Matplotlib的边界)

基础图表能满足日常检查需求,但如果你想把骑行路线发到社交平台,或者想动态展示一次骑行的过程,静态折线图就不够了。本节聊聊怎么用 Matplotlib 做出动图,以及什么时候该换用更合适的工具。

7.1 地理轨迹:经纬度坐标的可视化

Pandas 本身不能画地图,但可以把经纬度数据整理好,再用 Matplotlib 的plot画经纬度坐标。如果你的骑行区域很小,直接把经纬度做横纵轴就能看到路线形状。但这样比例不对,且没有地理底图来标出道路,所以只适合平滑轨迹。

更实用的方式是结合folium或plotly绘制交互地图。folium可以生成 HTML 文件,在网页上缩放、点击,效果非常好。核心代码:

import folium m = folium.Map(location=[df['latitude'].mean(), df['longitude'].mean()], zoom_start=13) points = list(zip(df['latitude'], df['longitude'])) folium.PolyLine(points, color='red', weight=3, opacity=0.8).add_to(m) m.save('ride_map.html')

不过标题里说的是 Matplotlib,所以我更建议先把轨迹在 Matplotlib 里画出来,比如:

plt.figure(figsize=(8, 8)) plt.plot(df['longitude'], df['latitude'], linewidth=2) 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([], [], lw=2) point, = ax.plot([], [], 'o', color='red') 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, frames=len(df), init_func=init, interval=50) ani.save('ride_animation.gif', writer='pillow')

保存 GIF 需要 Pillow 库,一般 Anaconda 环境自带。如果你的数据每秒一条,170分钟的数据有约 10000 个点,全部一帧一帧保存会非常慢,而且 GIF 文件体积巨大。建议先重采样到每 10 秒一个点,再生成动画,控制在 1000 帧以内。

动画的展示价值在于时间变化,比如位置沿着地图前进时,速度曲线同步增长。这个效果对于发社交媒体来说很讨喜,但它更多是“演示”而不是“分析”。做数据分析时,我更多使用交互式图表,比如 Plotly 的scatter_mapbox可以在地图上拖动时间滑块,效果比 GIF 好得多。所以如果只是图好玩,用 Matplotlib 自己折腾;如果有分析汇报需求,还是建议学一下 Plotly。

7.3 何时该换用其他库:Pandas+Matplotlib 的优势与边界

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')

或者直接读取时用utc=True,再转。如果不想输出带时区的时间索引,就用.dt.tz_localize(None)去掉时区信息,这样后续resample更简洁。

8.2 坐标轴刻度过密:时间轴标签黏在一起

绘制长时间序列时,Matplotlib 默认会自动生成刻度,但有时候间隔太小,几十个标签挤在一起,完全看不清。解决方法是手动设置MaxNLocator或直接指定刻度间隔:

import matplotlib.dates as mdates ax.xaxis.set_major_locator(mdates.MinuteLocator(interval=30)) ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)

更常用的是每 30 分钟显示一个刻度,正好符合一趟长骑行的读数习惯。如果你的骑行只有 20 分钟,那用MinuteLocator(interval=5)更合适。为了避免每次手动调整,我通常写一个函数:

def set_time_axis(ax, df, interval_min=30): ax.xaxis.set_major_locator(mdates.MinuteLocator(interval=interval_min)) ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) plt.setp(ax.xaxis.get_majorticklabels(), rotation=45)

rotation=45是为了避开长标签彼此重叠。如果你的时间标签是05-01 08:00这种,间隔可能需要更稀疏。

8.3 大文件读取慢与内存优化

当你把一年三百多次骑行数据合并成一个 DataFrame 时,可能出现内存占用飙高,分析变慢。这时候有几个常用技巧:

  • 读取 CSV 时指定dtype,比如把speed设为float32,heart_rate设为int8,能省一半内存。
  • 只读取需要的列,用usecols参数。
  • 如果需要聚合运算,先groupby再合并,不要一开始就做笛卡尔积。

一个很实际的例子是:GPS 经纬度列在分析时通常用不到高精度算术,float64完全可以降到float32。使用pd.to_numeric结合downcast:

for col in ['speed', 'heart_rate', 'altitude', 'power']: df[col] = pd.to_numeric(df[col], downcast='float')

对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(subset='timestamp') df = df.set_index('timestamp') # 缺失值处理等 df['altitude'] = df['altitude'].interpolate() # 计算距离、累计爬升等指标 df['dist_km'] = calculate_distance(df) df['climb'] = df['altitude'].diff().clip(lower=0) 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(),也足够完成一次像样的骑行分析了。不要从一开始就奢望去复现码表公司那套复杂的算法。先把自己骑得最爽的一次数据跑通,生成一张能看的图,再逐步加入更多指标,让报告越来越丰富。这个逐步迭代的过程,也是你对“数据理解”逐步加深的过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询