Jakub’s hardloop data

Python
Machine Learning
Een netwerk voor hardloopprestaties.
Author

Sobotka

Published

June 25, 2026

Het leek mij erg leuk om als eerste data-project iets te doen met mijn eigen hardloop data. Misschien nog geen deep dive machine learning of AI-modellen, maar wel iets wat uit mijn eigen dag komt. Ik weet dat Garmin en Strava mij dezelfde grafieken kunnen laten zien maar toch zijn deze net wat anders. Na wat saaie grafieken heb ik wat geprobeerd om te maken a.d.v. markov chains.

Code
import pandas as pd

activities = client.get_activities(0, 582)

df_raw = pd.DataFrame(activities)

relevante_kolommen = [
    'startTimeLocal', 
    'distance', 
    'duration', 
    'averageHR', 
    'maxHR' 
]

bestaande_kolommen = [col for col in relevante_kolommen if col in df_raw.columns]

df_raw[bestaande_kolommen].head()
startTimeLocal distance duration averageHR maxHR
0 2026-08-05 07:28:46 21163.779297 6899.370117 147.0 167.0
1 2026-08-04 17:01:05 37093.199219 4391.462891 160.0 179.0
2 2026-08-02 10:04:37 57489.070312 6996.058105 165.0 181.0
3 2026-07-30 12:24:04 116846.898438 16720.072266 152.0 181.0
4 2026-07-29 06:33:05 21179.609375 6945.195801 150.0 165.0
Code
df_raw['afstand_km'] = df_raw['distance'] / 1000
df_raw['duur_min'] = df_raw['duration'] / 60
Code
import matplotlib.pyplot as plt
import pandas as pd
import datetime

if 'datum' not in df_raw.columns:
    df_raw['datum'] = pd.to_datetime(df_raw['startTimeLocal'], utc=True).dt.tz_localize(None)
else:
    df_raw['datum'] = pd.to_datetime(df_raw['datum'], utc=True).dt.tz_localize(None)

if 'afstand_km' not in df_raw.columns:
    df_raw['afstand_km'] = df_raw['distance'] / 1000

huidig_jaar = datetime.datetime.now().year
start_van_het_jaar = pd.to_datetime(f"{huidig_jaar}-01-01")

df_recent = df_raw[df_raw['datum'] >= start_van_het_jaar].copy()

df_week = df_recent.groupby(pd.Grouper(key='datum', freq='W-MON'))['afstand_km'].sum().reset_index()

plt.figure(figsize=(12, 6))
plt.bar(df_week['datum'], df_week['afstand_km'], width=5, color='#2c3e50', label='Afgelegde kilometers')

plt.title(f'Wekelijkse Kilometers ({huidig_jaar})', fontsize=16, fontweight='bold')
plt.xlabel('Datum', fontsize=12)
plt.ylabel('Totale Afstand (km)', fontsize=12)
plt.legend()
plt.grid(axis='y', linestyle='--', alpha=0.5)

plt.show()

Code
import matplotlib.pyplot as plt
import pandas as pd
import datetime

if 'datum' not in df_raw.columns:
    df_raw['datum'] = pd.to_datetime(df_raw['startTimeLocal'], utc=True).dt.tz_localize(None)
else:
    df_raw['datum'] = pd.to_datetime(df_raw['datum'], utc=True).dt.tz_localize(None)

if 'afstand_km' not in df_raw.columns:
    df_raw['afstand_km'] = df_raw['distance'] / 1000

if 'sport' not in df_raw.columns and 'activityType' in df_raw.columns:
    df_raw['sport'] = df_raw['activityType'].apply(lambda x: x.get('typeKey', '') if isinstance(x, dict) else str(x))

huidig_jaar = datetime.datetime.now().year
start_van_het_jaar = pd.to_datetime(f"{huidig_jaar}-01-01")

toegestane_sporten = ['running', 'trail_running']

df_recent = df_raw[
    (df_raw['datum'] >= start_van_het_jaar) & 
    (df_raw['sport'].isin(toegestane_sporten))
].copy()

df_week = df_recent.groupby(pd.Grouper(key='datum', freq='W-MON'))['afstand_km'].sum().reset_index()

plt.figure(figsize=(12, 6))
plt.bar(df_week['datum'], df_week['afstand_km'], width=5, color='#2c3e50', label='Hardloop & Trail km')

plt.title(f'Wekelijkse Hardloop Kilometers ({huidig_jaar})', fontsize=16, fontweight='bold')
plt.xlabel('Datum', fontsize=12)
plt.ylabel('Totale Afstand (km)', fontsize=12)
plt.legend()
plt.grid(axis='y', linestyle='--', alpha=0.5)

plt.show()

Code
import matplotlib.pyplot as plt
import pandas as pd
import datetime

if 'datum' not in df_raw.columns:
    df_raw['datum'] = pd.to_datetime(df_raw['startTimeLocal'], utc=True).dt.tz_localize(None)
else:
    df_raw['datum'] = pd.to_datetime(df_raw['datum'], utc=True).dt.tz_localize(None)

if 'afstand_km' not in df_raw.columns:
    df_raw['afstand_km'] = df_raw['distance'] / 1000
if 'duur_min' not in df_raw.columns:
    df_raw['duur_min'] = df_raw['duration'] / 60

if 'sport' not in df_raw.columns and 'activityType' in df_raw.columns:
    df_raw['sport'] = df_raw['activityType'].apply(lambda x: x.get('typeKey', '') if isinstance(x, dict) else str(x))

huidig_jaar = datetime.datetime.now().year
start_van_het_jaar = pd.to_datetime(f"{huidig_jaar}-01-01")
toegestane_sporten = ['running', 'trail_running']

df_pace = df_raw[
    (df_raw['datum'] >= start_van_het_jaar) & 
    (df_raw['sport'].isin(toegestane_sporten)) &
    (df_raw['afstand_km'] > 0)
].copy()


df_pace['tempo_decimaal'] = df_pace['duur_min'] / df_pace['afstand_km']

df_pace = df_pace.sort_values('datum')

df_pace['trendlijn'] = df_pace['tempo_decimaal'].rolling(window=5, min_periods=1).mean()

plt.figure(figsize=(12, 6))

plt.scatter(df_pace['datum'], df_pace['tempo_decimaal'], color='#3498db', alpha=0.6, label='Individuele Run')

plt.plot(df_pace['datum'], df_pace['trendlijn'], color='#e74c3c', linewidth=2, label='Trend (Rollend gem. 5 runs)')

plt.title(f'Tempo per Run ({huidig_jaar})', fontsize=16, fontweight='bold')
plt.xlabel('Datum', fontsize=12)
plt.ylabel('Tempo (min/km, decimaal)', fontsize=12)

plt.gca().invert_yaxis()

plt.legend()
plt.grid(axis='both', linestyle='--', alpha=0.4)

plt.show()

Code
import matplotlib.pyplot as plt
import pandas as pd
import datetime


if 'datum' not in df_raw.columns:
    df_raw['datum'] = pd.to_datetime(df_raw['startTimeLocal'], utc=True).dt.tz_localize(None)
else:
    df_raw['datum'] = pd.to_datetime(df_raw['datum'], utc=True).dt.tz_localize(None)

if 'sport' not in df_raw.columns and 'activityType' in df_raw.columns:
    df_raw['sport'] = df_raw['activityType'].apply(lambda x: x.get('typeKey', '') if isinstance(x, dict) else str(x))


huidig_jaar = datetime.datetime.now().year
start_van_het_jaar = pd.to_datetime(f"{huidig_jaar}-01-01")
toegestane_sporten = ['running', 'trail_running']

df_hartslag = df_raw[
    (df_raw['datum'] >= start_van_het_jaar) & 
    (df_raw['sport'].isin(toegestane_sporten)) &
    (df_raw['averageHR'] > 0) 
].copy()


df_hartslag = df_hartslag.sort_values('datum')



df_hartslag['trendlijn'] = df_hartslag['averageHR'].rolling(window=5, min_periods=1).mean()


plt.figure(figsize=(12, 6))

plt.scatter(df_hartslag['datum'], df_hartslag['averageHR'], color='#9b59b6', alpha=0.5, label='Gem. Hartslag per Run')

plt.plot(df_hartslag['datum'], df_hartslag['trendlijn'], color='#8e44ad', linewidth=2.5, label='Trend (5 runs)')


plt.title(f'Gemiddelde Hartslag per Run ({huidig_jaar})', fontsize=16, fontweight='bold')
plt.xlabel('Datum', fontsize=12)
plt.ylabel('Hartslag (bpm)', fontsize=12)
plt.legend()

plt.grid(axis='y', linestyle='--', alpha=0.6)

plt.show()

Code
import matplotlib.pyplot as plt
import pandas as pd
import datetime


if 'datum' not in df_raw.columns:
    df_raw['datum'] = pd.to_datetime(df_raw['startTimeLocal'], utc=True).dt.tz_localize(None)
else:
    df_raw['datum'] = pd.to_datetime(df_raw['datum'], utc=True).dt.tz_localize(None)

if 'afstand_km' not in df_raw.columns:
    df_raw['afstand_km'] = df_raw['distance'] / 1000
if 'duur_min' not in df_raw.columns:
    df_raw['duur_min'] = df_raw['duration'] / 60

if 'sport' not in df_raw.columns and 'activityType' in df_raw.columns:
    df_raw['sport'] = df_raw['activityType'].apply(lambda x: x.get('typeKey', '') if isinstance(x, dict) else str(x))

huidig_jaar = datetime.datetime.now().year
start_van_het_jaar = pd.to_datetime(f"{huidig_jaar}-01-01")
toegestane_sporten = ['running', 'trail_running']

df_combo = df_raw[
    (df_raw['datum'] >= start_van_het_jaar) & 
    (df_raw['sport'].isin(toegestane_sporten)) &
    (df_raw['averageHR'] > 0) &
    (df_raw['afstand_km'] > 0)
].copy()

df_combo['tempo_decimaal'] = df_combo['duur_min'] / df_combo['afstand_km']
df_combo = df_combo.sort_values('datum')

df_combo['trend_hr'] = df_combo['averageHR'].rolling(window=5, min_periods=1).mean()
df_combo['trend_tempo'] = df_combo['tempo_decimaal'].rolling(window=5, min_periods=1).mean()

fig, ax1 = plt.subplots(figsize=(12, 6))

#laag 1
kleur_hr = '#e74c3c'  
ax1.scatter(df_combo['datum'], df_combo['averageHR'], color=kleur_hr, alpha=0.2)
ax1.plot(df_combo['datum'], df_combo['trend_hr'], color=kleur_hr, linewidth=3, label='Trend Hartslag (bpm)')
ax1.set_xlabel('Datum', fontsize=12)
ax1.set_ylabel('Hartslag (bpm)', color=kleur_hr, fontsize=12, fontweight='bold')
ax1.tick_params(axis='y', labelcolor=kleur_hr)

#laag 2
ax2 = ax1.twinx()  
kleur_tempo = '#3498db'  
ax2.scatter(df_combo['datum'], df_combo['tempo_decimaal'], color=kleur_tempo, alpha=0.2)
ax2.plot(df_combo['datum'], df_combo['trend_tempo'], color=kleur_tempo, linewidth=3, label='Trend Tempo (min/km)')
ax2.set_ylabel('Tempo (min/km)', color=kleur_tempo, fontsize=12, fontweight='bold')
ax2.tick_params(axis='y', labelcolor=kleur_tempo)

ax2.invert_yaxis()

plt.title(f'Hartslag vs Tempo ', fontsize=16, fontweight='bold')
ax1.grid(axis='x', linestyle='--', alpha=0.4)

fig.legend(loc='upper right', bbox_to_anchor=(0.9, 0.88))

plt.show()

Een heatmap van al mijn runs, zoals je kunt zien ren ik vaak in de duinen rondom IJmuiden en Haarlem.

Code
import folium
import pandas as pd
from IPython.display import display

run_data = df_recent.copy()
totaal_runs = len(run_data)

if totaal_runs == 0:
    print("df_recent is leeg")
else:
    heatmap = folium.Map(location=[52.46, 4.62], zoom_start=11, tiles='CartoDB dark_matter')

    succesvol = 0
    overgeslagen = 0

    for index, run in run_data.iterrows():
        activity_id = run['activityId']
        
        try:
            details = client.get_activity_details(activity_id)
            
            if details and details.get('geoPolylineDTO') and details['geoPolylineDTO'].get('polyline'):
                gps_punten = details['geoPolylineDTO']['polyline']
                
                route = [
                    [punt['lat'], punt['lon']] 
                    for punt in gps_punten 
                    if punt.get('lat') is not None and punt.get('lon') is not None
                ]
                
                if route:
                    folium.PolyLine(
                        locations=route,
                        color='#fc4c02',
                        weight=2,        
                        opacity=0.3      
                    ).add_to(heatmap)
                    succesvol += 1
            else:
                overgeslagen += 1
                
        except Exception as e:
            print(f"Fout {activity_id}: {e}")
            overgeslagen += 1
            

    display(heatmap)
Make this Notebook Trusted to load map: File -> Trust Notebook

Mijn trainings-data gerund op het K-Means algoritme, dit algoritme zoekt structuur in mijn data. We zien 3 profielen. - Cluster 0: Langzame herstel loopjes van ca 10km met een tempo van 5:40/km. - Cluster 2: Snellere loopjes van ca 11km met een tempo van 4:40/km. - Cluster 1: Langzame duurloopjes van ca 24km met een gemiddeld tempo van 5:50/km.

Code
import pandas as pd
import plotly.express as px
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler


df_ml = df_combo.dropna(subset=['afstand_km', 'tempo_decimaal', 'averageHR']).copy()

features = ['afstand_km', 'tempo_decimaal', 'averageHR']
X = df_ml[features]

scaler = StandardScaler()
X_geschaald = scaler.fit_transform(X)

aantal_clusters = 3

kmeans = KMeans(n_clusters=aantal_clusters, random_state=42, n_init=10)
df_ml['Cluster'] = kmeans.fit_predict(X_geschaald)

df_ml['Cluster'] = 'Cluster ' + df_ml['Cluster'].astype(str)

fig = px.scatter_3d(
    df_ml, 
    x='afstand_km', 
    y='tempo_decimaal', 
    z='averageHR',
    color='Cluster',
    title=f'K-Means Clustering ({aantal_clusters} Trainingsprofielen)',
    labels={
        'afstand_km': 'Afstand (km)',
        'tempo_decimaal': 'Tempo (min/km)',
        'averageHR': 'Gem. Hartslag (bpm)'
    },
    opacity=0.8
)

fig.update_layout(scene=dict(yaxis=dict(autorange='reversed')))

cluster_profielen = df_ml.groupby('Cluster')[['afstand_km', 'tempo_decimaal', 'averageHR']].mean()

cluster_profielen = cluster_profielen.round(2)

cluster_profielen = cluster_profielen.sort_values('afstand_km')

print(cluster_profielen)


fig.show()
           afstand_km  tempo_decimaal  averageHR
Cluster                                         
Cluster 2       10.32            5.62     141.03
Cluster 1       10.83            4.66     168.65
Cluster 0       24.29            5.69     149.20

Nadat je al 3 jaar lang rent kan je van jezelf best voorspellen wat de volgende training wordt, maar kan een algoritme dat ook? Met de hulp van markov chains zie je hier een matrix waarin de kans op de volgende training wordt benoemd.

Code
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

df_ml = df_ml.sort_values('datum')


df_ml['Volgende_Training'] = df_ml['Cluster'].shift(-1)


df_markov = df_ml.dropna(subset=['Volgende_Training'])


transitie_matrix = pd.crosstab(
    df_markov['Cluster'], 
    df_markov['Volgende_Training'], 
    normalize='index'
)

transitie_matrix = (transitie_matrix * 100).round(1)

plt.figure(figsize=(10, 6))
sns.heatmap(transitie_matrix, annot=True, fmt='g', cmap='Blues', cbar_kws={'label': 'Kans (%)'})

plt.title('Wat is de kans op de volgende training?', fontsize=14, fontweight='bold')
plt.xlabel('Voorspelde volgende Training', fontsize=12)
plt.ylabel('Jouw huidige Training', fontsize=12)

plt.show()

laatste_run = df_ml.iloc[-1]['Cluster']
voorspelling = transitie_matrix.loc[laatste_run].idxmax()
kans = transitie_matrix.loc[laatste_run].max()


print(f"Je laatste run was een: {laatste_run}.")
print(f"Op basis van jouw eigen data is de kans het grootst ({kans}%) dat je volgende run een {voorspelling} wordt")

Je laatste run was een: Cluster 1.
Op basis van jouw eigen data is de kans het grootst (68.8%) dat je volgende run een Cluster 2 wordt

Om het wat makkelijker te maken heb ik afstand genomen van de clusters en gaan we nu kijken naar daadwerkelijke afstanden. Deze matrix is wat uitgebreider. Je ziet dat als ik een loop van 25-30km heb afgerond dat er een 25% kans is dat de volgende training ook net zo lang wordt.

Code
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np

df_afstand = df_combo.copy()
df_afstand = df_afstand.sort_values('datum')


bins = [0, 5, 10, 15, 20, 25, 30, np.inf]

labels = ['0-5 km', '5-10 km', '10-15 km', '15-20 km', '20-25 km', '25-30 km', '30+ km']

df_afstand['Afstand_Categorie'] = pd.cut(df_afstand['afstand_km'], bins=bins, labels=labels)

df_afstand['Volgende_Afstand'] = df_afstand['Afstand_Categorie'].shift(-1)

df_afstand = df_afstand.dropna(subset=['Volgende_Afstand'])

transitie_matrix_km = pd.crosstab(
    df_afstand['Afstand_Categorie'], 
    df_afstand['Volgende_Afstand'], 
    normalize='index' 
)

transitie_matrix_km = (transitie_matrix_km * 100).round(1)

plt.figure(figsize=(12, 8))

sns.heatmap(transitie_matrix_km, annot=True, fmt='g', cmap='YlGnBu', cbar_kws={'label': 'Kans (%)'})

plt.title('Wat is de kans op afstand X na Y?', fontsize=16, fontweight='bold')
plt.xlabel('Voorspelde voglgende afstand', fontsize=12)
plt.ylabel('Jouw huidige Afstand', fontsize=12)

plt.xticks(rotation=45)
plt.yticks(rotation=0)

plt.tight_layout()
plt.show()

laatste_afstand_km = df_afstand.iloc[-1]['afstand_km']
laatste_bakje = df_afstand.iloc[-1]['Afstand_Categorie']
voorspeld_bakje = transitie_matrix_km.loc[laatste_bakje].idxmax()
kans = transitie_matrix_km.loc[laatste_bakje].max()


print(f"Je laatste run was exact {laatste_afstand_km:.1f} km (Categorie: '{laatste_bakje}').")
print(f"Historisch gezien is de kans het grootst ({kans}%) dat je volgende run tussen de '{voorspeld_bakje}' wordt.")

Je laatste run was exact 10.7 km (Categorie: '10-15 km').
Historisch gezien is de kans het grootst (28.6%) dat je volgende run tussen de '10-15 km' wordt.