emp-admin commited on
Commit
56f192b
Β·
verified Β·
1 Parent(s): 67bf29f

Upload 9 files

Browse files
Files changed (9) hide show
  1. Dockerfile +12 -0
  2. README.md +125 -5
  3. app.py +288 -0
  4. feature_engineering.py +237 -0
  5. metadata.json +69 -0
  6. model.pkl +3 -0
  7. models.py +133 -0
  8. requirements.txt +6 -0
  9. run_training.py +624 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,131 @@
1
  ---
2
- title: Headache Predictor Xgboost
3
- emoji: πŸ‘
4
- colorFrom: blue
5
  colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  license: mit
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Phoebe Headache Predictor API v3
3
+ emoji: 🧠
4
+ colorFrom: purple
5
  colorTo: blue
6
  sdk: docker
7
+ pinned: true
8
  license: mit
9
+ app_port: 7860
10
  ---
11
 
12
+ # 🧠 Phoebe Headache Predictor API v3.0
13
+
14
+ **Production ML headache risk forecasting** for the [Phoebe](https://empedoclabs.com) iOS app by **EmpedocLabs**.
15
+
16
+ ## How It Works
17
+
18
+ Predicts headache probability for today + next 6 days using three real-time data streams from the user's iPhone:
19
+
20
+ | Source | Via | Features Used |
21
+ |---|---|---|
22
+ | **Weather** | Apple WeatherKit | Barometric pressure, 24h pressure Ξ”, humidity, temperature |
23
+ | **Health** | Apple HealthKit | Sleep (total/deep/REM), resting HR, HRV, workout min, menstrual flow |
24
+ | **Diary** | User Input in Phoebe | Yesterday's headache severity, duration, mood, symptoms, triggers |
25
+
26
+ ### Leak-Free Architecture
27
+
28
+ The model predicts **day T** headache using **day T-1** health/diary data + **day T** weather forecast.
29
+ No same-day diary data is used β€” that would be leakage (you can't know today's headache to predict today's headache).
30
+
31
+ ## API Endpoints
32
+
33
+ | Method | Path | Description |
34
+ |---|---|---|
35
+ | `GET` | `/` | API info + example request body |
36
+ | `GET` | `/health` | Health check + model metrics |
37
+ | `POST` | **`/forecast`** | **7-day headache forecast** (recommended) |
38
+ | `POST` | `/predict` | Single prediction (legacy raw features) |
39
+ | `POST` | `/predict/batch` | Batch predictions (legacy raw features) |
40
+ | `GET` | `/docs` | Interactive Swagger documentation |
41
+
42
+ ## `/forecast` Request
43
+
44
+ ```json
45
+ {
46
+ "user_context": {
47
+ "age_range": "30-40",
48
+ "location_region": "Balkan Peninsula, Europe"
49
+ },
50
+ "daily_snapshots": [
51
+ {
52
+ "headache_log": {
53
+ "severity": 3,
54
+ "duration_hours": 4.5,
55
+ "input_date": "2025-06-01",
56
+ "mood": "bad",
57
+ "symptoms": { "symptoms": ["nausea", "photophobia"] },
58
+ "triggers": { "triggers": ["stress", "weather_change"] }
59
+ },
60
+ "health_kit_metrics": {
61
+ "resting_heart_rate": 72,
62
+ "sleep_analysis": {
63
+ "total_duration_hours": 5.1,
64
+ "deep_sleep_minutes": 45,
65
+ "rem_sleep_minutes": 60
66
+ },
67
+ "hrv_summary": { "average_ms": 22 },
68
+ "workout_minutes": 0,
69
+ "had_menstrual_flow": true
70
+ },
71
+ "weather_data": {
72
+ "barometric_pressure_mb": 1005.3,
73
+ "pressure_change_24h_mb": -7.2,
74
+ "humidity_percent": 88,
75
+ "temperature_celsius": 28.5
76
+ }
77
+ }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ ## Response
83
+
84
+ ```json
85
+ {
86
+ "predictions": [
87
+ {
88
+ "day": 1,
89
+ "date": "2025-06-01",
90
+ "prediction": 1,
91
+ "probability": 0.7234,
92
+ "risk_level": "very_high",
93
+ "top_risk_factors": ["barometric_pressure_drop", "poor_sleep", "menstrual_phase"]
94
+ }
95
+ ],
96
+ "model_version": "3.0.0",
97
+ "threshold": 0.294
98
+ }
99
+ ```
100
+
101
+ ## Model Details
102
+
103
+ | Property | Value |
104
+ |---|---|
105
+ | Algorithm | HistGradientBoosting + Isotonic Calibration |
106
+ | Features | 38 (leak-free) |
107
+ | Training data | 198,000 samples, 1,000 synthetic users Γ— 200 days |
108
+ | User archetypes | chronic_migraine, episodic_tension, menstrual_migraine, weather_sensitive, mixed |
109
+ | Test ROC-AUC | 0.686 |
110
+ | Test F1 | 0.559 |
111
+ | Threshold | 0.294 (tuned on validation set) |
112
+
113
+ ### Top Predictive Features
114
+
115
+ 1. **Recent headache** (yesterday) β€” strongest predictor
116
+ 2. **Barometric pressure change** β€” rapid drops trigger migraines
117
+ 3. **Headache streak** β€” consecutive-day pattern detection
118
+ 4. **HRV** β€” low heart rate variability = stress = risk
119
+ 5. **Menstrual flow** β€” perimenstrual window is highest risk
120
+ 6. **Humidity** β€” high humidity worsens symptoms
121
+ 7. **Temperature** β€” extremes increase risk
122
+
123
+ ## Deployment
124
+
125
+ Upload `model.pkl` to the model repo, then create an HF Space with this code.
126
+
127
+ ```bash
128
+ # Set environment variables in HF Space settings:
129
+ HF_REPO_ID=emp-admin/headache-predictor-xgboost
130
+ HF_TOKEN=your_hf_token # if repo is private
131
+ ```
app.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ═══════════════════════════════════════════════════════════════════════
3
+ Phoebe Headache Predictor API v3.0
4
+ EmpedocLabs Β© 2025
5
+ ═══════════════════════════════════════════════════════════════════════
6
+
7
+ Endpoints:
8
+ GET / β†’ API info & usage examples
9
+ GET /health β†’ Health + model status
10
+ POST /forecast β†’ 7-day headache forecast (DailySnapshotDTO)
11
+ POST /predict β†’ Single-day legacy (raw feature vector)
12
+ POST /predict/batch β†’ Batch legacy (raw feature vectors)
13
+ """
14
+
15
+ import logging
16
+ import numpy as np
17
+ import pickle
18
+ import os
19
+ from typing import List
20
+
21
+ from fastapi import FastAPI, HTTPException
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+ from pydantic import BaseModel
24
+ from huggingface_hub import hf_hub_download
25
+
26
+ from models import (
27
+ DailySnapshotDTO, UserContextDTO, WeatherDataDTO,
28
+ PredictionRequest, PredictionResponse, DayPrediction,
29
+ SinglePredictionRequest, SinglePredictionResponse,
30
+ )
31
+ from feature_engineering import (
32
+ extract_features_for_day, extract_forecast_features,
33
+ get_risk_factors, FEATURE_NAMES, NUM_FEATURES,
34
+ )
35
+
36
+ # ── Logging ──────────────────────────────────────────────────────────
37
+
38
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
39
+ logger = logging.getLogger("phoebe")
40
+
41
+ # ── App ──────────────────────────────────────────────────────────────
42
+
43
+ app = FastAPI(
44
+ title="Phoebe Headache Predictor API",
45
+ version="3.0.0",
46
+ description="ML-powered headache risk forecasting for the Phoebe iOS app by EmpedocLabs.",
47
+ docs_url="/docs",
48
+ redoc_url="/redoc",
49
+ )
50
+
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=["*"],
54
+ allow_credentials=True,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+
59
+ # ── Globals ──────────────────────────────────────────────────────────
60
+
61
+ clf = None
62
+ threshold = 0.5
63
+ model_version = "3.0.0"
64
+ feature_importances = {}
65
+
66
+
67
+ # ── Startup ──────────────────────────────────────────────────────────
68
+
69
+ @app.on_event("startup")
70
+ async def load_model():
71
+ global clf, threshold, model_version, feature_importances
72
+
73
+ try:
74
+ cache_dir = "/tmp/hf_cache"
75
+ os.makedirs(cache_dir, exist_ok=True)
76
+
77
+ hf_token = os.environ.get("HF_TOKEN")
78
+ repo_id = os.environ.get("HF_REPO_ID", "emp-admin/headache-predictor-xgboost")
79
+
80
+ logger.info(f"Loading model from {repo_id}...")
81
+
82
+ model_path = hf_hub_download(
83
+ repo_id=repo_id, filename="model.pkl",
84
+ cache_dir=cache_dir, token=hf_token,
85
+ )
86
+
87
+ with open(model_path, "rb") as f:
88
+ data = pickle.load(f)
89
+
90
+ if isinstance(data, dict):
91
+ clf = data["model"]
92
+ threshold = float(data.get("optimal_threshold", 0.5))
93
+ model_version = data.get("model_version", "3.0.0")
94
+ feature_importances = data.get("feature_importances", {})
95
+ metrics = data.get("test_metrics", {})
96
+ logger.info(
97
+ f"βœ… Model v{model_version} loaded | "
98
+ f"threshold={threshold:.3f} | "
99
+ f"AUC={metrics.get('roc_auc', '?')} | "
100
+ f"F1={metrics.get('f1', '?')} | "
101
+ f"features={data.get('num_features', '?')}"
102
+ )
103
+ else:
104
+ clf = data
105
+ threshold = 0.5
106
+ logger.info("βœ… Model loaded (legacy format)")
107
+
108
+ except Exception as e:
109
+ logger.error(f"❌ Model load failed: {e}")
110
+ import traceback
111
+ traceback.print_exc()
112
+
113
+
114
+ # ── Helpers ──────────────────────────────────────────────────────────
115
+
116
+ def _risk_level(prob: float) -> str:
117
+ if prob < 0.20: return "low"
118
+ if prob < 0.40: return "moderate"
119
+ if prob < 0.65: return "high"
120
+ return "very_high"
121
+
122
+
123
+ # ── Root ─────────────────────────────────────────────────────────────
124
+
125
+ @app.get("/")
126
+ def root():
127
+ return {
128
+ "service": "Phoebe Headache Predictor API",
129
+ "version": model_version,
130
+ "by": "EmpedocLabs",
131
+ "status": "running" if clf is not None else "model_not_loaded",
132
+ "endpoints": {
133
+ "/health": "GET β€” model status & metrics",
134
+ "/forecast": "POST β€” 7-day headache risk forecast",
135
+ "/predict": "POST β€” single prediction (legacy)",
136
+ "/predict/batch": "POST β€” batch prediction (legacy)",
137
+ "/docs": "GET β€” Swagger UI",
138
+ },
139
+ "example_forecast_body": {
140
+ "user_context": {"age_range": "30-40", "location_region": "Balkan Peninsula, Europe"},
141
+ "daily_snapshots": [
142
+ {
143
+ "headache_log": {"severity": 0, "duration_hours": 0, "input_date": "2025-06-01", "mood": "good"},
144
+ "health_kit_metrics": {
145
+ "resting_heart_rate": 62,
146
+ "sleep_analysis": {"total_duration_hours": 7.2, "deep_sleep_minutes": 85, "rem_sleep_minutes": 95},
147
+ "hrv_summary": {"average_ms": 42},
148
+ "workout_minutes": 30,
149
+ "had_menstrual_flow": False,
150
+ },
151
+ "weather_data": {
152
+ "barometric_pressure_mb": 1015.2, "pressure_change_24h_mb": -2.1,
153
+ "humidity_percent": 65, "temperature_celsius": 22.5,
154
+ },
155
+ },
156
+ ],
157
+ },
158
+ }
159
+
160
+
161
+ @app.get("/health")
162
+ def health():
163
+ return {
164
+ "status": "healthy" if clf is not None else "degraded",
165
+ "model_loaded": clf is not None,
166
+ "model_version": model_version,
167
+ "threshold": threshold,
168
+ "num_features": NUM_FEATURES,
169
+ "top_features": list(feature_importances.keys())[:5],
170
+ }
171
+
172
+
173
+ # ── /forecast β€” Main endpoint ───────────────────────────────────────
174
+
175
+ @app.post("/forecast", response_model=PredictionResponse)
176
+ def forecast(request: PredictionRequest):
177
+ """
178
+ 7-day headache risk forecast.
179
+
180
+ Send daily_snapshots[0] = today (full HealthKit + diary + weather),
181
+ daily_snapshots[1..6] = future days (weather forecast only).
182
+
183
+ Returns probability, risk level, and top risk factors per day.
184
+ """
185
+ if clf is None:
186
+ raise HTTPException(status_code=503, detail="Model not loaded. Please retry shortly.")
187
+
188
+ if not request.daily_snapshots:
189
+ raise HTTPException(status_code=400, detail="daily_snapshots cannot be empty.")
190
+
191
+ if len(request.daily_snapshots) > 14:
192
+ raise HTTPException(status_code=400, detail="Maximum 14 days supported.")
193
+
194
+ try:
195
+ ctx = request.user_context
196
+ snaps = request.daily_snapshots
197
+
198
+ X = extract_forecast_features(snaps, ctx)
199
+ predictions = []
200
+
201
+ for i in range(len(snaps)):
202
+ prob_arr = clf.predict_proba(X[i:i + 1])[0]
203
+ prob = float(prob_arr[1])
204
+ pred = 1 if prob >= threshold else 0
205
+
206
+ date_str = None
207
+ if snaps[i].headache_log and snaps[i].headache_log.input_date:
208
+ date_str = snaps[i].headache_log.input_date
209
+
210
+ risks = get_risk_factors(X[i], feature_importances, top_k=3)
211
+
212
+ predictions.append(DayPrediction(
213
+ day=i + 1,
214
+ date=date_str,
215
+ prediction=pred,
216
+ probability=round(prob, 4),
217
+ risk_level=_risk_level(prob),
218
+ top_risk_factors=risks,
219
+ ))
220
+
221
+ logger.info(
222
+ f"Forecast: {len(snaps)} days | "
223
+ f"probs={[p.probability for p in predictions]}"
224
+ )
225
+
226
+ return PredictionResponse(
227
+ predictions=predictions,
228
+ model_version=model_version,
229
+ threshold=round(threshold, 4),
230
+ )
231
+
232
+ except HTTPException:
233
+ raise
234
+ except Exception as e:
235
+ logger.error(f"Forecast error: {e}", exc_info=True)
236
+ raise HTTPException(status_code=400, detail=f"Forecast error: {str(e)}")
237
+
238
+
239
+ # ── Legacy endpoints ─────────────────────────────────────────────────
240
+
241
+ class BatchRequest(BaseModel):
242
+ instances: List[List[float]]
243
+
244
+ class BatchDayPred(BaseModel):
245
+ day: int
246
+ prediction: int
247
+ probability: float
248
+
249
+ class BatchResponse(BaseModel):
250
+ predictions: List[BatchDayPred]
251
+
252
+
253
+ @app.post("/predict", response_model=SinglePredictionResponse)
254
+ def predict_single(request: SinglePredictionRequest):
255
+ """Legacy: raw feature vector β†’ single prediction."""
256
+ if clf is None:
257
+ raise HTTPException(status_code=503, detail="Model not loaded")
258
+ try:
259
+ X = np.array(request.features, dtype=np.float32).reshape(1, -1)
260
+ if X.shape[1] != NUM_FEATURES:
261
+ raise ValueError(f"Expected {NUM_FEATURES} features, got {X.shape[1]}")
262
+ prob = float(clf.predict_proba(X)[0][1])
263
+ return SinglePredictionResponse(prediction=1 if prob >= threshold else 0, probability=round(prob, 4))
264
+ except HTTPException:
265
+ raise
266
+ except Exception as e:
267
+ raise HTTPException(status_code=400, detail=str(e))
268
+
269
+
270
+ @app.post("/predict/batch", response_model=BatchResponse)
271
+ def predict_batch(request: BatchRequest):
272
+ """Legacy: batch raw feature vectors."""
273
+ if clf is None:
274
+ raise HTTPException(status_code=503, detail="Model not loaded")
275
+ try:
276
+ X = np.array(request.instances, dtype=np.float32)
277
+ if X.ndim != 2 or X.shape[1] != NUM_FEATURES:
278
+ raise ValueError(f"Expected shape (n, {NUM_FEATURES}), got {X.shape}")
279
+ probas = clf.predict_proba(X)[:, 1]
280
+ preds = (probas >= threshold).astype(int)
281
+ return BatchResponse(predictions=[
282
+ BatchDayPred(day=i + 1, prediction=int(preds[i]), probability=round(float(probas[i]), 4))
283
+ for i in range(len(probas))
284
+ ])
285
+ except HTTPException:
286
+ raise
287
+ except Exception as e:
288
+ raise HTTPException(status_code=400, detail=str(e))
feature_engineering.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature engineering v3.0 β€” Leak-free extraction from DailySnapshotDTO.
3
+
4
+ Predicts day T headache using:
5
+ - Day T weather forecast (WeatherKit)
6
+ - Day T-1 HealthKit + diary (lag)
7
+ - Day T-2 headache history
8
+ - Temporal + user context + interactions
9
+
10
+ Total: 38 features.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import math
15
+ import numpy as np
16
+ from datetime import datetime
17
+ from typing import List, Optional
18
+
19
+ from models import (
20
+ DailySnapshotDTO, UserContextDTO,
21
+ HeadacheLogSnapshotDTO, HealthKitMetricsDTO, WeatherDataDTO,
22
+ SleepAnalysisDTO, HRVSummaryDTO,
23
+ )
24
+
25
+ MOOD_MAP = {"great": 5, "good": 4, "okay": 3, "bad": 2, "terrible": 1}
26
+
27
+ FEATURE_NAMES = [
28
+ "pressure_mb", "pressure_change_24h", "pressure_volatility",
29
+ "humidity_pct", "temperature_c", "is_pressure_drop",
30
+ "sleep_total_hours", "deep_sleep_min", "rem_sleep_min",
31
+ "resting_hr", "hrv_avg_ms", "workout_min", "menstrual_flow_flag",
32
+ "had_headache_1d", "severity_1d", "duration_1d",
33
+ "mood_1d", "symptom_count_1d", "trigger_count_1d",
34
+ "had_headache_2d", "severity_2d", "duration_2d",
35
+ "dow_sin", "dow_cos", "month_sin", "month_cos",
36
+ "doy_sin", "doy_cos", "is_weekend",
37
+ "age_midpoint", "is_europe", "is_tropical",
38
+ "sleep_x_pressure", "low_hrv_flag", "sleep_deficit",
39
+ "high_humidity_flag", "headache_streak_2d", "consecutive_headache_days",
40
+ ]
41
+ NUM_FEATURES = len(FEATURE_NAMES) # 38
42
+
43
+ # Human-readable risk factor labels for the API response
44
+ RISK_LABELS = {
45
+ "had_headache_1d": "recent_headache",
46
+ "pressure_change_24h": "barometric_pressure_drop",
47
+ "consecutive_headache_days": "headache_streak",
48
+ "hrv_avg_ms": "low_hrv_stress",
49
+ "headache_streak_2d": "multi_day_pattern",
50
+ "humidity_pct": "high_humidity",
51
+ "menstrual_flow_flag": "menstrual_phase",
52
+ "temperature_c": "temperature_extreme",
53
+ "sleep_total_hours": "poor_sleep",
54
+ "is_weekend": "weekend_pattern",
55
+ "sleep_deficit": "sleep_deficit",
56
+ "low_hrv_flag": "stress_indicator",
57
+ "is_pressure_drop": "pressure_front",
58
+ }
59
+
60
+
61
+ def _safe(val, default=0.0) -> float:
62
+ return float(val) if val is not None else default
63
+
64
+ def _cyclic(value: float, period: float):
65
+ a = 2 * math.pi * value / period
66
+ return math.sin(a), math.cos(a)
67
+
68
+ def _parse_age_range(age_range: Optional[str]) -> float:
69
+ if not age_range:
70
+ return 35.0
71
+ try:
72
+ parts = age_range.replace(" ", "").split("-")
73
+ return (float(parts[0]) + float(parts[1])) / 2.0
74
+ except Exception:
75
+ return 35.0
76
+
77
+
78
+ def extract_features_for_day(
79
+ target_weather: WeatherDataDTO,
80
+ target_date: str,
81
+ yesterday_snapshot: Optional[DailySnapshotDTO],
82
+ two_days_ago_snapshot: Optional[DailySnapshotDTO],
83
+ user_ctx: Optional[UserContextDTO] = None,
84
+ consecutive_headache_days: int = 0,
85
+ ) -> np.ndarray:
86
+ """Build 38-feature vector for predicting headache on target_date."""
87
+ f: List[float] = []
88
+
89
+ w = target_weather or WeatherDataDTO()
90
+ yest = yesterday_snapshot or DailySnapshotDTO()
91
+ twod = two_days_ago_snapshot or DailySnapshotDTO()
92
+ ctx = user_ctx or UserContextDTO()
93
+
94
+ yest_hk = yest.health_kit_metrics or HealthKitMetricsDTO()
95
+ yest_sl = yest_hk.sleep_analysis or SleepAnalysisDTO()
96
+ yest_hrv = yest_hk.hrv_summary or HRVSummaryDTO()
97
+ yest_log = yest.headache_log or HeadacheLogSnapshotDTO()
98
+ twod_log = twod.headache_log or HeadacheLogSnapshotDTO()
99
+
100
+ # Weather target (6)
101
+ pc = _safe(w.pressure_change_24h_mb, 0.0)
102
+ hum = _safe(w.humidity_percent, 50.0)
103
+ f.append(_safe(w.barometric_pressure_mb, 1013.25))
104
+ f.append(pc)
105
+ f.append(abs(pc))
106
+ f.append(hum)
107
+ f.append(_safe(w.temperature_celsius, 15.0))
108
+ f.append(1.0 if pc < -5 else 0.0)
109
+
110
+ # HealthKit yesterday (7)
111
+ slp = _safe(yest_sl.total_duration_hours, 7.0)
112
+ hrv = _safe(yest_hrv.average_ms, 40.0)
113
+ f.append(slp)
114
+ f.append(_safe(yest_sl.deep_sleep_minutes, 80.0))
115
+ f.append(_safe(yest_sl.rem_sleep_minutes, 90.0))
116
+ f.append(_safe(yest_hk.resting_heart_rate, 65.0))
117
+ f.append(hrv)
118
+ f.append(_safe(yest_hk.workout_minutes, 0))
119
+ f.append(1.0 if yest_hk.had_menstrual_flow else 0.0)
120
+
121
+ # Headache yesterday (6)
122
+ yh = 1.0 if yest_log.severity > 0 else 0.0
123
+ f.append(yh)
124
+ f.append(float(yest_log.severity))
125
+ f.append(float(yest_log.duration_hours))
126
+ f.append(float(MOOD_MAP.get(str(yest_log.mood).lower(), 3)))
127
+ f.append(float(len(yest_log.symptoms.symptoms)))
128
+ f.append(float(len(yest_log.triggers.triggers)))
129
+
130
+ # Headache 2d ago (3)
131
+ th = 1.0 if twod_log.severity > 0 else 0.0
132
+ f.append(th)
133
+ f.append(float(twod_log.severity))
134
+ f.append(float(twod_log.duration_hours))
135
+
136
+ # Temporal (7)
137
+ try:
138
+ dt = datetime.strptime(target_date, "%Y-%m-%d")
139
+ except (ValueError, TypeError):
140
+ dt = datetime.now()
141
+ dw_s, dw_c = _cyclic(dt.weekday(), 7)
142
+ mn_s, mn_c = _cyclic(dt.month - 1, 12)
143
+ dy_s, dy_c = _cyclic(dt.timetuple().tm_yday, 365)
144
+ f.extend([dw_s, dw_c, mn_s, mn_c, dy_s, dy_c])
145
+ f.append(1.0 if dt.weekday() >= 5 else 0.0)
146
+
147
+ # User context (3)
148
+ f.append(_parse_age_range(ctx.age_range))
149
+ reg = str(ctx.location_region or "").lower()
150
+ f.append(1.0 if "europe" in reg else 0.0)
151
+ f.append(1.0 if "tropic" in reg else 0.0)
152
+
153
+ # Interactions (6)
154
+ f.append(slp * abs(pc))
155
+ f.append(1.0 if hrv < 25 else 0.0)
156
+ f.append(max(0.0, 6.0 - slp))
157
+ f.append(1.0 if hum > 80 else 0.0)
158
+ f.append(yh + th)
159
+ f.append(float(min(consecutive_headache_days, 7)))
160
+
161
+ return np.array(f, dtype=np.float32)
162
+
163
+
164
+ def extract_forecast_features(
165
+ snapshots: List[DailySnapshotDTO],
166
+ user_ctx: Optional[UserContextDTO] = None,
167
+ ) -> np.ndarray:
168
+ """
169
+ Build feature matrix for 7-day forecast.
170
+ snapshots[0] = today (full data), [1..6] = future (weather only).
171
+ """
172
+ rows = []
173
+ for i in range(len(snapshots)):
174
+ snap = snapshots[i]
175
+ tw = snap.weather_data or WeatherDataDTO()
176
+ td = ""
177
+ if snap.headache_log and snap.headache_log.input_date:
178
+ td = snap.headache_log.input_date
179
+
180
+ yest = snapshots[i - 1] if i > 0 else None
181
+ twod = snapshots[i - 2] if i > 1 else None
182
+
183
+ consec = 0
184
+ for j in range(i - 1, -1, -1):
185
+ lj = snapshots[j].headache_log
186
+ if lj and lj.severity > 0:
187
+ consec += 1
188
+ else:
189
+ break
190
+
191
+ rows.append(extract_features_for_day(tw, td, yest, twod, user_ctx, consec))
192
+ return np.vstack(rows)
193
+
194
+
195
+ def get_risk_factors(
196
+ features: np.ndarray,
197
+ feature_importances: dict,
198
+ top_k: int = 3,
199
+ ) -> List[str]:
200
+ """Identify top risk factors from feature values + learned importances."""
201
+ risks = []
202
+
203
+ # Check each important feature for concerning values
204
+ checks = [
205
+ ("had_headache_1d", lambda v: v > 0),
206
+ ("pressure_change_24h", lambda v: v < -3),
207
+ ("consecutive_headache_days", lambda v: v >= 2),
208
+ ("hrv_avg_ms", lambda v: v < 30),
209
+ ("headache_streak_2d", lambda v: v >= 1),
210
+ ("humidity_pct", lambda v: v > 75),
211
+ ("menstrual_flow_flag", lambda v: v > 0),
212
+ ("temperature_c", lambda v: v > 30 or v < -5),
213
+ ("sleep_total_hours", lambda v: v < 6),
214
+ ("sleep_deficit", lambda v: v > 0),
215
+ ("low_hrv_flag", lambda v: v > 0),
216
+ ("is_pressure_drop", lambda v: v > 0),
217
+ ("is_weekend", lambda v: v > 0),
218
+ ]
219
+
220
+ # Sort by feature importance
221
+ sorted_checks = sorted(
222
+ checks,
223
+ key=lambda x: feature_importances.get(x[0], 0),
224
+ reverse=True,
225
+ )
226
+
227
+ for fname, condition in sorted_checks:
228
+ if fname in FEATURE_NAMES:
229
+ idx = FEATURE_NAMES.index(fname)
230
+ if condition(features[idx]):
231
+ label = RISK_LABELS.get(fname, fname)
232
+ if label not in risks:
233
+ risks.append(label)
234
+ if len(risks) >= top_k:
235
+ break
236
+
237
+ return risks
metadata.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "optimal_threshold": 0.293748559646689,
3
+ "feature_names": [
4
+ "pressure_mb",
5
+ "pressure_change_24h",
6
+ "pressure_volatility",
7
+ "humidity_pct",
8
+ "temperature_c",
9
+ "is_pressure_drop",
10
+ "sleep_total_hours",
11
+ "deep_sleep_min",
12
+ "rem_sleep_min",
13
+ "resting_hr",
14
+ "hrv_avg_ms",
15
+ "workout_min",
16
+ "menstrual_flow_flag",
17
+ "had_headache_1d",
18
+ "severity_1d",
19
+ "duration_1d",
20
+ "mood_1d",
21
+ "symptom_count_1d",
22
+ "trigger_count_1d",
23
+ "had_headache_2d",
24
+ "severity_2d",
25
+ "duration_2d",
26
+ "dow_sin",
27
+ "dow_cos",
28
+ "month_sin",
29
+ "month_cos",
30
+ "doy_sin",
31
+ "doy_cos",
32
+ "is_weekend",
33
+ "age_midpoint",
34
+ "is_europe",
35
+ "is_tropical",
36
+ "sleep_x_pressure",
37
+ "low_hrv_flag",
38
+ "sleep_deficit",
39
+ "high_humidity_flag",
40
+ "headache_streak_2d",
41
+ "consecutive_headache_days"
42
+ ],
43
+ "num_features": 38,
44
+ "model_version": "3.0.0",
45
+ "trained_at": "2026-03-12T10:47:40.844379",
46
+ "test_metrics": {
47
+ "roc_auc": 0.6859,
48
+ "pr_auc": 0.5669,
49
+ "f1": 0.5593
50
+ },
51
+ "training_rows": 150480,
52
+ "feature_importances": {
53
+ "had_headache_1d": 0.039352,
54
+ "pressure_change_24h": 0.008701,
55
+ "consecutive_headache_days": 0.007257,
56
+ "hrv_avg_ms": 0.004118,
57
+ "headache_streak_2d": 0.003967,
58
+ "duration_2d": 0.003535,
59
+ "duration_1d": 0.002007,
60
+ "humidity_pct": 0.001802,
61
+ "menstrual_flow_flag": 0.001657,
62
+ "temperature_c": 0.001522,
63
+ "resting_hr": 0.000438,
64
+ "had_headache_2d": 0.000436,
65
+ "rem_sleep_min": 0.000399,
66
+ "pressure_mb": 0.000374,
67
+ "pressure_volatility": 0.000369
68
+ }
69
+ }
model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e7d510635513fbf1390206d5631523655d18c49c127bd9c91a893c0eddf29a3d
3
+ size 10437799
models.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic models β€” exact mirrors of Phoebe iOS Swift DTOs.
3
+
4
+ Field names = snake_case matching CodingKeys from:
5
+ - APIModels.swift (HeadacheLogSnapshotDTO, DailySnapshotDTO)
6
+ - InsightPayloadDTO.swift (HealthKitMetricsDTO, WeatherDataDTO, UserContextDTO)
7
+ """
8
+
9
+ from __future__ import annotations
10
+ from pydantic import BaseModel, Field
11
+ from typing import List, Optional
12
+ from enum import Enum
13
+
14
+
15
+ # ── Enums ────────────────────────────────────────────────────────────
16
+
17
+ class MedicationResponseEnum(str, Enum):
18
+ horrible = "horrible"
19
+ worse = "worse"
20
+ same = "same"
21
+ better = "better"
22
+ excellent = "excellent"
23
+
24
+
25
+ # ── Diary sub-payloads ───────────────────────────────────────────────
26
+
27
+ class SymptomsPayload(BaseModel):
28
+ symptoms: List[str] = []
29
+
30
+ class TriggersPayload(BaseModel):
31
+ triggers: List[str] = []
32
+
33
+ class MedicationPayload(BaseModel):
34
+ medication_taken: List[str] = []
35
+
36
+ class TherapeuticPayload(BaseModel):
37
+ therapeutic_activities: List[str] = []
38
+
39
+
40
+ # ── HealthKit (from InsightPayloadDTO.swift) ─────────────────────────
41
+
42
+ class SleepAnalysisDTO(BaseModel):
43
+ total_duration_hours: Optional[float] = None
44
+ deep_sleep_minutes: Optional[float] = None
45
+ rem_sleep_minutes: Optional[float] = None
46
+
47
+ class HRVSummaryDTO(BaseModel):
48
+ average_ms: Optional[float] = None
49
+
50
+ class HealthKitMetricsDTO(BaseModel):
51
+ resting_heart_rate: Optional[float] = None
52
+ sleep_analysis: Optional[SleepAnalysisDTO] = None
53
+ hrv_summary: Optional[HRVSummaryDTO] = None
54
+ workout_minutes: Optional[int] = None
55
+ had_menstrual_flow: Optional[bool] = None
56
+
57
+
58
+ # ── Weather (from InsightPayloadDTO.swift) ───────────────────────────
59
+
60
+ class WeatherDataDTO(BaseModel):
61
+ barometric_pressure_mb: float = 1013.25
62
+ pressure_change_24h_mb: float = 0.0
63
+ humidity_percent: float = 50.0
64
+ temperature_celsius: float = 15.0
65
+
66
+
67
+ # ── Headache log (from APIModels.swift) ──────────────────────────────
68
+
69
+ class HeadacheLogSnapshotDTO(BaseModel):
70
+ severity: int = 0
71
+ duration_hours: float = 0.0
72
+ symptoms: SymptomsPayload = Field(default_factory=SymptomsPayload)
73
+ triggers: TriggersPayload = Field(default_factory=TriggersPayload)
74
+ notes: Optional[str] = None
75
+ input_date: str = ""
76
+ input_time: Optional[str] = None
77
+ end_date: Optional[str] = None
78
+ end_time: Optional[str] = None
79
+ pain_frontal: bool = False
80
+ pain_temporal_left: bool = False
81
+ pain_temporal_right: bool = False
82
+ pain_occipital: bool = False
83
+ pain_parietal: bool = False
84
+ pain_ocular_left: bool = False
85
+ pain_ocular_right: bool = False
86
+ pain_sinus: bool = False
87
+ mood: Optional[str] = None
88
+ medication_taken: Optional[MedicationPayload] = None
89
+ medication_response: Optional[MedicationResponseEnum] = None
90
+ therapeutic_activities: Optional[TherapeuticPayload] = None
91
+
92
+
93
+ # ── DailySnapshotDTO (top-level) ────────────────────────────────────
94
+
95
+ class DailySnapshotDTO(BaseModel):
96
+ headache_log: Optional[HeadacheLogSnapshotDTO] = None
97
+ health_kit_metrics: Optional[HealthKitMetricsDTO] = None
98
+ weather_data: Optional[WeatherDataDTO] = None
99
+
100
+
101
+ # ── UserContextDTO ──────────────────────────────────────────────────
102
+
103
+ class UserContextDTO(BaseModel):
104
+ age_range: Optional[str] = None
105
+ location_region: Optional[str] = None
106
+
107
+
108
+ # ── API request / response ──────────────────────────────────────────
109
+
110
+ class PredictionRequest(BaseModel):
111
+ user_context: Optional[UserContextDTO] = None
112
+ daily_snapshots: List[DailySnapshotDTO]
113
+
114
+ class DayPrediction(BaseModel):
115
+ day: int
116
+ date: Optional[str] = None
117
+ prediction: int
118
+ probability: float
119
+ risk_level: str
120
+ top_risk_factors: List[str] = []
121
+
122
+ class PredictionResponse(BaseModel):
123
+ predictions: List[DayPrediction]
124
+ model_version: str
125
+ threshold: float
126
+
127
+ # Legacy
128
+ class SinglePredictionRequest(BaseModel):
129
+ features: List[float]
130
+
131
+ class SinglePredictionResponse(BaseModel):
132
+ prediction: int
133
+ probability: float
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.104.0
2
+ uvicorn[standard]>=0.24.0
3
+ pydantic>=2.5.0
4
+ numpy>=1.24.0
5
+ scikit-learn>=1.3.0
6
+ huggingface_hub>=0.19.0
run_training.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ═══════════════════════════════════════════════════════════════════════════
3
+ PHOEBE HEADACHE PREDICTOR v3.0 β€” Production Training Pipeline
4
+ EmpedocLabs Β© 2025
5
+ ═══════════════════════════════════════════════════════════════════════════
6
+
7
+ Clinical-grade synthetic data with user archetypes:
8
+ - Chronic migraineur (high baseline, medication dependent)
9
+ - Episodic tension-type (stress/sleep driven)
10
+ - Menstrual migraine (hormonal cycle dominant)
11
+ - Weather-sensitive (barometric pressure dominant)
12
+ - Mixed/general (moderate baseline)
13
+
14
+ Leak-free: predicts day T headache using day T weather + day T-1 health/diary.
15
+
16
+ 38 features matching the iOS DailySnapshotDTO:
17
+ WeatherKit forecast (6) β€” pressure, Ξ”p, |Ξ”p|, humidity, temp, drop flag
18
+ HealthKit yesterday (7) β€” sleep h/deep/rem, rhr, hrv, workout, menstrual
19
+ Diary yesterday (6) β€” headache, severity, duration, mood, #symptoms, #triggers
20
+ Diary 2-days-ago (3) β€” headache, severity, duration
21
+ Temporal (7) β€” dow sin/cos, month sin/cos, doy sin/cos, weekend
22
+ User context (3) β€” age, is_europe, is_tropical
23
+ Interactions (6) β€” sleepΓ—pressure, low_hrv, sleep_deficit,
24
+ high_humidity, streak_2d, consecutive_days
25
+ """
26
+
27
+ import os, sys, math, random, pickle, json, warnings
28
+ import numpy as np
29
+ import pandas as pd
30
+ from datetime import datetime, timedelta
31
+ from sklearn.model_selection import GroupShuffleSplit
32
+ from sklearn.ensemble import HistGradientBoostingClassifier
33
+ from sklearn.calibration import CalibratedClassifierCV
34
+ from sklearn.metrics import (
35
+ classification_report, roc_auc_score, f1_score,
36
+ precision_recall_curve, average_precision_score, confusion_matrix,
37
+ )
38
+
39
+ warnings.filterwarnings("ignore")
40
+
41
+ # ═══════════════════════════════════════════════════════════════════════
42
+ # FEATURE SCHEMA
43
+ # ═══════════════════════════════════════════════════════════════════════
44
+
45
+ FEATURE_NAMES = [
46
+ "pressure_mb", "pressure_change_24h", "pressure_volatility",
47
+ "humidity_pct", "temperature_c", "is_pressure_drop",
48
+ "sleep_total_hours", "deep_sleep_min", "rem_sleep_min",
49
+ "resting_hr", "hrv_avg_ms", "workout_min", "menstrual_flow_flag",
50
+ "had_headache_1d", "severity_1d", "duration_1d",
51
+ "mood_1d", "symptom_count_1d", "trigger_count_1d",
52
+ "had_headache_2d", "severity_2d", "duration_2d",
53
+ "dow_sin", "dow_cos", "month_sin", "month_cos",
54
+ "doy_sin", "doy_cos", "is_weekend",
55
+ "age_midpoint", "is_europe", "is_tropical",
56
+ "sleep_x_pressure", "low_hrv_flag", "sleep_deficit",
57
+ "high_humidity_flag", "headache_streak_2d", "consecutive_headache_days",
58
+ ]
59
+ NUM_FEATURES = len(FEATURE_NAMES) # 38
60
+
61
+ # ═══════════════════════════════════════════════════════════════════════
62
+ # USER ARCHETYPES (based on migraine clinical literature)
63
+ # ═══════════════════════════════════════════════════════════════════════
64
+
65
+ ARCHETYPES = {
66
+ "chronic_migraine": {
67
+ "weight": 0.20,
68
+ "base_rate": 0.40, # ~12 headache days/month
69
+ "sensitivity": 0.3,
70
+ "pressure_coeff": 0.5,
71
+ "sleep_coeff": 0.6,
72
+ "hrv_coeff": 0.3,
73
+ "menstrual_coeff": 0.4,
74
+ "humidity_coeff": 0.2,
75
+ "rebound_coeff": 0.7, # strong rebound/cluster effect
76
+ "weekend_coeff": 0.1,
77
+ "temp_coeff": 0.15,
78
+ },
79
+ "episodic_tension": {
80
+ "weight": 0.25,
81
+ "base_rate": 0.12,
82
+ "sensitivity": 0.0,
83
+ "pressure_coeff": 0.2,
84
+ "sleep_coeff": 0.9, # very sleep-dependent
85
+ "hrv_coeff": 0.7, # very stress-dependent
86
+ "menstrual_coeff": 0.1,
87
+ "humidity_coeff": 0.1,
88
+ "rebound_coeff": 0.2,
89
+ "weekend_coeff": 0.25, # "weekend headache" pattern
90
+ "temp_coeff": 0.1,
91
+ },
92
+ "menstrual_migraine": {
93
+ "weight": 0.20,
94
+ "base_rate": 0.15,
95
+ "sensitivity": 0.1,
96
+ "pressure_coeff": 0.3,
97
+ "sleep_coeff": 0.4,
98
+ "hrv_coeff": 0.3,
99
+ "menstrual_coeff": 1.2, # dominant factor
100
+ "humidity_coeff": 0.15,
101
+ "rebound_coeff": 0.4,
102
+ "weekend_coeff": 0.05,
103
+ "temp_coeff": 0.1,
104
+ },
105
+ "weather_sensitive": {
106
+ "weight": 0.15,
107
+ "base_rate": 0.15,
108
+ "sensitivity": 0.1,
109
+ "pressure_coeff": 1.0, # dominant factor
110
+ "sleep_coeff": 0.3,
111
+ "hrv_coeff": 0.2,
112
+ "menstrual_coeff": 0.2,
113
+ "humidity_coeff": 0.6, # also weather
114
+ "rebound_coeff": 0.3,
115
+ "weekend_coeff": 0.05,
116
+ "temp_coeff": 0.4, # temperature sensitive too
117
+ },
118
+ "mixed_general": {
119
+ "weight": 0.20,
120
+ "base_rate": 0.18,
121
+ "sensitivity": 0.0,
122
+ "pressure_coeff": 0.4,
123
+ "sleep_coeff": 0.5,
124
+ "hrv_coeff": 0.4,
125
+ "menstrual_coeff": 0.3,
126
+ "humidity_coeff": 0.2,
127
+ "rebound_coeff": 0.35,
128
+ "weekend_coeff": 0.12,
129
+ "temp_coeff": 0.15,
130
+ },
131
+ }
132
+
133
+
134
+ def _logistic(x):
135
+ return 1.0 / (1.0 + math.exp(-max(-20, min(20, x))))
136
+
137
+
138
+ def _cyclic(val, period):
139
+ a = 2 * math.pi * val / period
140
+ return math.sin(a), math.cos(a)
141
+
142
+
143
+ # ═══════════════════════════════════════════════════════════════════════
144
+ # USER CLASS
145
+ # ═══════════════════════════════════════════════════════════════════════
146
+
147
+ class User:
148
+ def __init__(self, uid):
149
+ self.uid = uid
150
+
151
+ # Pick archetype by weights
152
+ names = list(ARCHETYPES.keys())
153
+ weights = [ARCHETYPES[n]["weight"] for n in names]
154
+ self.archetype_name = random.choices(names, weights=weights)[0]
155
+ self.arch = ARCHETYPES[self.archetype_name]
156
+
157
+ age_lo = random.choice([18, 20, 25, 30, 35, 40, 45, 50, 55, 60])
158
+ self.age_mid = age_lo + 4.5
159
+ self.region = random.choices(
160
+ ["europe", "americas", "asia", "tropical"],
161
+ weights=[40, 30, 20, 10],
162
+ )[0]
163
+
164
+ # Menstrual migraine archetype β†’ always female
165
+ if self.archetype_name == "menstrual_migraine":
166
+ self.is_female = True
167
+ else:
168
+ self.is_female = random.random() < 0.65
169
+
170
+ # Personal baselines with variance
171
+ self.base_hr = random.gauss(65, 8)
172
+ self.base_hrv = random.gauss(45, 15)
173
+ self.base_sleep = random.gauss(7.0, 0.8)
174
+ self.personal_noise = random.gauss(0, 0.2)
175
+
176
+ # Cycle params
177
+ self.cycle_len = random.randint(26, 32) if self.is_female else 0
178
+ self.cycle_off = random.randint(0, 30)
179
+
180
+
181
+ # ═══════════════════════════════════════════════════════════════════════
182
+ # DATA GENERATION β€” per user
183
+ # ═══════════════════════════════════════════════════════════════════════
184
+
185
+ def generate_user(user: User, n_days: int, start: datetime):
186
+ """Generate n_days of raw data, return leak-free (features, labels)."""
187
+ a = user.arch
188
+
189
+ # Weather random walk
190
+ pressure = random.gauss(1013.25, 8)
191
+ temp = random.gauss(15, 10)
192
+ humidity = random.gauss(60, 15)
193
+
194
+ raw = []
195
+
196
+ for d in range(n_days):
197
+ dt = start + timedelta(days=d)
198
+ month = dt.month
199
+
200
+ # ── Weather with realistic autocorrelation ───────────────────
201
+ seasonal_t = 15 + 14 * math.sin(2 * math.pi * (month - 4) / 12)
202
+ seasonal_h = 55 + 20 * math.sin(2 * math.pi * (month - 7) / 12)
203
+
204
+ # Pressure: occasional fronts (sudden drops)
205
+ if random.random() < 0.08: # ~3x/month cold front
206
+ p_change = random.gauss(-8, 3)
207
+ elif random.random() < 0.05: # occasional rapid rise
208
+ p_change = random.gauss(6, 2)
209
+ else:
210
+ p_change = random.gauss(0, 2.5) + 0.12 * (1013.25 - pressure)
211
+ pressure += p_change
212
+ pressure = max(970, min(1050, pressure))
213
+
214
+ temp += random.gauss(0, 1.8) + 0.15 * (seasonal_t - temp)
215
+ humidity += random.gauss(0, 4) + 0.08 * (seasonal_h - humidity)
216
+ humidity = max(15, min(98, humidity))
217
+
218
+ # ── HealthKit ────────────────────────────────────────────────
219
+ # Sleep varies: weekends slightly longer, bad days shorter
220
+ base_s = user.base_sleep + (0.5 if dt.weekday() >= 5 else 0)
221
+ sleep_h = max(2.5, random.gauss(base_s, 1.0))
222
+
223
+ # If had headache yesterday β†’ worse sleep tonight
224
+ if d > 0 and raw[d-1]["headache"]:
225
+ sleep_h = max(2.5, sleep_h - random.gauss(0.8, 0.5))
226
+
227
+ deep = max(0, random.gauss(75 + sleep_h * 3, 18))
228
+ rem = max(0, random.gauss(80 + sleep_h * 5, 22))
229
+
230
+ rhr = max(45, random.gauss(user.base_hr, 4))
231
+ # HRV: stress lowers it, good sleep raises it
232
+ hrv_base = user.base_hrv + (sleep_h - 7) * 3
233
+ hrv = max(8, random.gauss(hrv_base, 8))
234
+
235
+ workout = max(0, int(random.gauss(25, 18)))
236
+ # Less workout on headache days
237
+ if d > 0 and raw[d-1]["headache"]:
238
+ workout = max(0, workout - 15)
239
+
240
+ cycle_day = 0
241
+ menstrual = False
242
+ if user.is_female and user.cycle_len > 0:
243
+ cycle_day = ((d + user.cycle_off) % user.cycle_len) + 1
244
+ menstrual = cycle_day <= 4
245
+
246
+ # ── Headache probability ─────────────────────────────────────
247
+ base_rate = a["base_rate"]
248
+ lo = math.log(base_rate / (1 - base_rate))
249
+ lo += user.personal_noise
250
+
251
+ # Barometric pressure: graded response
252
+ if p_change < -8:
253
+ lo += a["pressure_coeff"] * 1.2
254
+ elif p_change < -5:
255
+ lo += a["pressure_coeff"] * 0.8
256
+ elif p_change < -3:
257
+ lo += a["pressure_coeff"] * 0.4
258
+ elif p_change > 8:
259
+ lo += a["pressure_coeff"] * 0.5 # rapid rise also triggers
260
+
261
+ # Sleep: graded response
262
+ if sleep_h < 4:
263
+ lo += a["sleep_coeff"] * 1.2
264
+ elif sleep_h < 5:
265
+ lo += a["sleep_coeff"] * 0.8
266
+ elif sleep_h < 6:
267
+ lo += a["sleep_coeff"] * 0.4
268
+ elif sleep_h > 9:
269
+ lo += a["sleep_coeff"] * 0.3 # oversleep trigger
270
+
271
+ # HRV (stress proxy): graded
272
+ if hrv < 20:
273
+ lo += a["hrv_coeff"] * 1.0
274
+ elif hrv < 30:
275
+ lo += a["hrv_coeff"] * 0.6
276
+ elif hrv < 35:
277
+ lo += a["hrv_coeff"] * 0.2
278
+
279
+ # Menstrual: perimenstrual window (days 1-3, 26-28)
280
+ if user.is_female and user.cycle_len > 0:
281
+ if cycle_day <= 3:
282
+ lo += a["menstrual_coeff"] * 1.0
283
+ elif cycle_day <= 5:
284
+ lo += a["menstrual_coeff"] * 0.4
285
+ elif cycle_day >= user.cycle_len - 2:
286
+ lo += a["menstrual_coeff"] * 0.7 # premenstrual
287
+
288
+ # Humidity
289
+ if humidity > 85:
290
+ lo += a["humidity_coeff"] * 0.8
291
+ elif humidity > 75:
292
+ lo += a["humidity_coeff"] * 0.3
293
+
294
+ # Temperature extremes
295
+ if temp > 32 or temp < -8:
296
+ lo += a["temp_coeff"] * 0.8
297
+ elif temp > 28 or temp < -3:
298
+ lo += a["temp_coeff"] * 0.3
299
+
300
+ # Rebound / cluster effect
301
+ if d > 0 and raw[d-1]["headache"]:
302
+ lo += a["rebound_coeff"] * 0.6
303
+ if d > 1 and raw[d-2]["headache"]:
304
+ lo += a["rebound_coeff"] * 0.3 # 2-day streak
305
+
306
+ # Weekend "let-down" headache
307
+ if dt.weekday() == 5: # Saturday
308
+ lo += a["weekend_coeff"]
309
+ elif dt.weekday() == 6:
310
+ lo += a["weekend_coeff"] * 0.6
311
+
312
+ # Small random noise (less than before β€” let signal dominate)
313
+ lo += random.gauss(0, 0.15)
314
+
315
+ prob = _logistic(lo)
316
+ headache = random.random() < prob
317
+
318
+ # ── Diary details ────────────────────────────────────────────
319
+ if headache:
320
+ severity = random.choices([1,2,3,4,5], weights=[8,22,38,22,10])[0]
321
+ duration = round(max(0.5, random.gauss(2.5 + severity * 1.0, 1.5)), 1)
322
+ n_symp = random.randint(1, min(5, 1 + severity))
323
+ n_trig = random.randint(0, min(4, severity))
324
+ mood = random.choices([1,2,3,4,5], weights=[30,35,25,8,2])[0]
325
+ else:
326
+ severity, duration, n_symp, n_trig = 0, 0.0, 0, 0
327
+ mood = random.choices([1,2,3,4,5], weights=[3,8,25,38,26])[0]
328
+
329
+ raw.append({
330
+ "dt": dt, "pressure": pressure, "p_change": p_change,
331
+ "humidity": humidity, "temp": temp,
332
+ "sleep_h": round(sleep_h, 1), "deep": round(deep, 0),
333
+ "rem": round(rem, 0), "rhr": round(rhr, 0),
334
+ "hrv": round(hrv, 1), "workout": workout,
335
+ "menstrual": menstrual,
336
+ "headache": headache, "severity": severity,
337
+ "duration": duration, "mood": mood,
338
+ "n_symp": n_symp, "n_trig": n_trig,
339
+ })
340
+
341
+ # ── Build feature vectors (leak-free) ────────────────────────────
342
+ rows, labels = [], []
343
+ consec = 0
344
+
345
+ for i in range(2, n_days):
346
+ t = raw[i] # target day
347
+ y = raw[i - 1] # yesterday
348
+ p = raw[i - 2] # 2 days ago
349
+ dt = t["dt"]
350
+ f = []
351
+
352
+ # Weather target (6)
353
+ f.append(t["pressure"])
354
+ f.append(t["p_change"])
355
+ f.append(abs(t["p_change"]))
356
+ f.append(t["humidity"])
357
+ f.append(t["temp"])
358
+ f.append(1.0 if t["p_change"] < -5 else 0.0)
359
+
360
+ # HealthKit yesterday (7)
361
+ f.append(y["sleep_h"])
362
+ f.append(y["deep"])
363
+ f.append(y["rem"])
364
+ f.append(y["rhr"])
365
+ f.append(y["hrv"])
366
+ f.append(float(y["workout"]))
367
+ f.append(1.0 if y["menstrual"] else 0.0)
368
+
369
+ # Diary yesterday (6)
370
+ f.append(1.0 if y["headache"] else 0.0)
371
+ f.append(float(y["severity"]))
372
+ f.append(float(y["duration"]))
373
+ f.append(float(y["mood"]))
374
+ f.append(float(y["n_symp"]))
375
+ f.append(float(y["n_trig"]))
376
+
377
+ # Diary 2d ago (3)
378
+ f.append(1.0 if p["headache"] else 0.0)
379
+ f.append(float(p["severity"]))
380
+ f.append(float(p["duration"]))
381
+
382
+ # Temporal (7)
383
+ dw_s, dw_c = _cyclic(dt.weekday(), 7)
384
+ mn_s, mn_c = _cyclic(dt.month - 1, 12)
385
+ dy_s, dy_c = _cyclic(dt.timetuple().tm_yday, 365)
386
+ f.extend([dw_s, dw_c, mn_s, mn_c, dy_s, dy_c])
387
+ f.append(1.0 if dt.weekday() >= 5 else 0.0)
388
+
389
+ # User context (3)
390
+ f.append(user.age_mid)
391
+ f.append(1.0 if "europe" in user.region else 0.0)
392
+ f.append(1.0 if "tropical" in user.region else 0.0)
393
+
394
+ # Interactions (6)
395
+ f.append(y["sleep_h"] * abs(t["p_change"]))
396
+ f.append(1.0 if y["hrv"] < 25 else 0.0)
397
+ f.append(max(0.0, 6.0 - y["sleep_h"]))
398
+ f.append(1.0 if t["humidity"] > 80 else 0.0)
399
+ streak = (1.0 if y["headache"] else 0.0) + (1.0 if p["headache"] else 0.0)
400
+ f.append(streak)
401
+ consec = (consec + 1) if y["headache"] else 0
402
+ f.append(float(min(consec, 7)))
403
+
404
+ rows.append(f)
405
+ labels.append(1 if t["headache"] else 0)
406
+
407
+ return np.array(rows, dtype=np.float32), np.array(labels, dtype=np.int32)
408
+
409
+
410
+ # ═══════════════════════════════════════════════════════════════════════
411
+ # DATASET ASSEMBLY
412
+ # ═══════════════════════════════════════════════════════════════════════
413
+
414
+ def generate_dataset(n_users=2000, days=365, seed=42):
415
+ random.seed(seed)
416
+ np.random.seed(seed)
417
+
418
+ all_X, all_y, all_uid, all_arch = [], [], [], []
419
+ start = datetime(2023, 6, 1)
420
+
421
+ arch_counts = {}
422
+
423
+ for uid in range(n_users):
424
+ user = User(uid)
425
+ arch_counts[user.archetype_name] = arch_counts.get(user.archetype_name, 0) + 1
426
+ X_u, y_u = generate_user(user, days, start)
427
+ all_X.append(X_u)
428
+ all_y.append(y_u)
429
+ all_uid.extend([uid] * len(y_u))
430
+ all_arch.extend([user.archetype_name] * len(y_u))
431
+ if (uid + 1) % 200 == 0:
432
+ print(f" {uid + 1}/{n_users} users generated")
433
+
434
+ X = np.vstack(all_X)
435
+ y = np.concatenate(all_y)
436
+
437
+ df = pd.DataFrame(X, columns=FEATURE_NAMES)
438
+ df["headache"] = y
439
+ df["user_id"] = all_uid
440
+ df["archetype"] = all_arch
441
+
442
+ print(f"\nβœ… Dataset: {df.shape[0]:,} rows Γ— {NUM_FEATURES} features")
443
+ print(f" Headache rate: {y.mean():.1%}")
444
+ print(f" Archetypes: {arch_counts}")
445
+ return df
446
+
447
+
448
+ # ═══════════════════════════════════════════════════════════════════════
449
+ # TRAINING
450
+ # ═══════════════════════════════════════════════════════════════════════
451
+
452
+ def group_split(df, test_f=0.12, val_f=0.12, seed=42):
453
+ gss = GroupShuffleSplit(n_splits=1, test_size=test_f, random_state=seed)
454
+ i_tv, i_te = next(gss.split(df, groups=df["user_id"]))
455
+ df_tv, df_test = df.iloc[i_tv], df.iloc[i_te]
456
+ rel_val = val_f / (1 - test_f)
457
+ gss2 = GroupShuffleSplit(n_splits=1, test_size=rel_val, random_state=seed)
458
+ i_tr, i_v = next(gss2.split(df_tv, groups=df_tv["user_id"]))
459
+ return df_tv.iloc[i_tr], df_tv.iloc[i_v], df_test
460
+
461
+
462
+ def tune_threshold(y_true, y_prob):
463
+ prec, rec, thr = precision_recall_curve(y_true, y_prob)
464
+ f1 = 2 * prec * rec / (prec + rec + 1e-8)
465
+ best = np.argmax(f1)
466
+ return float(thr[min(best, len(thr)-1)]), float(f1[best])
467
+
468
+
469
+ def evaluate(y_true, y_prob, thr, label):
470
+ y_pred = (y_prob >= thr).astype(int)
471
+ print(f"\n{'═'*60}")
472
+ print(f" {label} (threshold={thr:.3f})")
473
+ print(f"{'═'*60}")
474
+ print(classification_report(y_true, y_pred,
475
+ target_names=["No headache", "Headache"], zero_division=0))
476
+ auc = roc_auc_score(y_true, y_prob)
477
+ ap = average_precision_score(y_true, y_prob)
478
+ f1 = f1_score(y_true, y_pred, zero_division=0)
479
+ cm = confusion_matrix(y_true, y_pred)
480
+ print(f" ROC-AUC : {auc:.4f}")
481
+ print(f" PR-AUC : {ap:.4f}")
482
+ print(f" F1 : {f1:.4f}")
483
+ print(f" Confusion:\n{cm}")
484
+ return {"roc_auc": round(auc,4), "pr_auc": round(ap,4), "f1": round(f1,4)}
485
+
486
+
487
+ def main():
488
+ print("=" * 62)
489
+ print(" PHOEBE HEADACHE PREDICTOR v3.0 β€” Production Training")
490
+ print(" EmpedocLabs | Beta Release Build")
491
+ print("=" * 62)
492
+
493
+ # ── Generate ─────────────────────────────────────────────────────
494
+ print("\nπŸ“Š Generating clinical-grade synthetic data (2000 users Γ— 365 days)...")
495
+ df = generate_dataset(n_users=2000, days=365, seed=42)
496
+
497
+ # ── Split ────────────────────────────────────────────────────────
498
+ df_train, df_val, df_test = group_split(df)
499
+ print(f"\nπŸ“‚ Split: Train={len(df_train):,} Val={len(df_val):,} Test={len(df_test):,}")
500
+
501
+ X_tr = df_train[FEATURE_NAMES].values.astype(np.float32)
502
+ y_tr = df_train["headache"].values.astype(np.int32)
503
+ X_va = df_val[FEATURE_NAMES].values.astype(np.float32)
504
+ y_va = df_val["headache"].values.astype(np.int32)
505
+ X_te = df_test[FEATURE_NAMES].values.astype(np.float32)
506
+ y_te = df_test["headache"].values.astype(np.int32)
507
+
508
+ neg, pos = np.bincount(y_tr)
509
+ print(f" Class: neg={neg:,} pos={pos:,} ratio={neg/pos:.2f}")
510
+
511
+ # ── Train ────────────────────────────────────────────────────────
512
+ print("\nπŸš€ Training HistGradientBoosting (production config)...")
513
+ model = HistGradientBoostingClassifier(
514
+ max_iter=800,
515
+ max_depth=6,
516
+ learning_rate=0.03,
517
+ min_samples_leaf=25,
518
+ max_leaf_nodes=48,
519
+ l2_regularization=0.8,
520
+ max_features=0.85,
521
+ early_stopping=True,
522
+ validation_fraction=0.08,
523
+ n_iter_no_change=50,
524
+ scoring="loss",
525
+ class_weight="balanced",
526
+ random_state=42,
527
+ )
528
+ model.fit(X_tr, y_tr)
529
+ print(f" Iterations: {model.n_iter_}")
530
+
531
+ # ── Calibrate ────────────────────────────────────────────────────
532
+ print("\nπŸ“ Probability calibration (isotonic, 5-fold)...")
533
+ calibrated = CalibratedClassifierCV(model, method="isotonic", cv=5)
534
+ calibrated.fit(X_tr, y_tr)
535
+
536
+ # ── Threshold ────────────────────────────────────────────────────
537
+ vp = calibrated.predict_proba(X_va)[:, 1]
538
+ opt_thr, vf1 = tune_threshold(y_va, vp)
539
+ print(f"\n🎯 Optimal threshold: {opt_thr:.3f} (val F1={vf1:.4f})")
540
+
541
+ # ── Evaluate ─────────────────────────────────────────────────────
542
+ val_m = evaluate(y_va, vp, opt_thr, "VALIDATION")
543
+ tp = calibrated.predict_proba(X_te)[:, 1]
544
+ test_m = evaluate(y_te, tp, opt_thr, "TEST")
545
+
546
+ # ── Per-archetype eval ───────────────────────────────────────────
547
+ print(f"\nπŸ“Š Per-archetype performance:")
548
+ for arch in ARCHETYPES:
549
+ mask = df_test["archetype"] == arch
550
+ if mask.sum() < 50:
551
+ continue
552
+ a_y = y_te[mask.values]
553
+ a_p = tp[mask.values]
554
+ try:
555
+ a_auc = roc_auc_score(a_y, a_p)
556
+ a_f1 = f1_score(a_y, (a_p >= opt_thr).astype(int), zero_division=0)
557
+ rate = a_y.mean()
558
+ print(f" {arch:25s} n={mask.sum():6,} rate={rate:.1%} AUC={a_auc:.3f} F1={a_f1:.3f}")
559
+ except:
560
+ pass
561
+
562
+ # ── Feature importance ───────────────────────────────────────────
563
+ print(f"\nπŸ“Š Permutation feature importance...")
564
+ base_auc = roc_auc_score(y_te, tp)
565
+ imps = np.zeros(NUM_FEATURES)
566
+ rng = np.random.RandomState(42)
567
+ for fi in range(NUM_FEATURES):
568
+ Xp = X_te.copy()
569
+ Xp[:, fi] = rng.permutation(Xp[:, fi])
570
+ pp = calibrated.predict_proba(Xp)[:, 1]
571
+ imps[fi] = base_auc - roc_auc_score(y_te, pp)
572
+
573
+ top_idx = np.argsort(imps)[-15:][::-1]
574
+ print(f"\n Top features:")
575
+ mx = max(imps.max(), 1e-6)
576
+ for r, i in enumerate(top_idx, 1):
577
+ bar = "β–ˆ" * max(1, int(imps[i] / mx * 35)) if imps[i] > 0 else "Β·"
578
+ print(f" {r:2d}. {FEATURE_NAMES[i]:30s} Ξ”AUC={imps[i]:+.4f} {bar}")
579
+
580
+ # ── Save ─────────────────────────────────────────────────────────
581
+ os.makedirs("model", exist_ok=True)
582
+
583
+ model_data = {
584
+ "model": calibrated,
585
+ "raw_model": model,
586
+ "optimal_threshold": opt_thr,
587
+ "feature_names": FEATURE_NAMES,
588
+ "num_features": NUM_FEATURES,
589
+ "model_version": "3.0.0",
590
+ "trained_at": datetime.now().isoformat(),
591
+ "test_metrics": test_m,
592
+ "val_metrics": val_m,
593
+ "training_rows": len(df_train),
594
+ "total_users": 2000,
595
+ "feature_importances": {
596
+ FEATURE_NAMES[i]: round(float(imps[i]), 6)
597
+ for i in top_idx if imps[i] > 0
598
+ },
599
+ }
600
+
601
+ with open("model/model.pkl", "wb") as f:
602
+ pickle.dump(model_data, f)
603
+ sz = os.path.getsize("model/model.pkl") / 1024
604
+ print(f"\nπŸ’Ύ model/model.pkl ({sz:.0f} KB)")
605
+
606
+ meta = {k: v for k, v in model_data.items() if k not in ("model", "raw_model")}
607
+ with open("model/metadata.json", "w") as f:
608
+ json.dump(meta, f, indent=2, default=str)
609
+ print(f"πŸ“‹ model/metadata.json")
610
+
611
+ os.makedirs("data", exist_ok=True)
612
+ df.to_parquet("data/training_data.parquet", index=False)
613
+ print(f"πŸ“ data/training_data.parquet")
614
+
615
+ print(f"\n{'═'*62}")
616
+ print(f" βœ… PRODUCTION MODEL READY β€” v3.0.0")
617
+ print(f" Test ROC-AUC: {test_m['roc_auc']}")
618
+ print(f" Test F1: {test_m['f1']}")
619
+ print(f" Threshold: {opt_thr:.3f}")
620
+ print(f"{'═'*62}")
621
+
622
+
623
+ if __name__ == "__main__":
624
+ main()