В предыдущей статье я рассказал о том, как получить данные о персональных тренировках из набора FIT-файлов, которые создаются при использовании носимых устройств (фитнес-браслеты, часы, смартфоны, велокомпьютеры).
При дальнейшем анализе моих активностей я решил сфокусироваться на виртуальных велотренировках по нескольким причинам:
наличие более сотни записей с этого вида тренировок, совершенных за последние два года на одном виде станка с использованием одних и тех же сенсоров (мощемер, датчик каденса и пульсометр) и одного и того же приложения
тренировки проводились в зимний сезон в помещении при относительно одинаковых условиях, то есть исключается фактор разных метеоусловий (температура воздуха, сила ветра, влажность)
тренировки проводились регулярно приблизительно в одно и то же время суток и дни недели
Относительная сопоставимость условий проведения тренировок позволяет сравнивать между собой имеющиеся данные.
Стоит отметить, что уровень точности сенсоров их стабильности все же не претендует на абсолютную значимость результатов анализа, и сам анализ интересен исключительно с любительской точки зрения.
Что такое виртуальная велотренировка
Виртуальная велотренировка является заменой или дополнением к велотренировкам в помещении, которые проходят на велотренажерах.
Минимальный набор оборудования для начала виртуальных велотренировок включает в себя сам велосипед и станок, пульсометр, датчик каденса, мощемер и приложение (на компьютере, смартфоне или смарт-приставке телевизора), которое транслирует структурированную тренировку и записывает данные со всех датчиков. Виртуальность может дополняться прорисованным в приложении маршрутом, соревнованиями с другими велосипедистами.
Детально о том как проходит виртуальная велотренировка можно посмотреть здесь.
К ключевым показателям виртуальной велотренировки относятся:
мощность (power, watts) – прилагаемая на педали сила, умноженная на скорость. Измеряется в ваттах и демонстрирует эффективность той работы, которую вы выполняете, крутя педали
каденс (cadence, rpm) – частота педалирования или по-другому количество оборотов педалей велосипедиста, сделанных за одну минуту
пульс (heart rate, bpm) – частота сердечных сокращений или по-другому количество ударов сердца в минуту.
Данные со всех датчиков регистрируются в момент времени (каждую секунду). Если рассматривать FIT-файл, то упомянутые данные для каждой тренировки хранятся в сообщении Record, обощенные данные - в Session.
Как построить график тренировки
Для извлечения данных о конкретной тренировке из развернутой мною ранее базы данных, я использую запрос с указанием уникального номера тренировки (activity_id =124863703316) в таблицу Record:
select * from record
where activity_id =124863703316 order by timestamp asc
![cтруктура таблицы Record с данными одной тренировки cтруктура таблицы Record с данными одной тренировки](https://habrastorage.org/getpro/habr/upload_files/370/283/703/37028370335204aacb95f359fa207b31.png)
Добавлю, что другие показатели - координаты, отметка высоты, скорость, расстояние - в структурированных виртуальных велотренировках являются производными от ключевых (мощность, каденс и пульс), поэтому исключены из дальнейшего анализа.
Для построения привычного графика показателей тренировки я использовал подключение к базе данных PostgreSQL через модуль psycopg2, pandas для работы с датасетом и matplotlib для визуализации.
В первом шаге подключаемся к данным:
import psycopg2
import pandas as pd
activity_id = 124863703316
conn = psycopg2.connect(host="localhost", database="garmin_data", user="postgres", password="afande")
df = pd.read_sql_query("""select timestamp, heart_rate, cadence, power from record
where activity_id ={} order by timestamp asc""".format(activity_id), conn)
Для каждой записи мы имеем указание на конкретный момент времени в виде 2022-02-15 16:18:16+00:00, что не очень удобно для общего графика тренировки. Обозначим старт тренировки как нулевую секунду, все последующие записи будут пересчитаны относительно старта. Добавим новую колонку sec к датасету:
df['sec'] = (df['timestamp']-min(df['timestamp'])).dt.total_seconds()/60
На графике предполагается отображение пульса, мощности и каденса одновременно, то есть необходимо иметь три шкалы показателей. Я нашел пример построения графика с тремя осями Y на одном графике здесь.
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
fig.subplots_adjust(right=0.75)
plt.title(str(min(df['timestamp']).date()) + " / Activity - " +str(activity_id))
twin1 = ax.twinx()
twin2 = ax.twinx()
twin2.spines.right.set_position(("axes", 1.1))
p1, = ax.plot(df.sec, df.heart_rate,"r-", label="HR")
p2, = twin1.plot(df.sec, df.power, "b-", label="Power")
p3, = twin2.plot(df.sec, df.cadence, "g-", label="Cadence")
ax.set_xlim(0, 90)
ax.set_ylim(0, 200)
twin1.set_ylim(0, 400)
twin2.set_ylim(0, 120)
ax.set_xlabel("Time, min")
ax.set_ylabel("HR, bpm")
twin1.set_ylabel("Power, watts")
twin2.set_ylabel("Cadence, bpm")
ax.yaxis.label.set_color(p1.get_color())
twin1.yaxis.label.set_color(p2.get_color())
twin2.yaxis.label.set_color(p3.get_color())
tkw = dict(size=4, width=1.5)
ax.tick_params(axis='y', colors=p1.get_color(), **tkw)
twin1.tick_params(axis='y', colors=p2.get_color(), **tkw)
twin2.tick_params(axis='y', colors=p3.get_color(), **tkw)
ax.tick_params(axis='x', **tkw)
ax.legend(handles=[p1, p2, p3])
plt.rcParams['figure.figsize'] = [10, 5]
plt.show()
В итоге мы получаем следующий график:
![График показателей пульса, мощности и каденса для одной тренировки График показателей пульса, мощности и каденса для одной тренировки](https://habrastorage.org/getpro/habr/upload_files/dcc/6ff/6fe/dcc6ff6fee5b1b2236fb5780e41de17f.png)
На этом графике хорошо видно, что показатели могут довольно резко изменяться (секундные падения и взлеты), что может быть обусловлено потерей сигнала сенсора или остановкой педалирования.
Для визуально приятного отображения графика тренировки можно добавить сглаживание линий через функцию сплайна. Способ описан здесь.
Я добавил в код функцию spline с тремя параметрами – значения по оси X, значения по оси Y и параметр n, отвечающий за уровень сглаживания конечной линии (чем он меньше, тем более сглаженной получится линия):
from scipy.interpolate import make_interp_spline
import numpy as np
def spline(x, y, n):
do_spline = make_interp_spline(x,y)
x_ = np.linspace(x.min(), x.max(), n)
y_ = do_spline(x_)
return x_, y_
Заменим код для линий на графике на следующий:
p1, = ax.plot(spline(df.sec, df.heart_rate, 150)[0], spline(df.sec, df.heart_rate, 150)[1],"r-", label="HR")
p2, = twin1.plot(spline(df.sec, df.power, 150)[0], spline(df.sec, df.power, 150)[1], "b-", label="Power")
p3, = twin2.plot(spline(df.sec, df.cadence, 150)[0], spline(df.sec, df.cadence, 150)[1], "g-", label="Cadence")
На рисунке ниже показано сопотавление двух вариантов:
![Сравнение двух графиков: слева с исходными данными, справа с использованием функции сплайна Сравнение двух графиков: слева с исходными данными, справа с использованием функции сплайна](https://habrastorage.org/getpro/habr/upload_files/a05/3a8/f92/a053a8f923e6b7f8d791094f324115c2.png)
Какие бывают велотренировки
Регулярное планирование велотренировок довольно сложный процесс и может быть нацелено на достижения различных целей: конкретный старт, повышение выносливости на длинных дистанциях, общей максимизации эффективности тренировочного времени и прочее.
Наиболее популярный метод планирования построен на комбинировании различных зон интенсивности в рамках одной структурированной тренировки и/или в пределах недельного/месячного плана тренировок в зависимости от целей.
Разделение на зоны интенсивности чаще всего происходит на основе функциональной пороговой мощности (Functional Threshold Power, FTP) – средняя максимальная мощность при езде на велосипеде в течение часа. Персональное значение FTP велосипедиста можно определить с помощью FTP-теста.
Одна из наиболее распространенных моделей предполагает деление на семь зон интенсивности в соответствии с физиологическим ответом организма спортсмена:
![Семь зон интенсивности велотренировок. Источник: https://www.highnorth.co.uk/articles/cycling-training-zones Семь зон интенсивности велотренировок. Источник: https://www.highnorth.co.uk/articles/cycling-training-zones](https://habrastorage.org/getpro/habr/upload_files/d65/916/cda/d65916cda3a77971a9a9e9c80527e02d.jpeg)
Подробное описание каждой из семи зон приведено здесь.
Фактически отдельная тренировка может сочетать в себе несколько типов, и отнести тренировку к одному типу можно только условно.
Как определить похожие тренировки
Я попытался разделить весь набор завершенных тренировок на несколько типов, учитывая продолжительность тренировки, соотношение проведенного времени в зонах интенсивности, соотношение проведенного времени при низком и среднем каденсе. Дальше я расскажу как я получил каждый из показателей.
Продолжительность тренировки
Продолжительность тренировки можно получить напрямую из таблицы Session – это колонка total_timer_time, где время тренировки записано в секундах.
При дальнейшем сопоставлении времени тренировок с показателями в других единицах измерения целесообразно нормализовать эти данные. Для готового датафрейма нормализация может быть сделана следующим образом, где time_n – колонка с нормализованными данными, time – исходные данные:
data['time_n'] = data.apply(lambda row: round((row['time']-min(data['time']))/(max(data['time'])-min(data['time']))*100.00)
Соотношение проведенного времени в зонах интенсивности
Выставленные зоны интенсивности в процессе тренировок меняются и зависят от последнего результата FTP-теста. В сезоне 2020-2021 я провел три аналогичных теста, в сезоне 2021-2022 – два. Результаты тестов я занес в отдельную таблицу Test в своей базе данных.
![Таблица Test со значениями пороговой мощности для каждого FTP-теста Таблица Test со значениями пороговой мощности для каждого FTP-теста](https://habrastorage.org/getpro/habr/upload_files/84e/3d0/137/84e3d01377e47885ca3277e4cf78469d.png)
Далее для каждой тренировки из таблицы Session (здесь хранятся общие сведения о тренировке) я добавил значение последнего FTP-теста, на основе которого выставляются зоны интенсивности для всех последующих тренировок. Также я исключил все тренировки до первого теста в сезоне, так как использовать значение из прошлого сезона не правдоподобно (за лето форма могла измениться). В запросе я использовал cross join и rank() over:
select * from (select session.activity_id, session.timestamp, session.avg_heart_rate, session.avg_power,
session.total_timer_time/3600 as time,
test.power_threshold, test.timestamp as test_timestamp,
rank() over (partition by session.activity_id order by session.activity_id, test.timestamp desc) as ftp_rank
from session cross join test
where sub_sport = 'virtual_activity' and avg_heart_rate > 90
and session.timestamp > '2020-12-26' and session.timestamp >= test.timestamp
and record.activity_id not in (109983788203, 110771005101, 111376595537, 111494782478)
order by session.activity_id) a
where a.ftp_rank = 1
Для детальных данных тренировок я сделал запрос к таблице Record (к ней же мы делали запрос для построения графика одной тренировки ранее), исключив тренировки в каждом сезоне до первого теста:
select record.record_id, record.activity_id, record.timestamp, record.heart_rate,
record.cadence, record.power
from record join session on record.activity_id = session.activity_id
where record.timestamp >= '2020-12-26' and session.sub_sport = 'virtual_activity' and record.power > 0
and record.activity_id not in (109983788203, 110771005101, 111376595537, 111494782478)
order by timestamp asc
При объединении таблиц я получил детальные данные для каждой тренировки вместе с пороговой мощностью. Теперь можно рассчитать соотношение времени, проведенного в разных зонах интенсивности.
Добавим новую колонку в объединенную таблицу, где будет рассчитан процент от пороговой мощности для каждой записи (каждой секунды):
record_data['percent_power'] = round(record_data['power']/record_data['power_threshold']*100.00)
Для дальнейших расчетов я добавил следующую функцию на основе приведенной ранее таблицы семи зон интенсивности:
def zone(row):
if row['percent_power'] <= 55:
val = 1
elif row['percent_power'] > 55 and row['percent_power'] <=75:
val = 2
elif row['percent_power'] > 75 and row['percent_power'] <=90:
val = 3
elif row['percent_power'] > 90 and row['percent_power'] <=105:
val = 4
elif row['percent_power'] > 105 and row['percent_power'] <=120:
val = 5
elif row['percent_power'] > 120 and row['percent_power'] <=130:
val = 6
elif row['percent_power'] > 130:
val = 7
else:
val = 0
return val
Расчет номера зоны для каждой строчки в датафрейме упрощается до вида:
record_data['zone'] = record_data.apply(zone, axis=1)
Далее агрегируем данные для того, чтобы получить соотношение времени потраченного в каждой зоне интенсивности для отдельной тренировки:
record_pivot = pd.pivot_table(record_data, index = ['activity_id', 'zone'], values = ['percent_power'], aggfunc='count')
record_pivot = record_pivot.reset_index()
record_pivot['record_count'] = record_pivot.groupby('activity_id')['percent_power'].transform('sum')
record_pivot['percent_zone'] = round(record_pivot.percent_power/record_pivot.record_count*100.00)
record_pivot = record_pivot[['activity_id', 'zone', 'percent_zone']]
record_pivot['zone_desc'] = record_pivot.apply(lambda row: 'zone_'+str(int(row['zone'])), axis=1)
training_zone = pd.pivot_table(record_pivot, index=['activity_id'], values='percent_zone', columns='zone_desc')
training_zone = training_zone.reset_index()
Итоговый датафрейм имеет следующий вид:
![Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7) Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7)](https://habrastorage.org/getpro/habr/upload_files/cb2/7e6/db4/cb27e6db4be74112705f371bf1da0413.png)
Соотношение проведенного времени при низком и высоком каденсе
Используя метод расчета для зон интенсивности, можно сделать аналогичный расчет для каденса. Я использовал следующую функцию для расчета трех условных зон каденса (низкий, обычный или средний и высокий):
def cadence(row):
if row['cadence'] >= 45 and row['cadence'] < 65:
val = 1
elif row['cadence'] >=85 and row['cadence'] <= 95:
val = 2
elif row['cadence'] > 95:
val = 3
else:
val = 0
return val
Аналогично рассчитаем зоны каденса для каждой секунды тренировок:
cadence_pivot = pd.pivot_table(record_data, index = ['activity_id', 'cadence_zone'], values = ['cadence'], aggfunc='count')
cadence_pivot = cadence_pivot.reset_index()
cadence_pivot['record_count'] = cadence_pivot.groupby('activity_id')['cadence'].transform('sum')
cadence_pivot['cadence'] = round(cadence_pivot.cadence/cadence_pivot.record_count*100.00)
cadence_pivot = cadence_pivot[['activity_id', 'cadence_zone', 'cadence']]
cadence_pivot['cadence_zone_desc'] = cadence_pivot.apply(lambda row: 'cadence_zone_'+str(int(row['cadence_zone'])), axis=1)
cadence_zone = pd.pivot_table(cadence_pivot, index=['activity_id'], values='cadence', columns='cadence_zone_desc')
cadence_zone = cadence_zone.reset_index()
При объединении всех данных в одну таблицу мы получаем датафрейм следующего вида:
![Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7), зонам каденса (cadence_zone_0-cadence_zone_3) и ее продолжительностью в нормализованном виде (time_n) Тренировки с рассчитанным соотношением затраченного времени по зонам интенсивности (zone_1 - zone_7), зонам каденса (cadence_zone_0-cadence_zone_3) и ее продолжительностью в нормализованном виде (time_n)](https://habrastorage.org/getpro/habr/upload_files/6d7/e7b/378/6d7e7b378eb6546259160e3380864696.png)
Использование контролируемой классификации
В итоговой таблице я получил список из 102 тренировок за два последние сезона (2020-2021 и 2021-2022). Учитывая размер выборки можно проклассифицировать тренировки вручную, но я решил попробовать метод контролируемой классификации.
Сразу скажу, что рациональность этого действия сводится к минимуму, но мне было интересно как этот метод сможет сработать в данном случае.
Метод контролируемой классификации (или обучение с учителем) использует заранее подготовленные образцы или эталоны для классификации данных или точного прогнозирования результатов. Подробнее о методах обучения здесь.
Подготовка тренировок-эталонов
Для 37 из 102 тренировок я проставил наиболее близкий тип тренировки, сопоставляя время и зоны интенсивности для каждой из них. Примеры всех типов тренировок-эталонов и их описания приведены в таблице:
Код эталона | Название | Продолжительность | Зоны интенсивности | Зоны каденса |
3 | Условно пороговая тренировка / FTP-тест | короткая (меньше часа) | значительная часть времени (50% и более) проведено в зонах высокой интенсивности | большая часть времени проведена на среднем и высоком каденсе |
2 | Условно тренировка темпо | средняя (1,5-2 часа) | большая часть времени (более 70%) проведена в зонах низкой и средней интенсивности | большая часть времени проведена на среднем каденсе или на низком и среднем каденсе |
1 | Условно тренировка на выносливость | длительная (больше 1,5 часов) | большая часть времени (более 70%) проведена в зонах низкой интенсивности | большая часть времени проведена на среднем каденсе |
0 | Условно восстановительная тренировка | короткая (до 1,5 часов) | Большая часть времени (более 70%) проведена в зонах низкой интенсивности | большая часть времени проведена на среднем каденсе |
Random Forests
Я использовал наиболее распространенный и простой в исполнении алгоритм Random Forests, который включен в модуль sklearn. Понятная инструкция для его применения доступна здесь.
Для построения модели в качестве входных данных я использовал различные комбинации колонок из подготовленной ранее таблицы. Наиболее удачная комбинация на мой взгляд для моего набора данных выглядит следующим образом:
X=df_train[['time_n', 'zone_1_2', 'zone_3', 'zone_4_6', 'cadence_zone_1']]
Я суммировал значения первой и второй зон интенсивности, так как они почти всегда соответствуют низкому уровню, аналогично для четвертой, пятой и шестой – так как они соответствуют высокому уровню. Седьмую зону я исключил, так как она единично представлена для некоторых тренировок (1 и меньше процента). Для каденса я взял только зону с низкими значениями, так как значение средней и высокой зоны будет зависимо напрямую.
В качестве выходных данных я использовал 37 значений, соотносящихся с типом тренировок, расставленных ранее.
Тестирование модели показало ее высокую точность (Accuracy: 1.0), но скорее всего она меньше, исходя из маленького набора данных.
Применив эту модель, я проклассифицировал все остальные тренировки на четыре типа. В результате классификации я получил 5 тренировок на пороговой мощности, 39 темпо, 14 на выносливость, 44 на восстановление.
Сравнение похожих тренировок
Для похожих тренировок я попробовал совместить на одном графике их профили мощности, поскольку они отображают исходное структурированное задание. В теории я должен был увидеть схожие профили для одного типа тренировок.
Для визуализации нескольких профилей одновременно я использовал опцию subplot из библиотеки matplotlib.
# import libraries
import psycopg2
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime
# connect to the dataset
df = pd.read_csv('power_zones_data_rf_comparison.csv', index_col=0)
df = df[['activity_id', 'training_type']]
training_3 = df.loc[df['training_type']==3]
list_3 = training_3['activity_id'].to_list()
conn = psycopg2.connect(host="localhost", database="garmin_data", user="postgres", password="afande")
# generate a chart for each training from the list_3
for i in range(5):
activity_id = list_3[i]
df = pd.read_sql_query("""select timestamp, heart_rate, cadence, power from record
where activity_id ={} order by timestamp asc""".format(activity_id), conn)
df['sec'] = (df['timestamp']-min(df['timestamp'])).dt.total_seconds()/60
plt.subplot(2,3,i+1)
plt.plot(df.sec, df.power)
plt.title(min(df['timestamp']).date())
plt.xlim(0, 60)
plt.ylim(0,450)
plt.xlabel('time, min')
plt.ylabel('power, watts')
plt.rcParams['figure.figsize'] = [20, 10]
plt.show()
Наиболее точно были определены пороговые тренировки (или FTP-тесты). Три из них были определены вручную как эталонные, модель правильно определила оставшиеся две.
![Сопоставление профилей мощности тренировок с кодом 3: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации Сопоставление профилей мощности тренировок с кодом 3: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации](https://habrastorage.org/getpro/habr/upload_files/653/679/a50/653679a50b0617f28afa028b3d619c48.png)
Относительно точно были определены тренировки других типов. Сопоставление тренировок в рамках выделенных типов будет продолжено в последующем анализе.
![Сопоставление профилей мощности тренировок с кодом 2: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации Сопоставление профилей мощности тренировок с кодом 2: верхний ряд - тренировки-эталоны, нижний - определены с помощью модели контролируемой классификации](https://habrastorage.org/getpro/habr/upload_files/abe/490/ae5/abe490ae57887c439b2362e6eaf5b003.png)
Итоги
Это моя первая попытка проанализировать данные, полученные из FIT-файлов. По сути я смог воспроизвести отображение базового набора показателей, которые могут в дальшейшем быть использованы для расчетов эффективности тренировок. В данном виде это конечно ничего не добавляет к существующей функциональности фитнес-приложений (такие как Garmin Connect или Strava), но это первый шаг к независимости от их интерфейса.
Я попробовал произвести классификацию завершенных тренировок для дальнейшего сопоставления показателей похожих тренировок, а именно для сравнения ответа организма на похожую нагрузку через пульс. Надеюсь поделиться этим анализом в следующих статьях.