Vollständige technische Spezifikation aller ML-Modelle in PulseBase.
Implementierung: ml-service/src/models/, Scheduling + Orchestrierung: ml-service/src/main.py, Inferenz-Runner: inference_anomaly.py + inference_models.py + inference_energy.py (acwr, training_monotony, energy_*).
Für eine verständliche Erklärung ohne Mathekenntnisse: eli5.md.
| Modell | Typ | Algorithmus | Ziel |
|---|---|---|---|
anomaly_hr |
Anomalieerkennung | Z-Score | Ruhepuls-Ausreißer |
anomaly_spo2 |
Anomalieerkennung | Z-Score | SpO2-Ausreißer |
anomaly_stress |
Anomalieerkennung | Z-Score | Stress-Ausreißer |
anomaly_steps |
Anomalieerkennung | Z-Score | Schritt-Ausreißer |
anomaly_sleep_duration |
Anomalieerkennung | Z-Score | Schlafdauer-Ausreißer |
correlation_sleep_hrv |
Korrelationsanalyse | Pearson r | Schlaf → nächster-Tag-HRV |
correlation_sleep_rhr |
Korrelationsanalyse | Pearson r | Schlaf → nächster-Tag-Ruhepuls |
correlation_bb_rhr |
Korrelationsanalyse | Pearson r | Body Battery → nächster-Tag-Ruhepuls |
readiness_rf |
Regression | Random Forest | Readiness Score für morgen |
model_meta_rf |
Metadaten | — | Feature Importances + Trainingsinfos des RF |
battery_pattern |
Clustering | k-Means | Energie-Tagesmuster |
body_battery_custom |
Algorithmisch | Fresh-State-Modell | Tagesenergie (Schlafphasen + HRV) |
sleep_score_custom |
Algorithmisch | Gewichteter Score | Custom Schlaf-Score (Phasen + Dauer) |
hrv_status_custom |
Algorithmisch | σ-Klassifikation | BALANCED/UNBALANCED/LOW/POOR |
intensity_minutes_custom |
Algorithmisch | Karvonen HRR | WHO-Intensitätsminuten |
training_effect_custom |
Algorithmisch | Banister TRIMP + atan | Training Effect 0–5 |
acwr |
Algorithmisch | Banister CTL/ATL | Acute:Chronic Workload Ratio |
training_monotony |
Algorithmisch | CV-Statistik | Training Monotony + Strain |
sleep_consistency |
Algorithmisch | Zirkuläre σ | Schlaf-/Aufwachzeit-Konsistenz |
spo2_trend |
Algorithmisch | Lineare Regression | SpO2-Trendanalyse + Apnoe-Flag |
stress_score_custom |
Algorithmisch | HRV-σ + Garmin (75/25) | Autonomer Stressindex 0–100 (Garmin-Stress HRV-abgeleitet → 25% Gewicht) |
hrv_recovery |
Algorithmisch | ΔHRV/Tag | HRV-Erholungstrajektorie |
running_economy |
Algorithmisch | Z-Score-Normierung | Laufökonomie (GCT/VO/VR) |
energy_physical |
Algorithmisch | CTL/TSB Banister | Physische Energie (TSB-basiert) |
energy_autonomic |
Algorithmisch | HRV σ-Deviation | Autonome Energie |
energy_cognitive |
Algorithmisch | Schlafschuld-Akk. | Kognitive Energie |
Datei: ml-service/src/models/anomaly.py
Trigger: Täglich bei Inferenz-Lauf
SELECT resting_hr FROM daily_summary
WHERE user_id = $1
AND date < CURRENT_DATE
AND date >= CURRENT_DATE - INTERVAL '31 days'
ORDER BY dateLookback: 31 Tage, exkl. heute. None-Werte werden gefiltert.
Minimum: 7 gültige Datenpunkte, sonst status = "insufficient_data".
μ = (1/n) · Σ xᵢ (arithmetisches Mittel)
σ = sqrt[(1/n) · Σ(xᵢ − μ)²] (Standardabweichung der Population)
z = (x_heute − μ) / σ
Sonderfall: Wenn σ < 1.0 → z = 0.0 (keine relevante Varianz, Division vermeiden).
_THRESHOLD = 2.0
is_anomaly = abs(z) > _THRESHOLDZweiseitig: sowohl ungewöhnlich hohe als auch ungewöhnlich niedrige Werte werden markiert. Bei Normalverteilung markiert |2.0σ| ca. 5 % aller Tage als auffällig (beidseitig je ~2.5 %). Das entspricht dem klassischen statistischen Ausreißer-Kriterium und reduziert False Positives. Nicht zur klinischen Diagnose geeignet.
{
"is_anomaly": false,
"z_score": 1.29,
"threshold": 2.0,
"baseline_mean": 43.5,
"baseline_std": 3.2
}Datei: ml-service/src/models/correlation.py
Trigger: Täglich bei Inferenz-Lauf
r(X, Y) = Σ[(xᵢ − x̄)(yᵢ − ȳ)] / sqrt[Σ(xᵢ − x̄)² · Σ(yᵢ − ȳ)²]
Implementierung: scipy.stats.pearsonr(x, y) → gibt (r, p_value) zurück.
rauf 3 Dezimalstellen gerundetp_valueauf 4 Dezimalstellen gerundet- Minimum: n ≥ 10 Paare, sonst
status = "insufficient_data"
| |r|| Interpretation |
|-----|----------------|
| ≥ 0.7 | stark |
| ≥ 0.4 | moderat |
| ≥ 0.2 | schwach |
| < 0.2 | kein Zusammenhang |
SELECT s.sleep_score, h_next.hrv_last_night
FROM sleep_sessions s
JOIN hrv_daily h_next ON h_next.date = DATE(s.start_time) + 1
AND h_next.user_id = s.user_id
WHERE s.user_id = $1
AND s.start_time >= NOW() - INTERVAL '90 days'
AND s.sleep_score IS NOT NULL
AND h_next.hrv_last_night IS NOT NULLErwartete Richtung: positiv (besserer Schlaf → höherer HRV am Folgetag).
Wie oben, aber mit d_next.resting_hr aus daily_summary.
Erwartete Richtung: negativ (besserer Schlaf → niedrigerer Ruhepuls am Folgetag).
SELECT d1.body_battery_high, d2.resting_hr
FROM daily_summary d1
JOIN daily_summary d2 ON d2.date = d1.date + 1
AND d2.user_id = d1.user_id
WHERE d1.user_id = $1
AND d1.date >= CURRENT_DATE - INTERVAL '90 days'
AND d1.body_battery_high IS NOT NULL
AND d2.resting_hr IS NOT NULLErwartete Richtung: negativ (höhere Body Battery → niedrigerer Ruhepuls am Folgetag).
{
"r": 0.61,
"p_value": 0.0031,
"n": 42,
"interpretation": "stark"
}Datei: ml-service/src/models/readiness.py
Inferenz: Täglich | Training: Wöchentlich (Sonntag 03:00)
Der Random Forest lernt aus dem gleichen Composite, der auch in GET /api/energy
angezeigt wird — damit RF und Regelwert konsistent sind:
# ml-service/src/models/readiness.py: _energy_based_score()
components = []
if energy_autonomic_score is not None: components.append((energy_autonomic_score, 0.60))
if energy_cognitive_score is not None: components.append((energy_cognitive_score, 0.40))
score = sum(v * w for v, w in components) / sum(w for _, w in components)| Signal | Gewicht |
|---|---|
| Autonome Energie (HRV vs. 90d-Baseline, σ-skaliert) | 60% |
| Kognitive Energie (Schlafschuld vs. 7h-Ziel) | 40% |
| Physische Energie (TSB) | nicht im Target — misst akkumulierte Last, nicht Erholung |
Fehlende Komponenten werden aus der Gewichtssumme herausgerechnet.
Gibt None zurück wenn beide Komponenten fehlen.
Kandidaten-Features:
_CANDIDATE_FEATURES = [
"hrv_last_night",
"sleep_score",
"resting_hr",
"aerobic_effect_daily",
"anaerobic_effect_daily",
"body_battery_high",
"avg_stress",
"acwr_ratio", # per JOIN auf ml_predictions
]Dynamische Feature-Selektion:
Features, bei denen der globale Median über alle Trainingsrows None ist
(d.h. alle Werte fehlen), werden ausgeschlossen. Dies verhindert Abstürze wenn
z.B. hrv_last_night nie befüllt wurde.
Imputation: Fehlende Werte im aktiven Feature-Set werden mit dem globalen Median des jeweiligen Features ersetzt (Median-Imputation, nicht Mean — robuster gegen Ausreißer).
Trainingspaare:
X (Tag N): [hrv_last_night?, sleep_score?, resting_hr?]
y (Tag N+1): regelbasierter Score
Datenbasis: 365-Tage-Lookback (get_readiness_training_rows), LEFT JOIN über
daily_summary, hrv_daily, sleep_sessions. Minimum: 30 gültige Paare.
RandomForestRegressor(n_estimators=100, random_state=42)Output wird auf [0, 100] geclippt: min(100.0, max(0.0, prediction)).
Persistenz (atomar):
tmp_path = model_path.with_suffix(".joblib.tmp")
joblib.dump({"model": model, "features": feature_names, "medians": medians}, tmp_path)
tmp_path.rename(model_path) # atomic on same filesystem (Named Volume)
write_hash(model_path) # schreibt Integritäts-Hash (models/_integrity.py)
# model_path: /app/models/readiness_rf_{user_id}.joblibAtomares Schreiben verhindert korrupte Modell-Dateien bei SIGTERM während des Saves.
Zusätzlich wird ein Integritäts-Hash hinterlegt; die Inferenz lädt das Modell über
verify_and_load() und prüft den Hash, bevor das joblib-Pickle deserialisiert wird.
Feature Importances werden nach dem Training als model_meta_rf in
ml_predictions gespeichert:
{
"features": ["sleep_score", "resting_hr", "hrv_last_night"],
"importances": {"sleep_score": 0.421, "resting_hr": 0.318, "hrv_last_night": 0.261},
"n_rows": 429,
"trained_at": "2026-05-28"
}saved = verify_and_load(model_path) # Integritäts-Check (Hash) vor dem Laden
model = saved["model"]
feature_names = saved["features"]
medians = saved.get("medians", {})
# Für jedes aktive Feature: Wert holen; fehlt er → per-Feature-Median (aus Training).
# Nur wenn weder Wert noch Median existiert → return None.
vals = []
for f in feature_names:
v = features.get(f)
if v is None:
v = medians.get(f)
if v is None:
return None
vals.append(float(v))
X = np.array([vals])
# Score = Mittel der Einzelbaum-Vorhersagen; Konfidenzband aus 10./90. Perzentil.
tree_preds = np.array([t.predict(X)[0] for t in model.estimators_])
return {
"score": _clamp(float(np.mean(tree_preds))),
"confidence_low": _clamp(float(np.percentile(tree_preds, 10))),
"confidence_high": _clamp(float(np.percentile(tree_preds, 90))),
}Gibt None zurück, wenn für ein aktives Feature weder ein aktueller Wert noch ein
gespeicherter Trainings-Median vorliegt. _clamp rundet auf [0, 100].
Die Inferenz speichert readiness_rf für heute und morgen (gleicher Score);
der confidence_low/high-Bereich wandert als Metadata mit (inference_models.py: _run_readiness).
Datei: ml-service/src/models/battery_pattern.py (Runner: inference_models.py)
Inferenz: Täglich | Training: Wöchentlich (Sonntag 03:00)
Pro Tag werden aus den body_battery_intraday-Readings 5 Features berechnet:
| Feature | Berechnung |
|---|---|
morning_avg |
Mittelwert der Readings 06:00–09:00 Uhr |
evening_avg |
Mittelwert der Readings 20:00–23:00 Uhr |
daily_range |
max(value) − min(value) des gesamten Tages |
auc |
Mittelwert aller Tages-Readings (np.mean(vals)) |
n_dips |
Anzahl Abfälle > 10 Punkte zwischen aufeinanderfolgenden Readings |
# AUC (mittleres Tagesniveau):
auc = float(np.mean(vals))
# n_dips:
n_dips = int(np.sum(np.diff(vals) < -10))from sklearn.cluster import KMeans
# Geclustert wird auf den ROHEN 5 Features (kein StandardScaler).
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
kmeans.fit(X) # 5 Features, n_samples = bis zu 90 Tage (min. 14)Trainiert auf bis zu 90 Tagen History (get_body_battery_history), Minimum 14 Tage
mit jeweils ≥ 6 Intraday-Readings (_MIN_DAYS = 14, _MIN_POINTS_PER_DAY = 6).
Persistenz: eine Datei battery_pattern_{user_id}.joblib mit {kmeans, labels}.
Cluster-Label-Zuweisung:
Nach dem Training werden die Cluster zunächst nach auc (mittleres Tagesniveau) absteigend
gerankt. Das Label ergibt sich aus AUC-Rang und dem Delta evening_avg − morning_avg:
- Rang 0 (höchste AUC) mit unterdurchschnittlicher
daily_range→stabil_hoch - Größtes positives Delta (sonst noch kein
stabil_hochvergeben) →stabil_hoch - Positives Delta (steigende Kurve) →
erholung - Sonst →
erschoepft
für jeden aktiven User (garmin_linked=true, is_active=true):
anomaly.py → anomaly_hr, anomaly_spo2, anomaly_stress, anomaly_steps, anomaly_sleep_duration
correlation.py → correlation_sleep_hrv, correlation_sleep_rhr, correlation_bb_rhr
readiness.py → readiness_rf (date=tomorrow)
battery_pattern.py → battery_pattern
body_battery.py → body_battery_custom
sleep_score.py → sleep_score_custom
hrv_status.py → hrv_status_custom
intensity_minutes.py → intensity_minutes_custom
training_effect.py → training_effect_custom
training_load.py → acwr, training_monotony
sleep_metrics.py → sleep_consistency
spo2_metrics.py → spo2_trend
stress_metrics.py → stress_score_custom
hrv_recovery.py → hrv_recovery
running_economy.py → running_economy
energy_metrics.py → energy_physical, energy_autonomic, energy_cognitive
für jeden aktiven User:
readiness.py:
get_readiness_training_rows(365d) → train_and_save(model_path)
→ save_prediction("model_meta_rf", metadata={features, importances, n_rows, trained_at})
battery_pattern.py:
get_body_battery_history(90d) → fit_and_save(model_path)
Beim Start des ML-Service wird einmalig synchron Training + Inferenz für alle User durchgeführt, bevor der APScheduler die geplanten Jobs übernimmt.
Alle Ausgaben werden in der Tabelle ml_predictions gespeichert:
PRIMARY KEY (date, user_id, model)
-- Upsert-Logik:
INSERT INTO ml_predictions (date, user_id, model, value, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (date, user_id, model) DO UPDATE
SET value = EXCLUDED.value,
metadata = EXCLUDED.metadata,
created_at = NOW()model |
value |
metadata-Felder |
|---|---|---|
anomaly_hr / _spo2 / _stress / _steps / _sleep_duration |
z_score | is_anomaly, baseline_mean, baseline_std, threshold |
correlation_sleep_hrv / _sleep_rhr / _bb_rhr |
Pearson r | p_value, n, interpretation |
readiness_rf |
Score 0–100 (morgen) | confidence_low, confidence_high |
model_meta_rf |
n_rows (float) | features, importances, n_rows, trained_at |
battery_pattern |
Cluster-ID (int) | pattern, features, cluster |
body_battery_custom |
Score 5–100 | sleep_quality, hrv_factor, activity_drain, stress_drain, sleep_h, deep_h, rem_h, prev_score |
sleep_score_custom |
Score 0–100 | total_h, deep_pct, rem_pct, wake_pct |
hrv_status_custom |
Score 0–100 | status, deviation, baseline_mean, baseline_std, hrv_7d_mean |
intensity_minutes_custom |
Score 0–100 | moderate_minutes, vigorous_minutes, hrmax_used, resting_hr_used |
training_effect_custom |
Score 0–100 | effect, trimp_today, ctl, atl, tsb, vo2max, sex, b_coeff |
acwr |
Ratio (float) | atl, ctl, level (green/amber/red) |
training_monotony |
Monotony (float) | strain, trimp_7d_mean, trimp_7d_std, trimp_values |
sleep_consistency |
Score 0–100 | std_wake_h, std_sleep_h, n_nights |
spo2_trend |
mean_spo2 (float) | slope, trend (falling/stable/rising), apnea_flag (true wenn min_spo2 < 90 an ≥ 2 Nächten), apnea_nights |
stress_score_custom |
Score 0–100 | hrv_component, garmin_stress, hrv_deviation, n_hrv |
hrv_recovery |
recovery_speed (float) | n_events, hrv_baseline, trimp_threshold |
running_economy |
Score 0–100 | gct_score, vo_score, vr_score, avg_gct_ms, avg_vo_mm, avg_vr_pct, n_activities |
energy_physical |
Score 0–100 | tsb, ctl, atl |
energy_autonomic |
Score 0–100 | deviation, baseline_mean, baseline_std, hrv_7d_mean |
energy_cognitive |
Score 0–100 | debt_hours, days_used |
Alle folgenden Modelle berechnen deterministisch aus vorhandenen Daten — kein joblib-File, kein Training.
Datei: ml-service/src/models/sleep_score.py
Formel: Gewichteter Durchschnitt der Schlafphasen-Qualität:
| Komponente | Gewicht | Formel |
|---|---|---|
| Tiefschlaf-Score | 35% | min(100, deep% / 20% × 100) |
| REM-Score | 25% | min(100, rem% / 22% × 100) |
| Dauer-Score | 25% | min(100, stunden / 8 × 100) |
| Wach-Penalty | 15% | max(0, 100 − wake% × 500) |
Fehlende Phasen: verbleibende Gewichte werden proportional normiert.
Input: sleep_sessions letzte Nacht (bis 2 Tage Lookback)
Skip-Bedingung: kein total_sleep_seconds vorhanden
Datei: ml-service/src/models/hrv_status.py
Verwendet compute_autonomic_energy() intern (gleiche Log-Baseline) und mappt die σ-Deviation auf Labels:
| Status | Bedingung |
|---|---|
BALANCED |
deviation ≥ −0.5σ |
UNBALANCED |
−1.5σ ≤ deviation < −0.5σ |
LOW |
−2.0σ ≤ deviation < −1.5σ |
POOR |
deviation < −2.0σ |
Input: hrv_daily.hrv_last_night — 90 Tage Lookback
Skip-Bedingung: weniger als 7 HRV-Werte vorhanden
Datei: ml-service/src/models/intensity_minutes.py
Formel: Karvonen Heart Rate Reserve:
HRr = (HR − Ruhepuls) / (HRmax − Ruhepuls)
Moderat: 0.50 ≤ HRr < 0.70
Intensiv: HRr ≥ 0.70
Score = min(100, (moderate_min + vigorous_min × 2) / 30 × 100)
Input: activity_records.heart_rate (Sekundenwerte) + daily_summary.resting_hr für heutige Aktivitäten
Skip-Bedingung: keine activity_records für heute oder kein resting_hr
Datei: ml-service/src/models/body_battery.py
Ersetzt Garmins proprietären Body-Battery-Score durch ein transparentes, physiologisch begründetes Energiemodell. Löst das Akkumulationsplateau des früheren Banister-FFM-Ansatzes (Scientific Reports, 2025: fundamentale statistische Mängel der Methode) ab.
Schlafqualitätsfaktor (Phases 60% + Dauer 40%):
deep_score = min(1.0, (deep_h / total_h) / 0.20) # Ziel: 20% Tiefschlaf (Walker 2017)
rem_score = min(1.0, (rem_h / total_h) / 0.25) # Ziel: 25% REM (Dijk & Czeisler 1995)
quality = 0.55 × deep_score + 0.45 × rem_score # Phasen-Qualität
sleep_quality = 0.40 × (total_h / 7.5) + 0.60 × quality
Tagesscore (Fresh-State-Formel):
hrv_factor = min(1.0, hrv_last_night / hrv_baseline) # Plews et al. 2013
fresh = 40 + sleep_quality × 35 + hrv_factor × 25 # max 100 bei Idealwerten
activity_drain = min(40, today_trimp × 0.5)
stress_drain = max(0, (avg_stress − 25) × 0.2)
score = clamp(0.30 × prev + 0.70 × fresh − activity_drain − stress_drain, 5, 100)
Verhindert Akkumulationsplateau: Fresh-State (70%) dominiert, Trägheit (30%) verhindert
tägliche Überschwingungen. Bei guten Werten: fresh ≈ 100, score ≈ 100 unabhängig von
gestern.
Input: sleep_sessions (letzte Nacht), hrv_daily (letzte Nacht + 30-Tage-Baseline),
activities (heutige TRIMP), daily_summary.avg_stress
Fallback: kein hrv_last_night → hrv_factor = 0.5; keine Schlafphasen → Duration-only
Backfill: make backfill-battery — löscht alte body_battery_custom-Predictions und rechnet neu
Wissenschaftlicher Status: Einzelkomponenten validiert (HRV ✅, Schlafphasen ✅, TRIMP ✅); Composite-Aggregation heuristisch — kein Hersteller publiziert klinisch validierte Formel.
Datei: ml-service/src/models/training_effect.py
Banister TRIMP (geschlechtsspezifisch):
TRIMP = Dauer(min) × HRr × e^(b × HRr)
b = 1.92 (männlich) | b = 1.67 (weiblich)
CTL/ATL via EWM (gleiche Zeitkonstanten wie energy_physical: τ=42/7):
Training Effect (0–5):
effect = atan(TRIMP_heute / (CTL × 0.5)) × (10/π)
VO₂max (Uth et al. 2004): 15 × (HRmax / HRrest)
Input: activities (50d Lookback), users.sex + users.date_of_birth
Skip-Bedingung: has_profile = False (sex nicht gesetzt in Einstellungen → Profil)
| Modell | Lookback | Minimum |
|---|---|---|
| Anomalie Z-Score (Baseline) | 31 Tage | 7 Datenpunkte |
| Pearson-Korrelation | 90 Tage | 10 Paare |
| RF Training | 365 Tage | 30 Trainingspaare |
| K-Means Training | 90 Tage | — |
| RF Inferenz (Features) | 2 Tage | alle aktiven Features vorhanden |
sleep_score_custom |
2 Tage | letzte Schlafsession |
hrv_status_custom |
90 Tage | 7 HRV-Werte |
intensity_minutes_custom |
heute | 1 activity_record + resting_hr |
training_effect_custom |
50 Tage | Profil (sex) gesetzt |
body_battery_custom |
2 Tage Schlaf + 30 Tage HRV-Baseline | — (Fallbacks greifen) |
acwr |
42 Tage (CTL) | — |
training_monotony |
7 Tage | 2 Trainingstage |
sleep_consistency |
14 Tage | 5 Schlafnächte |
spo2_trend |
7 Tage | 1 SpO2-Wert (Apnoe-Flag erst bei ≥ 2 Nächten < 90 %) |
stress_score_custom |
90 Tage HRV-Baseline | 7 HRV-Werte |
hrv_recovery |
60 Tage | 14 gültige HRV-Werte (+ mind. 1 Erholungsevent) |
running_economy |
letzte Aktivitäten (kein Tagesfenster) | 1 Lauf mit Biomechanik-Daten (Top 5 verwendet) |
energy_physical/autonomic/cognitive |
90 Tage | — (Fallbacks greifen) |
| Library | Version | Verwendung |
|---|---|---|
scikit-learn |
≥1.4 | RandomForestRegressor, KMeans |
scipy |
≥1.12 | pearsonr() |
numpy |
≥1.26 | Array-Operationen |
joblib |
(via sklearn) | Modell-Serialisierung |
asyncpg |
≥0.29 | Datenbankzugriff |
APScheduler |
≥3.10 | Job-Scheduling |