個人健康データ基盤「yuru」運用記 その2 — AIバックエンド移行と、相関発見の土台づくりで学んだ「データに騙されない」技術
前回の記事では、Streamlit + Supabase で放置死したセルフ健康トラッカーを Mac Studio 常駐のローカル完結システムとして作り直し、本稼働までこぎ着けた話を書いた。この続編では、そこから先の運用フェーズ——AIバックエンドの移行で噴出した構造欠陥、ダッシュボードの再設計、そして本題の「自分でも気づいていない相関を発見する」ための土台づくり——を扱う。特に後半は、個人の時系列健康データを相関分析するときに素朴にやると必ず騙されるという話が中心で、統計の落とし穴を実例で潰していく記録になっている。自分の勉強用メモ。
この記事の位置づけ
前回(その1)で到達したのはこういう状態だった。
- Mac Studio 常駐、SQLite(操作)+ DuckDB(分析)+ Parquet(アーカイブ)の三層ストレージ
- Oura Ring / Withings / SwitchBot / OpenWeatherMap の4ソースを15分毎に自動収集
- ローカル vLLM(GX10 で Qwen3.5-122B)が日次インサイト、週次も自動生成
- FastAPI + HTMX + PWA のダッシュボード、Discord へ配信
- 設計思想は Data Maximal(生データ全保存)/ Input Minimal / ゆるストイック(YAGNI)
実装は Claude Code に委譲し、自分は PM兼アーキテクトとして設計判断とレビューをする分業。この続編もその壁打ちログの再構成である。
今回のセッションで扱ったのは、大きく分けて次の6テーマ。
- サプリの後追い記録とデバイス増設(実運用の細かい機能追加)
- 週次インサイト不発の原因究明とAI障害耐性
- Withings Body Scan 移行(内臓脂肪・PWV など指標が一気に増えた)
- AIバックエンドの DeepSeek 移行で噴出した5つの構造欠陥
- ダッシュボードの引き算リデザイン
- 相関発見の土台づくり(この記事の主役)
技術的な学びが濃いのは 4 と 6 なので、そこを厚く書く。
1. AIが落ちても気づける仕組み — 「無言の欠落」との戦い
週次インサイトが届かない
ある週末、日曜の朝に来るはずの週次振り返りが Discord に来なかった。調査すると、daemon(LaunchDaemon)は正常に発火していて、パイプラインも正常。AI呼び出しの依存先である GX10 の vLLM が熱暴走で落ちていた。7月の長野で、122B モデルを常駐させている GPU マシンが夏場に発熱したのが顕在化した形。
これは単発事故ではなく構造的な問題だと気づいた。週次は「日曜08:00」という一点だけに依存している。その瞬間に GX10 が寝ていれば、毎回死ぬ。しかも日次インサイトも同じリスクを抱えている。
対策は「リトライ or 通知」ではなく両方 + 順序にした。
- リトライ: vLLM 接続失敗時、10分間隔×5回(最大50分)再試行。GX10 の一時的なスリープや再起動をまたげる長さ
- 最終失敗時の Discord 通知: リトライを使い切ったら、同じ webhook にエラー通知を送る。Discord送信自体は vLLM と独立なので、GX10 が死んでいても通知は届く。主目的は「無言の欠落を無くす」こと
- リトライ間隔は
VLLM_RETRY_INTERVAL環境変数で調整(テストは5秒、本番は10分)
教訓: 単一時刻に依存する処理は、その瞬間に依存先が落ちていると毎回死ぬ。リトライで時間の幅を持たせ、それでもダメなら「失敗したこと自体を通知する」。監視の代わりにもなる。無言で欠けるのが最悪で、失敗が可視化されていれば手動回収できる。
2. Withings Body Scan 移行 — raw-first が「後から起こす」を可能にする
Amazon セールで Withings の体組成計(日本市場名 Body Segment、実体は上位機種 Body Scan)を買った。移行は驚くほど軽い。認証まわりは何も変わらない——同じ Withings アカウントに新デバイスを足すだけで、既存の OAuth トークン・API 経路がそのまま新スケールの測定値を返す。yuru 側のコード変更は「新しい meastype のパース追加」だけ。
内臓脂肪が個人アカウントで取れた
購入前の最大の懸念は「内臓脂肪(meastype 170)が個人無料アカウントの API で取れるか」だった。Withings/Tanita の教訓で「API スキーマに定義があっても個人では取れない」ことがあるので、購入後の実測確認が必須だった。
結果、meastype 170 が API 制限なく返った(値 2.6)。しかも既存パーサが 170 を既にマップ済みだったので、実質追加作業ゼロで内臓脂肪の日次トラッキングが稼働開始した。当初構想していた「日次プロキシ + 年次CT較正」(内臓脂肪のような計器ノイズの大きい指標は、日次で安いプロキシを追い、年に一度CTで真値を較正する)の日次側が、勝手に動き出した。
さらに嬉しい誤算があった。API 上は modelid=10 “Body Scan” として振る舞い、PWV(脈波伝播速度)や EDA(足裏皮膚電気活動)まで送ってきた。PWV は Body Cardio では削除された経緯があるので、日本版が機能削減版のはずなのに取れたのは想定外だった。届いた新 meastype は18列分。
偶然が生んだ「機器差の実測データ」
移行日に、たまたま同じ日に旧 Body Cardio と新 Body Scan の両方で測定していた。その結果、体脂肪率が 19.8%(Cardio)vs 16.2%(Scan) と 3.6pt もズレた。これは体が変わったのではなく、機器のアルゴリズム差(機器差)。
この偶然の並行測定が、後で効いてくる。yuru では annotations(数値に出ない文脈をAIに渡すチャネル)に「体組成計を Body Scan に変更、体脂肪率の段差は機器差であって実際の身体変化ではない(同日実測 19.8%→16.2%)」と記録した。おかげで、直後の週次インサイトは体脂肪率の段差を「急改善」と誤読せず、「機器のアルゴリズム差であり、実際の身体変化ではありません」と正しく処理した。実データを根拠に書けたのは、あの偶然の並行測定があったから。
見つかった構造的制約と、副産物のバグ
このとき2つ発見があった。
PWV は5/17以降しか raw に無い。 Withings の fetch が30日ルックバックなので、それより前は raw にすら存在しない。つまり「移行時に明示的にインポートしなかった Withings 指標」は、30日ルックバックの壁で過去を取り返せない。PWV はスパースなので軽傷で済んだが、raw-first の限界を示す例だった(raw に入る前のデータは保存されない)。
water_pct 列の実体が kg だった。 Body Scan の水分データを見て気づいた。列名は「率(%)」なのに、入っている値は kg(37.98kg = 61.2%)。しかも weekly pack は SELECT * で列名ごと LLM に渡るので、先週の週次が「水分率37.0%→35.8%」と語っていたのは、kg値を%と誤解していたことになる。water_kg にリネームしたら、列名がそのまま LLM への意味情報になっているので、リネームだけで%誤読が構造的に消えた。
教訓: raw-first のおかげで、増えた指標(内臓脂肪・PWV・EDA)を後から「起こす」ことができた。使わない指標も raw に入っていれば、必要になった時にカラム追加+バックフィルで過去分ごと復活する。ただし「raw に入る前」は取り返せないので、新データ源は早く繋いで raw に流し始める、活用は後回しが最適。
3. DeepSeek 移行で噴出した5つの構造欠陥 — 「調査を挟む」ことの価値
ここがこのセッションで技術的に一番濃い。AIバックエンドを Qwen3.5-122B から Proxy 経由の DeepSeek V4 Flash に本採用で切り替えたら、日次インサイト生成が失敗しだした。
通知の誤診断
Discord のエラー通知は「vLLM(GX10)に接続できません」と言っていた。だが実際は接続成功していて、パース失敗だった。エラーの中身:
1 validation error for HealthInsight
Invalid JSON: expected value at line 1 column 1
input_value='```json\n{\n "summary":...索する。"\n}\n```'
DeepSeek が JSON を ```json ... ``` のマークダウンコードフェンスで囲んで返し、生JSONを期待する Pydantic 検証が先頭で弾いていた。Qwen では出なかった癖。
ここで焦らず「調査」を挟んだ
エラーログから原因は「ほぼ」見えていた。だがモデル切替直後という大きな変更の後だったので、フェンスだけ直さず、まず全体を調査させた。これが決定的に正しかった。フェンスは氷山の一角で、調査すると致命的な地雷が他に眠っていた。
判明した5つの構造欠陥:
① フェンス付きJSON — DeepSeek がコードフェンスで囲む癖。Qwen では出なかったので潜在していた。
② 真因はスキーマ強制のサイレント無効化 — これが本丸。enumトリック(モデルが推測不可能な enum を仕込んで強制の有無を判定)で検証したら、optional プロパティを持つスキーマだと、vLLM が強制トークンをサイレントに無視して自由生成にフォールバックしていた。natural_experiment が optional だったのが原因。フェンスは症状で、根本はこれ。catalog の AI 下書きだけが無事だったのは、そのスキーマが全 required だったから。
③ 週次の NameError(時限爆弾) — 過去のコミットで変数を消したのに参照が残っていた。しかもDB insert 後・Discord配信前に落ちる → 再実行は「生成済み」でスキップ → 通知も来ず黙って永久欠落。前に潰したはずの「無言の欠落」を、通知機構すら効かない形で再び作っていた。次の日曜に確実に落ちる時限爆弾だった。
④ max_tokens 切断 — reasoning_content(思考トークン)が出力予算を食って、応答が途中で切れる。
⑤ 通知の誤誘導 — 原因を問わず「接続失敗」と表示する固定文言。
修正:多層防御 + 順序反転
- スキーマ全required化(optional →
str | Noneの required + nullable)で強制を復活。これが本丸。フェンスも自由生成フォールバックも根本から消える - パース側サニタイズ(フェンス除去 +
{〜}抽出)をバックエンド非依存の保険として多層防御 - 配信成功 → DB insert の順序反転で「黙って欠落」を構造的に根絶(週次だけでなく日次にも横展開)
- Proxy 経由に切替して thinking を無効化(切断解消)
- 通知のエラー型分類(接続失敗/HTTPエラー/検証失敗を区別)+ raw応答先頭200字ログ保存 + 連発抑制
教訓(重要): エラーログから原因が「ほぼ」見えても、大きな変更の直後は調査を挟む。フェンスだけ直していたら、②のスキーマ強制無効(切断で再発する)と③の時限爆弾(次の日曜に無言で死ぬ)を見逃していた。「同じパターン・同じコードパスは同じバグを持つ」——1箇所で見つけたら横断的に探し、日次と週次の両方を揃える。そして構造化出力を強制する場合、optional プロパティは強制をサイレントに無効化しうるので全 required + nullable にする、というのは他のプロジェクトにも効く知見。
トーンの調整はプロンプトのtxt編集だけ
DeepSeek は Qwen より饒舌で断定的だった。逆説仮説で「このまま活動を続けるとオーバートレーニング症候群や副腎疲労に陥るリスクが高い」と書いてきた。副腎疲労は医学的に未確立な概念だし、単日データで慢性病態を断定するのも過剰。これは Qwen 時代の「逆説仮説が楽観に振れすぎる」の逆パターン(悲観・病理化に振れすぎ)。
プロンプトを外部txt化してあったので、コード変更も再起動もなく、txt編集だけで「逆説仮説は楽観にも悲観にも根拠を超えて振らない。未確立概念や単日で断定できない慢性病態を確定的に述べない」と歯止めを足せた。プロンプト外部化の設計が、こういうチューニングの度に効く。
4. ダッシュボードは「引き算」で作る
Body Scan で指標が一気に増えたが、全部ダッシュボードに載せると煩雑化する。方針を「足し算」ではなく「引き算」に定めた。
- 新指標は分析側(相関スキャン・月次)に回し、毎朝の画面はむしろ減らす
- トップは「今日どう動くか」の判断に使う3指標(準備度・睡眠・回復力)だけ大きく
- 増えた指標は下段/別ページに逃がす
セクション分割で着脱容易に
実装はセクション単位のコンポーネントに分割し、SECTIONS リスト1箇所で各セクションの ON/OFF と順序を制御できるようにした。「スパークライン気に入らなければ1行で消せる」着脱容易性を最優先。7日トレンドは依存ゼロのインライン SVG スパークライン(グラフライブラリを足すと重いし削除も面倒)。横スクロールする数字テーブルを廃し、折れ線の「形」で勢いを一目にした。
最終的な階層:
- 最上段 = 今日の判断(3カード + 昨夜のCO2)
- 中段 = 7日トレンド(スパークライン)
- 下段 = 体組成・環境の詳細
「昨夜のCO2」を上段に置いたのは、当時の相関分析で「就寝時CO2高 → 翌日パフォーマンス低下」が検出されたから——相関で発見した種を、ダッシュボードのトップで日々の意思決定に還元する流れだった。……のだが、この「CO2の種」が後で崩れる。それが次の話。
5. 本題:相関発見の土台づくり — 「たくさんデータを取れば真実が見える」は嘘
ここからがこのセッションの主役。「日次・週次の振り返り以外に、自分でも気づいていない相関を暴くために何ができるか」という問いから始まった。
答えは明確で、これはダッシュボードのレイアウト改善では解けない。相関発見は人間が数字を眺めて気づく作業ではなく、データに探索させる作業。DuckDB で全指標を1つの wide テーブルに結合し、全ペアの相関 + ラグ付き相関(-3〜+3日)を機械的に計算する。データ量は170日・数万セルなので実行2.5秒、負荷は無視できる。
ところが、この相関スキャンを回すたびに「素朴な相関分析がいかに人を騙すか」を思い知ることになった。以下、実際に踏んだ4つの落とし穴を、勉強用に噛み砕いて書く。
落とし穴① 季節交絡 — “本命”が偽物だった
最初のスキャンで「就寝時CO2が高い → 翌日パフォーマンスが低い(r≈-0.31〜-0.37)」が出て、これを本命扱いした。ダッシュボードのトップに昨夜CO2を載せ、週次でも毎週「換気しろ」と言ってきた。
再スキャンで経過日数(=季節)を制御して計算し直したら、r が半減して閾値を割った(-0.31→-0.21)。
理由は「CO2 とパフォーマンス、両方が季節でゆっくり動いている」こと。冬は窓を閉めるので寝室CO2が高い→暖候期に換気が増えてCO2が下がる(実際 r=-0.47 で低下)。パフォーマンスも季節で動く。すると、両者の間に直接の因果が無くても、「CO2が低い日はパフォーマンスが高い」という相関が見かけ上生まれる。
これが交絡(confounding)。有名な例で言えば「アイスクリームの売上」と「水難事故」が相関するのと同じ——アイスが人を溺れさせるのではなく、両方を「夏(気温)」が動かしている。yuru では「季節」が隠れた第三変数だった。
対処は偏相関(partial correlation)。「CO2のうち季節で説明できる分」「パフォーマンスのうち季節で説明できる分」を両方から先に引き算し、残った変動だけで相関を計算する。今回それを通したら半減した=残った相関の多くは季節が作っていた。
含意: 相関を見つけて実装に反映する前に、必ず偏相関を通すべきだった。yuru は「CO2×睡眠」を信じて UI と週次プロンプトに焼き込んだ。もしこれが完全な季節アーティファクトなら、季節が変われば助言は外れる。ただし「高CO2が睡眠に悪い」という生理学自体は独立した研究で確立しているので、換気推奨が無意味なわけではない。変わったのは「あなた個人のデータがそれを強く裏付けている」という主張の信頼度。「一般論として正しいが、自分のデータで実証できたわけではなかった」に格下げされた。誠実な後退。
落とし穴② 逆因果 — 相関では向きが決められない
季節除去後も残った相関に、面白いものがあった。sleep_score × CO2平均 lag+1 = -0.41、activity_score × CO2平均 lag+2 = -0.52。表記は X(d) と Y(d+k) なので、これは「パフォーマンスが低い日の、翌日・翌々日の夜に CO2 が高い」を意味する。
つまり「CO2が睡眠を悪くする」ではなく、「不調な日は在室時間が長く換気が減る → 翌夜のCO2が高い」という逆因果かもしれない。しかも季節除去後も残る。
相関は因果の向きを教えてくれない。「CO2→不調」と「不調→CO2」は、相関係数だけでは区別できない。ラグ(時間差)である程度示唆はできるが確定はできない。これを確かめるには介入するしかない——「2週間、意識的に換気する期間」を作って前後を比較する。これが yuru の「自然実験」(regimen変更や環境介入の前後比較)が必要な理由そのものだった。
落とし穴③ フィルタが指標を静かに殺していた
再スキャンで「内臓脂肪が n=0」と報告された。だが実際には6日分(7/11以降)データがあるはず。原因は機器段差フィルタだった。
7/11 の機器交換で体脂肪率に 3.6pt の段差が出たので、「体組成指標は 7/11 より前(Cardio期)だけで計算する」フィルタ date < 2026-07-11 を入れていた。これ自体は正しい。だが網が雑すぎて、Body Scan で初めて取れるようになった新指標(内臓脂肪・PWV・部位別など)まで巻き込んでいた。
ここが皮肉。新指標は Cardio 期にそもそも存在しない=機器差の段差が原理的に発生しえない。守る必要が無いのに、date < 2026-07-11 フィルタを適用すると、7/11以降にしか無いデータが全部消えて n=0 になる。
「n=0」が特にタチが悪い理由:単なるデータ不足なら「n=6(データ待ち、あと2週間)」と出て「待てば解決」と分かる。だがフィルタで殺されていると「n=0」に見え、何ヶ月データを貯めても n=0 のまま。時間で解決しないバグを、データ不足に偽装している。
対処はフィルタの条件を「日付」から「指標の種類」に変える。両機器にまたがる指標(体重・体脂肪・筋肉・水分)だけフィルタ適用、Scan専用指標(内臓脂肪・PWV)はフィルタ対象外で全期間使う。これで新指標が n=0 → n=6 に解放された。
教訓: 除外ロジックは「守るべき対象」を外科的に狙う。雑な網は無実のデータを巻き込む。しかも時間では治らないバグなので、「待てば増えるはずの指標が増えない」なら、データ不足ではなくロジックが消している可能性を疑う。これは前回の「optionalでスキーマ強制がサイレント無効」と同系統の、エラーを吐かず静かに間違い続けるバグ。定期的に結果を目視して違和感を拾うのが唯一の発見経路。
落とし穴④ 多重比較 — たくさん試すと偶然が量産される
n=6 の新指標も「参考枠」に載せたら、muscle_arm_r × CO2平均 -0.96、visceral_fat × CO2最大 +0.86 みたいな派手な数字が並んだ。飛びつきたくなる。
だが較正を併記したら、この枠は読むべきでないと分かった。n=6 では真に無相関でも |r|>=0.4 が確率46%で起こる。試行1180回に対する期待偽陽性は約546件。実際の該当195件はその範囲内に完全に収まる。つまり上位の派手な数字は雑音と区別できない。
さらにラグ選択バイアスもある。7つのラグ(-3〜+3)から最大 |r| を選ぶ設計は、それ自体が多重比較で偽陽性を増やす。Fisher z による臨界値を n 別・Bonferroni 補正付きで併記した:
| n | 補正なし | ラグ7本をBonferroni補正 |
|---|---|---|
| 20 | r | |
| 40 | r | |
| 80 | r |
閾値 0.4 は n=20 では補正後臨界値(0.57)に届かない。つまり表の下位にある n=20〜23 の行は、掲載されていても統計的には支持されていない。この不整合が読み手に見えるようになったのが実質的な収穫だった。
教訓: 変数が増えるほど、そしてラグやパラメータを試すほど、偶然の相関が量産される(多重比較問題)。派手な r を見て飛びつく人間の性を、ツール側が「これは偶然の範囲」と釘を刺す設計にする。前回のCO2誤認の再発を、ツールの自己防衛機能として防ぐ。
土台整備で残った本物の種
4つの落とし穴を潰した後、季節除去を通っても減衰しない本物の相関が1つ残った。
resilience_daytime_recovery × spo2_avg:生 +0.54 → 季節除去 +0.54(減衰ゼロ)、n=39、p=0.0003、Bonferroni補正臨界値0.42も上回る。
CO2×睡眠が季節で崩れたのと対照的に、これは季節に一切依存せず、統計的に支持されている。「日中の回復力」と「夜間の酸素飽和度」の連動——機序も筋が通る。今のyuruで唯一“本物”と言える種で、CO2より遥かに信頼できる。
機器段差の設計判断 — 偽相関源を自分で作らない
両機器にまたがる指標(体脂肪・筋肉・水分)は、Cardio機が7/11に引退したため n=18 で頭打ち、現設計では永久に20に届かない。選択肢は3つ:
- A. オフセット補正 — 7/11同日の並行実測(3.6pt差)で Cardio 値を Scan 相当に変換して1本の系列にする。だが較正点が実質1日だけ、差が定数か比例かも不明
- B. 期間分割(採用) —
body_fat_pct_cardio(凍結)とbody_fat_pct_scan(育成)の別列にする - C. 棚上げ — Scan 期が20日貯まるまで待つ
Bを選んだ。理由が重要: Aのオフセット補正は、較正点1日で決めるとそれ自体が新しい偽相関源になる。季節という第3の偽相関源を見つけたばかりなのに、補正由来の第4の源を自分で作るのは割に合わない。正確さのために評価を遅らせる——「訂正は編集/リニューアルは新規」「置換でなく追加」と同じ、不変性と正直さの原則。
分割後、cardio × scan の同時観測ペアは0件(期間が完全排他)で、分割由来の偽ペアが生じないことも確認した。
6. 相関エンジンが炙り出したデータ品質バグ — 9時間ずれの幽霊行
土台整備の検証の過程で、別のデータ不整合が浮かんだ。分割後の weight_kg_cardio が n=95 と出たが、実測日数73日を超えていた。件数が多すぎる。
調べると、weight_kg に9時間ずれた重複行が55ペアあった。
2026-01-30T21:00:19+09:00 weight=62.149 ← 正しいJST行
2026-01-31T06:00:19+09:00 weight=62.15 ← 重複行(UTC時刻に+09:00を誤付与)
同一測定が「正しいJST表記」と「UTC時刻に+09:00を付けた表記」の2本で入っていた。9時間ずれるので、21時の測定が翌日6時にも現れ、日付が±1日ずれて複製される。ラグ解析にとっては致命的(相関の土台を整えた矢先なのに、weight だけ日付が汚染される)。
前提を覆した全数調査
ここで良い教訓があった。私は「フル測定行を正、体重だけの軽量行を重複」という判別基準を指示した。だが Claude Code が全数調査したら、55ペアすべてが「軽量↔軽量」で、私の想定は外れていた。指示を鵜呑みにせず調べたから、間違った基準で正しい行を消す事故を防げた。
代わりに採用した判別が的確だった。raw_data_lake を真実源にして、「その行が raw と一致するか」で正誤を決めた。正しい55行は raw と一致・時刻17〜23時、重複55行は raw と不一致・9時間前に相方あり・時刻2〜8時。三条件(raw不一致 / 9時間±60秒前に同device・体重差<0.02の相方 / 相方がraw一致)を全部満たす行だけ削除。これは Data Maximal(raw保持)が効いた瞬間——生記録が残っていたから、どちらが本物か機械的に判定できた。raw が無ければ推測するしかなかった。
真因は現行コードではなく移行データ
再発防止で原因を追ったら、現行の取り込み経路にはバグが無かった。datetime.fromtimestamp(int(date_unix), tz=JST) は正しい。重複55行は全件 legacy parquet 由来で、旧Supabaseで既に二重記録が起きていて、移行がそれを忠実に運んだ。移行日以降の新規重複はゼロ。
もし「現行コードのバグ」と決めつけて現行経路をいじっていたら、無実のコードを壊した上に真因を見逃していた。症状の出どころを時系列で特定してから対処する規律が効いた。
防御は、upsert_weight に書き込み層のガード(9時間ずれ・同device・体重差<0.02を検出してskip)を入れた。UNIQUE(measured_at) は時刻が違うと素通しするので、その穴を塞ぐ。機器切替日の並行実測(Cardio 18:25 / Scan 23:09)を誤って消さないよう device 違いは通す——過去に annotations で記録したあの並行測定を、防御ロジックが壊さないよう配慮した。
掃除の効果が相関に出た
掃除後、重複が体重系の相関を押し上げていたことが実証された。weight_kg_cardio × 夜間CO2max r=+0.52(n=21) が表から脱落、× CO2avg も +0.56→+0.46 に弱まった。放置していたら、体重が育ったとき偽の相関を本命と誤認する時限爆弾だった。本命(resilience×spo2)は無傷。
教訓: 相関エンジンが副産物としてデータ品質の検査装置にもなった。「n=95 が実測日数73日を超える」という違和感——数字を較正情報付きで見るようにしたから気づけた。前の「内臓脂肪 n=0」と合わせて、結果を注意深く見ると、静かに間違ったデータが浮かぶが2回起きた。エラーを吐かないデータ不整合は、能動的に見に行かないと見つからない。そして raw を保持していたから、parsed 側を安心して掃除できた(やり過ぎても raw から復元可能)。
7. このセッションで固まった設計原則(追記版)
前回の原則に、今回の学びを足す。
- 調査 → 方針確定 → 一括修正 — DeepSeek 移行の失敗で、対症療法に走らず調査を挟んだことで、フェンス以外の4つの隠れ地雷(スキーマ強制無効・時限爆弾・切断・通知誤誘導)を同時に潰せた
- 構造化出力は全 required + nullable — optional プロパティは vLLM のスキーマ強制をサイレントに無効化しうる。これは他プロジェクトにも効く知見
- 外部配信は成功してから DB 確定 — 「insert後・配信前クラッシュ → 再実行でスキップ → 無言欠落」を順序反転で根絶
- 相関を実装に反映する前に偏相関(季節除去)を通す — 時間で動くもの同士は、何もしなくても繋がって見える。時間の影響を引き算してから見る
- 除外ロジックは守る対象を外科的に狙う — 雑な網は無実のデータを巻き込み、しかも時間で治らないバグになる
- 多重比較を可視化する — 派手な相関に飛びつく人間の性を、ツール側の較正表示で止める
- 相関では因果の向きが決まらない — だから自然実験(介入前後比較)が要る
- 補正で偽相関源を自作しない — 較正点1日のオフセット補正は第4の偽相関源。正確さのために評価を遅らせる方を選ぶ
- raw を真実源にする — データ品質の判別(どちらの行が正か)が、raw との突き合わせで機械的にできる
- 静かに間違うバグは結果の目視でしか見つからない — n=0、n=95 のような違和感を拾う。エラーを吐かない不整合こそ危険
アンチパターン集(追記)
- optional プロパティによるスキーマ強制のサイレント無効化 — 構造化出力は全required化
- insert後・配信前クラッシュによる無言欠落 — 外部配信成功後にDB確定
- 季節交絡を本命と誤認 — 偏相関を標準装備
- 雑な除外フィルタが新指標を n=0 で殺す — 除外条件を指標の種類で狙う
- 移行データが旧システムの不整合を忠実に運ぶ — 現行コードを疑う前に、症状の出どころを時系列で特定
- 較正点1日での機器差補正 — 新しい偽相関源になる
おわりに:土台ができた。次は時間が仕事をする
このセッションの問い「自分でも気づいていない相関を暴くために何ができるか」への答えは、まず相関発見の土台を、データに騙されないものとして作ることだった。
出来上がったのは、次の4つを標準装備した相関スキャンエンジン:
- 季節偏相関(経過日数を制御して交絡を除く)
- 統計的較正の併記(n別の期待偽陽性、「これは雑音」の明示)
- 指標別の機器段差フィルタ(新指標を殺さない)
- ラグ選択バイアスの明示(Bonferroni補正臨界値)
そしてこの土台を作る過程で、埋もれていたデータ品質問題(内臓脂肪 n=0、weight_kg の9時間ずれ重複)まで炙り出して潰した。相関エンジンが、副産物としてデータの検査装置にもなった。
前回のCO2誤認のような「偽の種を本命と誤認する」事故は、もう土台の側が止めてくれる。実際、この土台を通して残った本物の種は resilience_daytime_recovery × spo2_avg(季節除去後も +0.54)ただ1つ。派手だが偽物のCO2やPWVは、較正情報が「まだ種」「n不足」と正しくラベルしてくれる。
次は時間の仕事だ。Body Scan 系(内臓脂肪・PWV・部位別・体脂肪scan)は2週間〜1ヶ月で n が育つ。8月中旬に再スキャンすれば、今「参考枠で雑音」の指標が本表に上がり、季節除去を通った本物の種が見えてくる。そのタイミングで月次相関レポート化(月1で自動スキャン→有意な種だけAIに意味づけさせてDiscord配信)する。器は既にできている。
焦って今レポート化しても中身が n 不足で雑音なので、データに育つ時間を与えるのが正解。前回から一貫して「器を先に作らず、データに教えてもらってから作る」。
いちばんの収穫は、「たくさんデータを取れば真実が見える」は嘘だと、実データで骨身に沁みたこと。時間で動くものは勝手に繋がって見えるし、変数を増やせば偶然の相関が量産されるし、相関は因果の向きを教えてくれない。それでも——季節を除き、多重比較を較正し、因果は介入で確かめる、という手続きを踏めば、自分でも気づいていなかった本物の関係(日中の回復力と夜間酸素飽和度の連動)が1つ、確かに浮かび上がった。
8月の再スキャンが、その本番になる。
このシステムはローカル完結・個人利用。コードは private リポジトリ、健康データは一切外部に出していない(git にはコードのみ、データはローカルのみ)。前回記事「個人の健康データ基盤『yuru』をゼロから作り直して運用に乗せるまでの全記録」の続編。