Upload 9 files
Browse files- Dockerfile +12 -0
- README.md +125 -5
- app.py +288 -0
- feature_engineering.py +237 -0
- metadata.json +69 -0
- model.pkl +3 -0
- models.py +133 -0
- requirements.txt +6 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 8 |
license: mit
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|