「AIの魔法」ではなく誠実な統計:ニューラルネットワークなしで予測エンジンを構築した方法#
App Storeの説明文は今やどれも「AI-powered insights」を謳っています。実際に開いてみると、外部LLMをラップしただけか、理由の説明が一切ない「リスク:高」を吐き出すだけのブラックボックスがほとんどです。天気・睡眠・活動量・体調のつながりを探すアプリ「MeteoHealth」を作ったとき、私は意図的に別の道を選びました。中身はニューラルネットワークでもCore MLでもなく、古典的な統計です。ピアソンの相関係数、p値を伴う分散分析、そして多重比較のためのベンジャミーニ・ホッホベルグ補正。すべて端末内・オフラインで計算され、何より重要なのは、どの結果も一文で説明できることです。このエンジンがどう動くのか、そしてなぜ流行りの「AI-powered」というラベルより誠実な統計のほうが強いプロダクト判断だと考えるのかをお話しします。
「AI-powered」というハイプと、私が慎重になった理由#
ニューラルネットワーク自体が悪いわけではありません。画像認識やテキスト生成では代替の効かない存在です。問題は「AI」という言葉が単なるマーケティング用語になり、単純なif-elseルールにまで貼り付けられていることです。ヘルスケア分野ではこれが特に危険です。ユーザーが「悪化リスク78%」という表示を見ても、アプリに「なぜ」と尋ねる方法がありません。規制当局もこれに気づいています。たとえばEU AI Actは医療AIシステムをリスクレベルで分類し、2026年8月からは高リスクシステムに対し、利用者が結果を実際に理解できるだけの説明を提供することを義務付けています。学術文献ではこれを「ブラックボックス問題」と呼びます。モデルは予測するが推論過程を説明せず、LIMEやSHAPのような事後説明手法も実際のロジックを再現するのではなく近似するに過ぎない、という批判があり、それは信頼の問題を解決するのではなく覆い隠してしまう可能性があると指摘されています。
体調を扱うアプリにとって、これは二重に受け入れがたいことです。MeteoHealthが「頭痛のリスクが上がっている」とユーザーに伝えるなら、それは気圧が下がり同時に睡眠時間も短くなったからだと理解できるべきで、「モデルがそう判断したから」であってはいけません。だからこそ、説明可能性を数式そのものに組み込み、後から別モジュールとして付け足さない統計の上にエンジンを構築するという決断をしました。
ピアソンの相関係数:エンジン最初の道具#
エンジンにおける最初のシンプルな道具がピアソンの相関係数です。これは2つの変数がどれだけ線形に連動しているかを示します。たとえば気圧と1日の体調スコアなど。値は−1から1の範囲で、0なら関係なし、絶対値が1に近いほど関係が強いことを意味します。
/// Pearson correlation coefficient between two data series
func pearsonCorrelation(_ x: [Double], _ y: [Double]) -> Double? {
guard x.count == y.count, x.count > 1 else { return nil }
let n = Double(x.count)
let meanX = x.reduce(0, +) / n
let meanY = y.reduce(0, +) / n
var numerator = 0.0
var sumSqX = 0.0
var sumSqY = 0.0
for i in 0..<x.count {
let dx = x[i] - meanX
let dy = y[i] - meanY
numerator += dx * dy
sumSqX += dx * dx
sumSqY += dy * dy
}
let denominator = (sumSqX * sumSqY).squareRoot()
guard denominator != 0 else { return nil }
return numerator / denominator
}魔法のような要素は一切なく、合計・平均・平方根だけです。しかしこのシンプルなコードには重要な性質があります。デバッガで開いて実際のユーザーデータを入れれば、エンジンが見ているのとまったく同じ数値を確認できるのです。これほどの透明性を持つニューラルネットワークはありません。隠れ層が2つ程度しかない小さな全結合ネットワークでさえ、すでに数千の重みを持ち、そこから人間が読める「なぜ」を取り出すことは不可能です。
分散分析、p値、そして多重比較の問題#
相関係数だけでは不十分です。手元のデータ量を踏まえてどこまで信頼できるかを知る必要があります。ここで分散分析(ANOVA)とp値が登場します。「もし本当は関係がないとしたら、この結果は偶然起こり得たか?」という問いに答えるものです。p値が小さいほど、そのパターンが単なるノイズである可能性は低くなります。
しかしここに古典的な統計の落とし穴が潜んでいます。MeteoHealthは気圧、湿度、睡眠、歩数、カフェイン、HRVなど、数十組の「要因と体調」のペアを同時に検証します。それぞれのペアをp < 0.05という閾値で個別に検定すると、20回の検定のうち平均して1つは単なる偶然で「有意」に見えてしまいます。これが多重比較の問題です。補正なしでは、アプリは見た目は説得力があるものの新しいデータでは再現しない偽の法則性をユーザーに提示してしまいます。
想像してください。あるユーザーが3日連続で曇りの日に頭痛があったとします。単独で見ればこの相関はp < 0.05を超えるかもしれませんが、同時に検証した他の15の要因を踏まえれば、それはほぼ確実に偶然です。だからこそp値ひとつでは不十分で、個々のペアを切り離してではなく全体像を一度に見るステップが必要なのです。
ベンジャミーニ・ホッホベルグ補正:偶然の一致をふるいにかける#
ベンジャミーニ・ホッホベルグ補正は、多数の仮説を同時に検定する際、「有意」とされたすべての結果における偽発見率(false discovery rate)を制御します。考え方はシンプルです。p値を昇順に並べ替え、スケールされた閾値をまだ下回っている最大のものを見つけます。
/// Benjamini–Hochberg correction for multiple comparisons.
/// Returns the indices of hypotheses that remain significant
/// after controlling the false discovery rate at `alpha`.
func benjaminiHochberg(pValues: [Double], alpha: Double = 0.05) -> Set<Int> {
let m = Double(pValues.count)
let indexed = pValues.enumerated().sorted { $0.element < $1.element }
var lastSignificantRank = -1
for (rank, item) in indexed.enumerated() {
let k = Double(rank + 1)
let threshold = (k / m) * alpha
if item.element <= threshold {
lastSignificantRank = rank
}
}
guard lastSignificantRank >= 0 else { return [] }
return Set(indexed.prefix(lastSignificantRank + 1).map { $0.offset })
}実際には、エンジンが体調と15の要因との関係を検定し、そのうち3つが単独ではp < 0.05をクリアしたとしても、補正後に残るのは本当に頑健な1つだけかもしれません。ユーザーに表示されるのはその1つだけで、「湿度80%超と活力低下の間に統計的に有意な関連があります」のような形で提示されます。これは偶然に対する検証を実際に通過した主張です。
個人ベースラインと約10件の記録後の適応#
絶対値は文脈なしには意味を持ちません。安静時心拍数68は、ある人にとっては正常でも、別の人にとってはすでに逸脱かもしれません。そこでエンジンは各指標について個人ベースラインを構築し、新しい記録が入るたびに更新します。
/// Updates a personal baseline using an exponentially weighted moving average,
/// so the model adapts as new daily entries arrive.
struct PersonalBaseline {
private(set) var mean: Double
private(set) var sampleCount: Int
private let smoothing: Double
init(initialMean: Double = 0, smoothing: Double = 0.2) {
self.mean = initialMean
self.sampleCount = 0
self.smoothing = smoothing
}
mutating func update(with value: Double) {
sampleCount += 1
if sampleCount <= 10 {
// Cold start: simple running average for the first ~10 entries
mean = mean + (value - mean) / Double(sampleCount)
} else {
// Warmed up: exponentially weighted average reacts to recent trends
mean = mean + smoothing * (value - mean)
}
}
}最初のおよそ10件の記録では、単純な移動平均という「コールドスタート」段階を使います。データが少ないうちは急な変動を均等にならすほうが安全だからです。その後は指数加重平均に切り替わり、直近の記録にやや強く反応しつつ、一時的な外れ値には引きずられにくくなります。24時間・72時間先の体調予測はまさにこの個人ベースラインの上に築かれており、エンジンは天気・睡眠・活動といった現在の条件を、全ユーザーの平均データではなく、その人自身の通常範囲と、その人にとって有意と判明した相関に照らして比較します。
統計 vs ニューラルネットワーク:誠実な比較#
集計データで小さなモデルを訓練することも、クラウドLLMを「賢いインサイト」として組み込むこともできたはずです。私は意図的にそうしませんでした。理由は以下の通りです。
| 基準 | 統計エンジン | ニューラルネットワーク/クラウドAI |
|---|---|---|
| 説明可能性 | すべての出力が数式とp値に還元される | しばしばブラックボックス、事後説明が必要 |
| プライバシー | データは端末外に出ず、分析はオフライン | 通常サーバーへのデータ送信が必要 |
| 開始に必要なデータ量 | 1人あたり約10件の記録で動作 | 通常は数千のラベル付き例が必要 |
| オフライン動作 | 常に可能 | 組み込みモデルなしでは通常不可 |
| 予測可能性 | 決定論的、再現可能 | 実行やモデルのバージョンで変動しうる |
タスクの種類による2つ目の比較です。
| タスク | 適したツール |
|---|---|
| 2つの指標間の線形関係を見つける | ピアソンの相関係数 |
| 写真の中の物体を認識する | ニューラルネットワーク(Core ML、Vision) |
| 発見が統計的に有意かを確認する | 分散分析 + p値 |
| 自然言語のテキストを生成する | LLM |
| 数十の仮説から偶然の一致をふるいにかける | ベンジャミーニ・ホッホベルグ補正 |
この2つの表からわかるのは、統計とニューラルネットワークはまったく異なる種類の問題を解決するものであり、一方が他方を置き換えるわけではないということです。しかし今回のタスク、つまり一人の人の健康データから個人的で説明可能かつプライベートなパターンを見つけるという課題においては、実務上重視されるすべての基準で古典的な統計がニューラルネットワークに勝りました。少ないサンプルで動作し、サーバーを必要とせず、幻覚を起こさず、開発者としてユーザーに予測が不安になった正確な理由を示せます。Core MLは写真から感情を認識したり加速度計データから活動を分類したりするには最適でしょう。しかし、明日おそらく体調が悪化する理由を人に説明するという仕事には向いていません。
このエンジンはすべてMeteoHealthの中で動いており、だからこそすべての予測には単なる数値ではなく説明が添えられています。「AI-powered」というハイプはいずれ過ぎ去りますが、ユーザーの信頼は、アプリがなぜそう言ったのかを説明できるかどうかの上に築かれます。「あなたのAIはどこにあるのですか」という問いへの最も誠実な答えは、時に「そんなものはありません、代わりに機能する統計があります」というものです。
ヘルステック業界は今、Webのフレームワーク流行と同じ段階を通過していると私は思います。最初は誰もが一番流行りの道具を欲しがり、その後で本当に課題を解決する道具を欲しがるようになります。古典的な統計は「ニューラルネットワークがあなたの健康を予測する」ほど華やかではありませんが、再現可能で、説明可能で、ユーザーにブラックボックスを信頼するよう求めません。一人ひとりの体調を扱うアプリにとって、それは妥協ではなく唯一理にかなった選択です。



