Health Dashboard, часть 4: когда готовность перестала быть одним числом
Часть 4 моего build log по Health Dashboard: как аудит пяти лет личных данных опроверг исходную формулу readiness, почему наивный baseline победил все линейные модели, которые я пробовал, и что на практике означает «без итогового балла».
В части 1 я описал исходный readiness score:
readiness = HRV_score * 0.40 + RHR_score * 0.30 + Sleep_score * 0.30
Это было полезное число. Каждое утро оно показывало, выглядит ли что-то в данных прошлой ночи необычно относительно моего личного baseline. А ещё оказалось, что оно измеряет не то.
Эта часть — об аудите, который это вскрыл, о редизайне, который из него вырос, и о моменте, когда Фаза 1 закрылась без единой модели, которая превзошла бы свой наивный baseline по заранее заданному критерию.
Эмпирический факт, с которого начался редизайн
Я прогнал исходный score против будущей физиологии на полном историческом датасете. Окно: 2021-01-01 → 2026-05-15, 1961 день. Проверка: сопоставить сегодняшний readiness с завтрашними измеренными HRV, RHR и sleep efficiency — и посмотреть, есть ли у score хоть какая-то прогнозная сила.
Нет. Сегодняшний readiness практически не коррелирует с завтрашними HRV, RHR или сном. Внутридневная согласованность нормальная — компоненты согласуются друг с другом, — но forward predictive power отсутствует. Score работает как дескриптор текущего состояния, а не как прогноз готовности к нагрузке.
Это не баг. Формула была методологически неверно названа. Редизайн исправляет именно этот разрыв, а не веса.
Из того же аудита вылез параллельный сюжет про EnergyBank v1 — это тема части 5. На 387 paired days он оказался wrong-signed относительно HRV следующего дня (r=−0.149) и RHR следующего дня (r=+0.212). Wrong-signed означает, что сама концепция не сломана, но target задан неверно. Вероятно, v1 на самом деле ловил productive strain, а не recovery deficit. В части 5 я к этому вернусь.
Что данные реально позволяют моделировать
Когда я перестал спрашивать «как улучшить score» и начал спрашивать «что вообще прогнозируемо из этих данных», пространство решений сильно сузилось.
Daily AR(1)1 для candidate targets низкий: 0.07–0.28 в зависимости от метрики. На 3-day rolling window те же метрики поднимаются до 0.50–0.58. Это не косметическое сглаживание. Это место, где начинается предсказуемость. Ниже примерно 0.5 AR(1) большая часть дисперсии — measurement noise, и модель в основном запоминает sensor jitter.
Пересечение дней, когда одновременно доступны HRV, sleep и walking_hr, — 889 дней, 45% календаря. Это основной training pool. Аудит также поднял аномалии, которые требовали явной обработки:
- Ingest gap в 2024 году.
walking_heart_rate_averageотсутствует за весь год. HRV — тоже. RHR присутствует частично. Root cause тот же: изменение конфигурации Health Auto Export, которое я тогда не заметил. Теперь это закодировано как отдельныйsource_epoch, чтобы модели не принимали этот разрыв за физиологию. - В 2021 году сон был только coarse-only. 322 daily Recovery rows в 2021 и начале 2022 не подходят для sleep efficiency targets, потому что данные пришли из RingConn / iPhone Sleep Schedule без stage tracking — территория частей 2 и 3. Writer корректно отличает их от настоящих out-of-range ночей.
minute_metricsне хранитwalking_speed. Minute-level walking segment path закрыт; проект использует дневной агрегат Applewalking_heart_rate_average.
Аудит также обнаружил, что Apple Health XML import не парсит элементы <Workout>. Репрезентативный export.zip — 149 МБ в сжатом виде, 2.4 ГБ после распаковки — содержал 800 структурированных тренировок за 2019–2026 годы. В базе было 11. Записи из live workout endpoint iOS-приложения — 102 штуки — оказались единственными структурированными workout-данными, которые когда-либо были загружены. Workout-residual readiness, то есть канал «настоящей спортивной готовности», не имеет production data, на которых его можно считать. Writer для этого sub-score выделен и отдаёт eligible=false, reason='importer_gap', пока import code не научится парсить <Workout>.
От readiness-first к recovery-first
Аудит обнулил исходный framing. На этом датасете проект не может быть readiness-системой: structured-workout ground truth, который нужен для readiness, отсутствует. Что данные действительно поддерживают измеримым сигналом, так это daily recovery + passive efficiency.
Новая форма — пять sub-scores в трёх слоях:
Core daily layer:
1. Recovery Stability → 3d-rolling sleep_efficiency
2. Passive Efficiency → 3d-rolling walking_heart_rate_average
Risk layer (parallel signals, different time horizons):
3. Acute Risk → HRV/RHR/sleep tail events, t+1..t+3
4. Chronic Load → sustained deterioration in Recovery Stability
Optional athletic layer (dormant):
5. Performance Readiness → walking workout HR residual
С этой формой пришли и закрепились два архитектурных решения.
Нет aggregate readiness number. Каждый sub-score отвечает на свой физиологический вопрос. Схлопнуть их во взвешенное среднее — значит заново ввести ровно тот failure mode, ради которого и был сделан редизайн: один score без определённого target. UI вместо этого показывает отдельные dimensions и rule-based decision layer.
Targets — это 3-day rolling values или event-window probabilities, а не single-day points. Daily AR(1) для доступных мне метрик ниже noise floor. 3-day rolling window — уровень, где модели хотя бы теоретически могут улучшить smoothed naive baseline.
Фаза 0: storage и eligibility
Прежде чем обучать любую модель, writers должны были появиться и выдать honest labels для всей истории. Добавились три новые таблицы: target_snapshots, feature_snapshots, naive_baselines. Четвёртая, source_epochs, хранит каталог ingest/method boundaries, на которых baseline-ы должны сбрасываться.
Каждая запись target содержит:
target_value— NULL, если день ineligible;eligible— bool;eligibility_reason, enum:ok,sleep_data_missing,coarse_only_source,no_walking_hr,baseline_warmup,event_window_data_missing, …;data_coverageJSONB с per-source coverage, которое повлияло на решение;source_epoch—initial,source_2024_gap,source_2025_current, …
unknown — легитимный результат. Дни с отсутствующими данными не заполняются через imputation. Imputation систематически искажает оценку ровно в те дни, которые важны: поездки, болезнь, часы сняты. Модель, которую оценивают по imputed labels, получает удобный test set, не похожий на реальность.
После полного прохода по истории числа выглядят так:
| Sub-score | target_kind | eligible (ok) | ineligible (top reason) |
|---|---|---|---|
| Recovery Stability | rolling_3d | 650 | sleep_data_missing 896 |
| Passive Efficiency | rolling_3d | 891 | no_walking_hr 1070 |
| Acute Risk | event_t1_t3 | 1482 (27.5% positive) | baseline_warmup 337 |
| Acute Risk | event_strict_t1_t3 | 1482 (2.3% positive) | baseline_warmup 337 |
| Chronic Load | chronic_label | 469 (17.5% positive) | baseline_warmup 1191 |
| Chronic Load | chronic_acute_density | 386 (76.4% positive) | baseline_warmup 1191 |
Перед любым моделированием здесь важны две вещи. Во-первых, baseline_warmup доминирует в Chronic Load, потому что ему нужно ≥30 eligible rolling-3d Recovery rows в текущей source epoch; разрыв 2024 года выжег большую часть 2024-го и протёк в Q1-2025. Во-вторых, chronic_acute_density с 76.4% positive был mis-tuned by construction. Порог ≥3 acute OR-events в 14-дневном окне ниже ожидаемого числа событий при OR base rate 27.5%: 14 × 0.275 = 3.85. В Фазе 1 этот threshold подняли до того, как к target прикоснулась какая-либо модель.
Фаза 1: сначала floors, потом модели
Я хотел избежать стандартного ML-паттерна: «обучить модель, побить 50% accuracy, объявить победу». Без нетривиального baseline почти любой classifier выглядит хорошо на autocorrelated time-series data.
Поэтому Фаза 1 началась с naive baselines. Они считаются и сохраняются как собственные строки в naive_baselines. Для continuous targets — четыре кандидата: persistence_yesterday, rolling_7d_mean, rolling_30d_mean, ewma_45d. Для classifiers — один: event_base_rate, то есть 90-day rolling prior probability.
Критерий успеха был задан до запуска любой модели:
Continuous targets. MAE2 модели на основном chronological 70/30 test3 должен превзойти lower CI bound лучшего naive baseline, с 1000-iteration block bootstrap4 на 14-дневных блоках — так сохраняется autocorrelation. Просто победить point estimate недостаточно. Это статистический шум.
Classifier targets. Lower CI модели для precision@recall=0.55 должен превышать upper CI floor precision@recall=0.5. Интервалы не должны пересекаться. Для sparse labels используется stratified bootstrap6: positives и negatives ресэмплируются отдельно, чтобы сохранить class counts.
Одну тонкость удалось увидеть не сразу. persistence_yesterday показал чрезвычайно хорошие результаты на всех classifier labels: precision 0.785–0.947, AUC 0.829–0.979. Это выглядит как сильный floor. Но это не floor. Labels — forward-window: event_t1_t3 для даты t покрывает t+1..t+3, а для даты t+1 — t+2..t+4. Два из трёх дней общие между соседними labels. Persistence эксплуатирует overlap label windows, а не предсказывает unseen physiology. chronic_label смотрит на 14 дней вперёд; соседние labels делят 13 из 14 дней, так что persistence тривиально переносится.
Честный classifier floor — event_base_rate. Persistence остаётся в отчёте для прозрачности, но не используется как decision metric.
Что сделали модели
Пять попыток в Фазе 1. Все закрылись победой naive baselines.
Recovery Stability, rolling_3d. Target SD7 0.033. EWMA458 floor MAE 0.0251 (CI lower 0.0231). Лучшая линейная модель: Ridge α=1009, MAE 0.0246. Не победила 0.0231. Вердикт: no production model; EWMA45 остаётся production layer.
Passive Efficiency, rolling_3d. Target SD 4.226 bpm. EWMA45 floor MAE 3.1911 bpm (CI lower 2.9263). Лучшая линейная модель: Ridge α=100, MAE 3.0783 bpm. Не победила 2.9263. Вердикт: то же, что Recovery.
Acute Risk, event_t1_t3. 108 test rows, 21 positive (0.194 base rate10). Floor precision@R=0.5 = 0.273 (CI [0.193, 0.394]). L2 logistic α=0.111: precision 0.229 (CI [0.200, 0.750]). CI пересекаются, lower CI модели ниже upper CI floor. Кандидата нет.
Chronic Load, chronic_label. 97 test rows, 30 positives. Floor precision@R=0.5 = 0.850 (CI [0.682, 1.000]). L2 logistic α=0.01: precision 0.750 (CI [0.593, 1.000]). CI пересекаются. Вердикт: модели нет. Но линейная модель дала AUC12 0.936 против floor 0.794 — явный ranking-signal gap, который не превратился в победу по precision-at-recall. Этот gap был единственным местом в Фазе 1, где non-linearity была проверяемой гипотезой, а не ML-hopium, поэтому я запустил ещё одну probe.
Chronic Load, chronic_label, GBM probe. Замороженная 16-cell grid: max_depth × learning_rate × n_estimators × min_samples_leaf, alpha выбиралась на inner train/val. Лучшая cell на val: md=3, lr=0.1, n=100, leaf=10, val precision@R=0.5 = 0.867. На held-out test: precision 0.714 (CI [0.577, 0.889]), AUC 0.915. Floor не пройден. Сработало заранее объявленное stop rule: не запускать GBM13 на chronic_acute_density или acute_risk. У них был более слабый linear AUC. Фаза 1 закрыта.
Chronic Load, chronic_acute_density. После retuning threshold — в Фазе 0 он был mis-calibrated на 76% positive, — 104 test rows, 3 positives. Floor precision 0.042. L2 logistic α=0.01: precision 0.020. Модель значительно хуже floor; CI не пересекаются на неправильной стороне. Модель ранжирует хуже calibrated base rate при recall = 0.5. Кандидата нет.
Пять попыток. Пять вердиктов: «no production model, naive layer wins». На поверхности это выглядит как провал. Но не в том смысле, который важен.
Recovery Stability, Passive Efficiency, Acute Risk, Chronic Load — четыре production sub-scores. У каждой карточки свой status; дашборд не схлопывает их в одно число.
Честное прочтение результата
Фаза 1 дала защищаемую форму production system:
- Continuous targets в production обслуживаются EWMA45. Это не placeholder. Это выбранная модель, проверенная критерием строже, чем большинство consumer-wearable scores вообще когда-либо оценивались. То, что линейные и tree models не смогли улучшить её на текущем chronological tail, означает: EWMA45 — правильная сложность модели для signal-to-noise ratio этих данных.
- Classifier targets обслуживаются
event_base_rate. Логика та же. - Recovery и Passive sub-scores описательные, а не предиктивные. Они говорят пользователю, где его последние три дня оказались относительно personal baseline. Они не пытаются предсказать завтра.
- Acute Risk в OR-форме работает с base rate 27.5%, то есть это noisy alert. Строгая версия — HRV AND RHR same day — на 2.3% с 9 positives в 401-row test slice. На практике полезна для калибровки; статистически пока слишком разрежена для модели.
Есть реальная возможность, отмеченная в каждом feasibility doc, что провал моделей — это scope, а не отсутствие сигнала. Chronological test tail (2026-01 → 2026-05) содержит 21 acute positive, тогда как в более ранних месяцах было 30+. Walk-forward sensitivity по chronic_label показывает precision 0.30–1.00 по месяцам, что похоже на seasonality или regime dependence, а не на бесполезный label. Но критерий остаётся прежним: победить floor на самых свежих данных, которые production system реально будет оценивать, потому что именно этот tail видит живая система. К этому естественно вернуться, когда накопится больше positives.
Почему я храню verdicts в репозитории
Каждая попытка Фазы 1 легла Markdown-документом рядом с кодом: READINESS_REDESIGN_PHASE1_PASSIVE_FEASIBILITY.md, ..._RECOVERY_FEASIBILITY.md, ..._CHRONIC_LABEL_FEASIBILITY.md, ..._CHRONIC_LABEL_GBM_PROBE.md, ..._ACUTE_RISK_FEASIBILITY.md, ..._CHRONIC_DENSITY_FEASIBILITY.md. Они auto-generated из analysis scripts, с замороженной methodology и одним paragraph verdict внизу.
Важны две причины:
- Re-runnable. Когда накопится больше positives, мне не нужно будет помнить, какой был threshold. Script перегенерирует файл. Verdict обновится автоматически.
- Честность с собой. Модель, которую я отверг в мае 2026-го, через три месяца снова будет выглядеть интересной. Замороженный критерий в документе не даст мне тихо его смягчить.
Та же логика применяется к source-epoch catalogue. HRV gap в 2024 задокументирован; spike strict event rate в 2022 — 5.7% против 1–2% в других годах, возможно illness cluster — помечен для narrative review до того, как любая модель будет тренироваться на этих features. Если я ретроспективно объясню 2022 как «кажется, я болел в июле», это объяснение тихо искривит будущие модели.
Настоящий урок
Исходный readiness score был одним числом, которое объединяло три сигнала. Редизайн — это пять sub-scores, которые отказываются объединяться. В этом и есть изменение.
Предыдущая статья была о доверии к источнику за каждой колонкой. Эта — о доверии к target. Способность сказать: «вот что мы пытаемся предсказать, вот floor, который должна победить модель, и вот что произошло, когда модели запустились». Выход выглядит менее впечатляюще, чем одно уверенное число. Зато у него меньше шансов оказаться красиво оформленной ложью.
Следующая статья продолжает параллельную находку из аудита: EnergyBank v1 был wrong-signed относительно HRV следующего дня. Она о том, что потребовалось, чтобы переделать банк из daily snapshot в state machine, которая переносит debt между днями и не врёт о пропущенных сенсорах.
Автокорреляция за один день. AR(1) — это корреляция между сегодняшним и вчерашним значениями за всю историю. Значения, близкие к 0, означают, что сигнал изо дня в день представляет собой в основном шум; значения, близкие к 1, означают, что сегодняшний день уже определяет завтрашний. Для показателей здоровья AR(1) выше примерно 0.5 — это тот порог, когда предсказание становится настоящей задачей, а не просто упражнением в запоминании колебаний датчиков. ↩︎
Средняя абсолютная ошибка. Среднее значение
|predicted − actual|по всем тестовым точкам. Стандартная метрика регрессии: чем ниже, тем лучше, единицы измерения соответствуют единицам целевой переменной. Менее сурова к большим ошибкам, чем RMSE (который возводит ошибки в квадрат перед усреднением). ↩︎Разделение данных на обучающую и тестовую выборки по времени. Обучение на первых 70% дней в хронологическом порядке, тестирование на последних 30%. Важно для временных рядов, поскольку перемешивание дней перед разделением приведет к утечке будущей информации в обучающую выборку (модель получала бы подсказки из данных, смежных с тестовыми, что невозможно при реальном прогнозировании). ↩︎
Метод повторной выборки для временных рядов. Стандартный бутстрэп перемешивает отдельные точки данных и разрушает любую временную корреляцию между последовательными днями, что уничтожает структуру автокорреляции, которую наследует тестовый набор из реального мира. Блочный бутстрэп выбирает непрерывные блоки дней (здесь блоки по 14 дней), таким образом сохраняется автокорреляция внутри каждого блока. ↩︎
Точность (доля отмеченных дней, которые действительно были положительными), измеренная при пороге, который захватывает половину положительных случаев (полнота/recall = 0.5). Стандартная метрика компромисса для классификаторов редких событий: легко добиться высокой точности, отмечая только несколько очевидных случаев (низкий recall), и легко добиться высокой полноты, отмечая всё (низкая точность). Precision@recall=0.5 фиксирует сравнение при фиксированной чувствительности. ↩︎
Бутстрэп, который повторно выбирает положительные и отрицательные классы отдельно, сохраняя исходные пропорции классов в каждом повторном образце. Без стратификации бутстрэп-выборка из набора данных с 5% положительных может иногда не содержать положительных примеров, что делает точность неопределённой и искажает доверительный интервал. ↩︎
Стандартное отклонение самой целевой переменной в единицах тестового набора. Устанавливает естественный масштаб для определения «насколько хорошо достаточно хорошо»; MAE 0.025 при целевой переменной с SD 0.033 означает, что средняя ошибка модели сопоставима с одним стандартным отклонением исходного сигнала. ↩︎
Экспоненциально взвешенное скользящее среднее. Метод сглаживания, при котором недавние дни имеют больший вес, чем более старые; “45” в EWMA45 обозначает эффективный период полураспада (half-life) (~45 дней, после которого вклад одного дня уменьшается вдвое). “Наивная база” для прогнозирования временных рядов, которую действительно трудно превзойти без реальной модели. ↩︎
Линейная регрессия с регуляризацией L2. Обычная линейная регрессия, но с добавленной штрафной функцией за большие значения коэффициентов:
loss = MSE + α · Σβ². Значениеα=100— это сила регуляризации, выбранная на валидационном наборе данных. Чем выше α, тем сильнее штраф за величину коэффициентов, что снижает переобучение (overfitting). Стандартная базовая модель перед переходом к нелинейным моделям. ↩︎Доля положительных примеров в наборе данных. Тривиальный классификатор «всегда предсказывает наиболее частый класс» достигает точности, равной базовой ставке, поэтому любой нетривиальный классификатор должен явно превосходить этот уровень. Базовая ставка в 19.4% означает, что примерно 1 из 5 дней был положительным. ↩︎
Логистическая регрессия (линейный классификатор, который выводит вероятности с помощью логистической сигмоиды) с таким же штрафом L2, как и в регрессии Риджа, на коэффициентах. Стандартная базовая модель для двоичной классификации до появления деревьев и нейронных сетей. ↩︎
Площадь под ROC-кривой (AUC ROC). Метрика ранжирования: вероятность того, что модель присвоит более высокий балл случайно выбранному положительному примеру, чем случайно выбранному отрицательному. 0.5 — случайное ранжирование, 1.0 — идеально. AUC измеряет, правильно ли модель упорядочивает примеры; precision@recall=0.5 оценивает, правильно ли выбран порог. Эти два показателя могут расходиться, что и произошло в случае
chronic_label. ↩︎Машина градиентного бустинга. Модель ансамбля, которая последовательно строит множество неглубоких решающих деревьев, каждое из которых учится исправлять ошибки предыдущих. Стандартная «нелинейная базовая модель, превосходящая логистическую регрессию»; если GBM не может превзойти линейную модель, скорее всего, в данных нет нелинейного сигнала, который стоит использовать. ↩︎