1. 骑行数据分析的价值与工具选型
骑行爱好者们常常会遇到这样的困惑:明明每周都在坚持骑行,却感觉进步不明显;装备升级后到底有没有提升速度;不同路线的体能消耗差异有多大。这些问题的答案都藏在你的骑行数据里。
我使用Python数据分析工具链处理骑行数据已有三年时间,这套工作流帮我从盲目骑行进阶到科学训练。Pandas+Matplotlib的组合就像骑行时的码表与心率带——一个负责精确记录,一个负责直观呈现。选择这个技术栈主要基于三个考量:
首先,Pandas的DataFrame结构特别适合处理带有时间戳的传感器数据。你的骑行记录本质上就是一组带时间标记的坐标、速度和海拔数据,这与Pandas处理时间序列数据的天然优势完美契合。相比Excel手动处理,用Pandas可以轻松处理10万行以上的骑行数据。
其次,Matplotlib的可定制化程度远超一般运动APP的内置图表。当你想对比不同季节的爬坡效率,或者分析踏频与速度的关系时,完全自由的图表类型选择权就非常重要。我在2022年环法期间就用这个工具分析过职业车手的爬坡数据。
最后,这个技术组合的学习曲线非常友好。只要掌握DataFrame的基础操作和plot()方法,就能完成80%的常规分析。下面这张表对比了常见骑行数据分析方案:
| 工具 | 数据处理能力 | 可视化灵活性 | 学习成本 | 适合场景 |
|---|---|---|---|---|
| 运动APP内置 | 弱 | 弱 | 低 | 快速查看单次骑行 |
| Excel | 中等 | 中等 | 中等 | 简单统计对比 |
| Pandas+Matplotlib | 强 | 强 | 中等 | 深度分析/长期趋势 |
| Tableau | 中等 | 强 | 高 | 商业级可视化 |
提示:新手建议从单次骑行数据开始分析,逐步过渡到月度/年度趋势分析。我最初就是从对比通勤路线的时间消耗入门的。
2. 数据准备与清洗实战
2.1 获取骑行数据的三种途径
骑行数据通常来自三类设备:GPS码表(如Garmin)、运动手表(如佳明、Suunto)以及手机APP(如Strava)。我推荐使用.fit或.gpx格式的原始文件,它们包含最完整的传感器数据。以我的Garmin Edge 530为例,单次骑行通常会记录这些字段:
- 时间戳(每1-3秒一条记录)
- 经纬度坐标
- 海拔高度
- 瞬时速度(km/h)
- 心率(bpm)
- 踏频(rpm)
- 功率(瓦特,如有功率计)
python复制import pandas as pd
# 读取.fit文件需要安装fitparse库
# pip install fitparse
from fitparse import FitFile
def fit_to_dataframe(fit_file):
records = []
fitfile = FitFile(fit_file)
for record in fitfile.get_messages('record'):
record_data = {}
for field in record:
record_data[field.name] = field.value
records.append(record_data)
return pd.DataFrame(records)
ride_data = fit_to_dataframe('morning_ride.fit')
2.2 数据清洗的五个关键步骤
原始骑行数据往往存在各种问题,我在处理超过300次骑行记录后总结出这套清洗流程:
- 时间戳标准化:不同设备的时间格式可能不同,需要统一为Pandas的DateTime格式
python复制ride_data['timestamp'] = pd.to_datetime(ride_data['timestamp'])
- 处理GPS漂移:城市环境中GPS信号可能漂移,造成速度异常
python复制# 速度超过100km/h视为异常值
ride_data = ride_data[ride_data['speed'] < 100]
- 填充缺失值:隧道等区域可能导致数据中断
python复制# 前向填充心率数据
ride_data['heart_rate'].fillna(method='ffill', inplace=True)
- 计算衍生指标:
python复制# 计算累计爬升
ride_data['elevation_diff'] = ride_data['altitude'].diff()
ride_data['climb'] = ride_data['elevation_diff'].apply(
lambda x: x if x > 0 else 0)
- 分段标记:按爬坡、平路、下坡标记路段
python复制conditions = [
(ride_data['elevation_diff'] > 1),
(ride_data['elevation_diff'] < -1),
(abs(ride_data['elevation_diff']) <= 1)
]
choices = ['climb', 'descent', 'flat']
ride_data['segment'] = np.select(conditions, choices)
注意:清洗时应保留原始数据副本。我曾因直接修改源数据丢失了重要的基准对比数据。
3. 核心指标分析与可视化
3.1 基础指标计算模板
每次骑行后我最关注的六个核心指标,它们的计算方法如下:
python复制def calculate_metrics(df):
duration = (df['timestamp'].iloc[-1] - df['timestamp'].iloc[0]).total_seconds()/60
distance = df['distance'].iloc[-1] / 1000 # 转成公里
avg_speed = distance / (duration/60)
max_speed = df['speed'].max()
total_climb = df['climb'].sum()
avg_hr = df['heart_rate'].mean()
return {
'duration_min': duration,
'distance_km': distance,
'avg_speed_kmh': avg_speed,
'max_speed_kmh': max_speed,
'total_climb_m': total_climb,
'avg_hr_bpm': avg_hr
}
3.2 专业级骑行可视化方案
3.2.1 海拔-速度组合图
这是分析路线难度最有效的图表类型,使用Matplotlib的subplots实现:
python复制fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
# 海拔曲线
ax1.plot(ride_data['timestamp'], ride_data['altitude'],
color='green', linewidth=2)
ax1.set_ylabel('Altitude (m)', color='green')
# 速度曲线
ax2.plot(ride_data['timestamp'], ride_data['speed'],
color='blue', linewidth=1)
ax2.set_ylabel('Speed (km/h)', color='blue')
# 标记爬坡段
for _, seg in ride_data[ride_data['segment'] == 'climb'].iterrows():
ax1.axvspan(seg['timestamp'],
seg['timestamp'] + pd.Timedelta(seconds=3),
alpha=0.3, color='red')
3.2.2 心率区间分布饼图
科学训练需要控制不同心率区间的时长比例:
python复制# 定义心率区间
bins = [0, 120, 140, 160, 180, 200]
labels = ['恢复', '耐力', '有氧', '阈值', '极限']
ride_data['hr_zone'] = pd.cut(ride_data['heart_rate'], bins=bins, labels=labels)
zone_time = ride_data.groupby('hr_zone')['timestamp'].agg(
lambda x: (x.iloc[-1] - x.iloc[0]).total_seconds()/60 if len(x) > 1 else 0)
plt.pie(zone_time, labels=labels, autopct='%1.1f%%',
colors=['lightgreen', 'green', 'yellow', 'orange', 'red'])
plt.title('心率区间分布')
3.2.3 交互式路线热力图
使用folium库创建带速度热力图的骑行路线:
python复制import folium
from folium.plugins import HeatMap
# 创建底图
m = folium.Map(location=[ride_data['position_lat'].mean(),
ride_data['position_long'].mean()],
zoom_start=13)
# 添加热力图
heat_data = [[row['position_lat'], row['position_long'], row['speed']]
for _, row in ride_data.iterrows()]
HeatMap(heat_data, radius=10, blur=15).add_to(m)
# 保存为HTML
m.save('ride_heatmap.html')
实战技巧:使用
plt.tight_layout()可以自动调整子图间距,避免标签重叠。这个细节让我的分析报告专业度提升了一个档次。
4. 高级分析与长期趋势跟踪
4.1 训练负荷计算与可视化
使用TRIMP(Training Impulse)算法量化训练强度:
python复制def calculate_trimp(df):
# 计算心率储备比例
max_hr = 189 # 需替换为个人最大心率
rest_hr = 55 # 静息心率
df['hr_ratio'] = (df['heart_rate'] - rest_hr) / (max_hr - rest_hr)
# 女性系数为0.86,男性为1.0
gender_factor = 1.0
df['trimp'] = df['hr_ratio'] * gender_factor * df['duration_min']/60
return df['trimp'].sum()
weekly_trimp = ride_data.resample('W', on='timestamp')['trimp'].sum()
weekly_trimp.plot(kind='bar', figsize=(10,5))
plt.title('周训练负荷趋势')
plt.ylabel('TRIMP指数')
4.2 装备性能对比分析
去年我更换了碳纤维轮组,通过对比分析验证了性能提升:
python复制# 筛选相同路线的骑行记录
route_mask = (ride_data['start_lat'].between(39.90, 39.91)) & \
(ride_data['start_long'].between(116.30, 116.31))
old_wheel = ride_data[route_mask & (ride_data['date'] < '2023-06-01')]
new_wheel = ride_data[route_mask & (ride_data['date'] >= '2023-06-01')]
fig, axes = plt.subplots(1, 2, figsize=(12,5))
axes[0].boxplot([old_wheel['speed'], new_wheel['speed']],
labels=['旧轮组', '新轮组'])
axes[0].set_title('速度分布对比')
axes[1].scatter(old_wheel['heart_rate'], old_wheel['speed'],
alpha=0.5, label='旧轮组')
axes[1].scatter(new_wheel['heart_rate'], new_wheel['speed'],
alpha=0.5, label='新轮组')
axes[1].set_title('心率-速度关系对比')
4.3 年度训练总结报告
我每年会生成一份包含这些要素的PDF报告:
- 月度骑行量统计(距离、时长)
- 关键指标进步曲线(平均速度、最长距离)
- 心率区间分布变化
- 新路线探索地图
- 装备使用评估
- 下一年度训练计划
python复制from matplotlib.backends.backend_pdf import PdfPages
def generate_annual_report(data, year):
with PdfPages(f'cycling_report_{year}.pdf') as pdf:
# 封面页
plt.figure(figsize=(8,11))
plt.text(0.5, 0.8, f'{year}年度骑行报告',
ha='center', fontsize=20)
pdf.savefig()
plt.close()
# 数据页
fig = plt.figure(figsize=(11,8))
# 添加各类图表...
pdf.savefig(fig)
plt.close()
5. 性能优化与实用技巧
5.1 处理大规模骑行数据的三个技巧
当分析多年的骑行数据时(我的数据集超过2GB),这些方法很管用:
- 使用Dask替代Pandas:
python复制import dask.dataframe as dd
dask_data = dd.read_parquet('all_rides/*.parquet')
monthly_stats = dask_data.groupby('month')['distance'].mean().compute()
- 采样显示技巧:
python复制# 每100个点取1个显示
plt.plot(ride_data['timestamp'][::100],
ride_data['altitude'][::100])
- 使用HDF5存储:
python复制store = pd.HDFStore('rides.h5')
store.append('2023_rides', ride_data, format='table')
5.2 自动化分析工作流
我设置了这样的自动化流程:
- Garmin Connect自动同步.fit文件到Google Drive
- 每天凌晨运行的Python脚本:
- 检查新文件并处理
- 更新数据库
- 生成最新统计图表
- 发送周报邮件
python复制import schedule
import time
def daily_task():
# 检查新文件
new_files = check_new_files('/google_drive/rides')
# 处理数据
for file in new_files:
process_ride_file(file)
# 更新周报
if datetime.now().weekday() == 0: # 每周一
send_weekly_report()
schedule.every().day.at("03:00").do(daily_task)
while True:
schedule.run_pending()
time.sleep(60)
5.3 移动端查看方案
通过Flask搭建简单的Web界面,手机随时查看分析结果:
python复制from flask import Flask, render_template
app = Flask(__name__)
@app.route('/')
def dashboard():
# 计算最近10次骑行数据
recent_rides = get_recent_rides(10)
return render_template('dashboard.html',
rides=recent_rides)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
避坑指南:Matplotlib默认不支持中文显示,需要额外配置。这是我常用的解决方案:
python复制plt.rcParams['font.sans-serif'] = ['SimHei'] # 设置中文显示
plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题
经过三年多的实践迭代,这套分析系统帮助我将平均速度从25km/h提升到32km/h,同时避免了过度训练。最关键的收获是养成了数据驱动的训练习惯——不再凭感觉骑行,而是用数据指导每一次训练决策。