Skip to content

Latest commit

 

History

History
592 lines (456 loc) · 21.8 KB

File metadata and controls

592 lines (456 loc) · 21.8 KB

ML Deep Dive — Technische Dokumentation

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.


Übersicht der Modelle

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

1. Anomalieerkennung: Z-Score auf Ruhepuls

Datei: ml-service/src/models/anomaly.py Trigger: Täglich bei Inferenz-Lauf

Datenbasis

SELECT resting_hr FROM daily_summary
WHERE user_id = $1
  AND date < CURRENT_DATE
  AND date >= CURRENT_DATE - INTERVAL '31 days'
ORDER BY date

Lookback: 31 Tage, exkl. heute. None-Werte werden gefiltert. Minimum: 7 gültige Datenpunkte, sonst status = "insufficient_data".

Formel

μ = (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).

Schwellwert

_THRESHOLD = 2.0
is_anomaly = abs(z) > _THRESHOLD

Zweiseitig: 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.

Ausgabe (gespeichert in ml_predictions.metadata)

{
  "is_anomaly": false,
  "z_score": 1.29,
  "threshold": 2.0,
  "baseline_mean": 43.5,
  "baseline_std": 3.2
}

2. Pearson-Korrelation

Datei: ml-service/src/models/correlation.py Trigger: Täglich bei Inferenz-Lauf

Formel

r(X, Y) = Σ[(xᵢ − x̄)(yᵢ − ȳ)] / sqrt[Σ(xᵢ − x̄)² · Σ(yᵢ − ȳ)²]

Implementierung: scipy.stats.pearsonr(x, y) → gibt (r, p_value) zurück.

  • r auf 3 Dezimalstellen gerundet
  • p_value auf 4 Dezimalstellen gerundet
  • Minimum: n ≥ 10 Paare, sonst status = "insufficient_data"

Interpretationsschwellen

| |r|| Interpretation | |-----|----------------| | ≥ 0.7 | stark | | ≥ 0.4 | moderat | | ≥ 0.2 | schwach | | < 0.2 | kein Zusammenhang |

Berechnete Korrelationen

correlation_sleep_hrv

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 NULL

Erwartete Richtung: positiv (besserer Schlaf → höherer HRV am Folgetag).

correlation_sleep_rhr

Wie oben, aber mit d_next.resting_hr aus daily_summary. Erwartete Richtung: negativ (besserer Schlaf → niedrigerer Ruhepuls am Folgetag).

correlation_bb_rhr

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 NULL

Erwartete Richtung: negativ (höhere Body Battery → niedrigerer Ruhepuls am Folgetag).

Ausgabe

{
  "r": 0.61,
  "p_value": 0.0031,
  "n": 42,
  "interpretation": "stark"
}

3. Random Forest Readiness Prediction

Datei: ml-service/src/models/readiness.py Inferenz: Täglich | Training: Wöchentlich (Sonntag 03:00)

Trainings-Target: Energie-basierter Score (_energy_based_score)

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.

Feature Engineering

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.

Modell

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}.joblib

Atomares 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"
}

Inferenz

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


4. Body Battery K-Means Clustering

Datei: ml-service/src/models/battery_pattern.py (Runner: inference_models.py) Inferenz: Täglich | Training: Wöchentlich (Sonntag 03:00)

Feature-Extraktion aus Intraday-Daten

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

Clustering

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_hoch vergeben) → stabil_hoch
  • Positives Delta (steigende Kurve) → erholung
  • Sonst → erschoepft

5. Scheduling & Datenpipeline

Inferenz-Lauf (nach jedem Garmin-Sync + täglicher Fallback via ML_INFER_HOUR)

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

Trainings-Lauf (wöchentlich, Sonntag 03:00)

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)

Startup-Verhalten

Beim Start des ML-Service wird einmalig synchron Training + Inferenz für alle User durchgeführt, bevor der APScheduler die geplanten Jobs übernimmt.


6. Persistenz-Schema (ml_predictions)

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

7. Algorithmische Modelle (kein ML-Training)

Alle folgenden Modelle berechnen deterministisch aus vorhandenen Daten — kein joblib-File, kein Training.

sleep_score_custom — Custom Schlaf-Score

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


hrv_status_custom — Custom HRV Status

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


intensity_minutes_custom — Karvonen Intensitätsminuten

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


body_battery_custom — Fresh-State Energiemodell

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.


training_effect_custom — Banister TRIMP + Training Effect

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)


8. Lookback-Windows im Überblick

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)

9. Abhängigkeiten

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