論文一覧に戻る 📚 用語解説(ジャストインタイム型データサイエンス教育)
オッズ比
Odds Ratio (OR)
2群のオッズの比。ロジスティック回帰では係数のexpがオッズ比に対応。OR>1 で正の影響。
推測統計ORORオッズ比

🔖 キーワード索引 — 完全強化版

「オッズ比」を理解するうえで必要なキーワードを 10 件以上提示します。 各チップから対応セクションへ移動できます。

30 秒結論 文脈 直感 数式 記号読み解き 実値計算 Python 実装 落とし穴 関連手法 関連用語 グループ教材 概念マップ

💡 30秒で分かる結論

🍰 まずはやさしく

2つのグループの起こりやすさを比べる道具です。

どちらの影響が強いかを知るために使います。

部活の練習量で試合に勝つ確率が変わるか調べます。

この章では計算方法と使い道を学びます。

📖 包括的解説 — この概念を完全マスター

📍 学習の3ステップ

  1. 定義を理解する:この概念は何か? 数式や条件を確認
  2. 具体例を見る:実データ(SSDSE 等)で計算してみる
  3. 応用する:自分のデータに適用、 結果を解釈

🔧 Python実装パターン

題材は本ページ全体で一貫して使う 「高齢化率が高い県では一般病院の密度も高いか?」という問い(SSDSE-B-2026, 2023 年, 47 都道府県)。 高齢化率=65 歳以上人口(A1303)/総人口(A1101)、 病院密度=一般病院数(I510120)/総人口 をそれぞれ中央値で二値化して 2×2 表を作り、 ①素の OR → ②ロジスティック回帰での再現 → ③連続変数の OR → ④ブートストラップ CI の順で計算します。

① scipy — 2×2 表から OR・χ²・信頼区間

🎯 目的: 中央値で二値化した 2×2 表から、 標本オッズ比・Fisher の正確検定・χ² 検定・95% 信頼区間を一度に求める。
📥 入力(橋渡し): table = [[17, 7], [7, 16]] 高齢化率高 → 病院密度高 17 / 病院密度低 7 高齢化率低 → 病院密度高 7 / 病院密度低 16
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np
from scipy import stats

# 高齢化率 高/低 × 病院密度 高/低 の 2×2 表(SSDSE-B-2026, 2023年, 47都道府県)
#             病院密度高  病院密度低
#  高齢化率高     17          7
#  高齢化率低      7         16
table = np.array([[17, 7], [7, 16]])

# Fisher の正確検定 → OR と p 値が一度に求まる
or_, p = stats.fisher_exact(table, alternative='two-sided')
print(f'OR = {or_:.2f}, p = {p:.4f}')

# χ² 検定(カイ二乗・Yates 連続性補正あり)
chi2, p_chi2, dof, expected = stats.chi2_contingency(table)
print(f'chi2 = {chi2:.3f}, p = {p_chi2:.4f}')

# OR の 95% CI を手計算(log スケールで対称 → exp で戻す)
a, b, c, d = 17, 7, 7, 16
log_or = np.log((a*d) / (b*c))
se_log_or = np.sqrt(1/a + 1/b + 1/c + 1/d)
ci_lower = np.exp(log_or - 1.96*se_log_or)
ci_upper = np.exp(log_or + 1.96*se_log_or)
print(f'OR 95% CI: [{ci_lower:.2f}, {ci_upper:.2f}]')
📤 実行結果: OR = 5.55, p = 0.0087 chi2 = 6.139, p = 0.0132 OR 95% CI: [1.59, 19.38]
💬 読み方: OR = 5.55 は「高齢化率が高い県では病院密度も高い(そのオッズが 5.55 倍)」ことを示す。 95% CI [1.59, 19.38] が 1 をまたがないので統計的に有意(Fisher p = 0.0087)。 ただし都道府県を単位にした生態学的な関連であり、 個人レベルの因果ではない点に注意。

② statsmodels — ロジスティック回帰で 2×2 の OR を再現

🎯 目的: 「ロジスティック回帰の係数の exp がオッズ比」という定義を実データで確かめる。 二値の説明変数を 1 つだけ入れた回帰の exp(係数) は、 2×2 表の OR と厳密に一致する。
📥 入力(橋渡し): SSDSE-B-2026.csv を cp932・skiprows=[1] で読み、 2023 年 47 県に絞り、 aging_high(高齢化率≥中央値)で high_hosp を説明する。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()

# 高齢化率 = 65歳以上人口(A1303) / 総人口(A1101)、病院密度 = 一般病院数(I510120) / 総人口
df['aging'] = df['A1303'] / df['A1101'] * 100
df['hosp']  = df['I510120'] / df['A1101'] * 100000

# 中央値で二値化
df['aging_high'] = (df['aging'] >= df['aging'].median()).astype(int)
df['high_hosp']  = (df['hosp']  >= df['hosp'].median()).astype(int)

# 二値の説明変数を1つだけ入れたロジスティック回帰は 2×2 表の OR を再現する
model = smf.logit('high_hosp ~ aging_high', data=df).fit(disp=0)
OR = np.exp(model.params['aging_high'])
ci = np.exp(model.conf_int().loc['aging_high'])
print(f'OR = {OR:.2f}  95% CI [{ci[0]:.2f}, {ci[1]:.2f}]  p = {model.pvalues["aging_high"]:.4f}')
📤 実行結果: OR = 5.55 95% CI [1.59, 19.38] p = 0.0072
💬 読み方: ①の 2×2 の OR (5.55) と CI (1.59, 19.38) が完全に一致する。 「係数の exp = オッズ比」が説明変数二値 1 本のとき厳密に成り立つことが確認できる。 交絡を調整したいときは、 ここに説明変数を足していけばよい。

③ statsmodels — 連続変数のオッズ比(1 ポイント増あたり)

🎯 目的: 高齢化率を二値化せず連続変数のまま投入すると、 OR は「高齢化率が 1 ポイント上がるごとのオッズ倍率」を表す。 二値化で捨てていた情報を活かせる。
📥 入力(橋渡し): ②のコードの説明変数を aging_high(二値)から aging(連続, %)に置き換えるだけ。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()
df['aging'] = df['A1303'] / df['A1101'] * 100
df['hosp']  = df['I510120'] / df['A1101'] * 100000
df['high_hosp'] = (df['hosp'] >= df['hosp'].median()).astype(int)

# 高齢化率を連続変数のまま投入 → 「1ポイント上がるごとのオッズ倍率」が OR
model = smf.logit('high_hosp ~ aging', data=df).fit(disp=0)
OR = np.exp(model.params['aging'])
ci = np.exp(model.conf_int().loc['aging'])
print(f'高齢化率 +1pt あたり OR = {OR:.2f}  95% CI [{ci[0]:.2f}, {ci[1]:.2f}]  p = {model.pvalues["aging"]:.4f}')
📤 実行結果: 高齢化率 +1pt あたり OR = 1.40 95% CI [1.10, 1.79] p = 0.0070
💬 読み方: 高齢化率が 1 ポイント高い県は、 病院密度が高い側に入るオッズが 1.40 倍。 5 ポイント差なら 1.40^5 ≈ 5.4 倍で、 ②の二値化 OR (5.55) とも整合する。 連続 OR は単位(1pt か 10pt か)で値が変わる点に注意。

④ ブートストラップで信頼区間を求める(小標本向け)

🎯 目的: n=47 と小さくセル度数も 1 桁のため、 log-OR の正規近似より、 復元抽出を繰り返すブートストラップの方が CI を素直に評価できる。
📥 入力(橋渡し): 2×2 表 (17, 7, 7, 16) を 47 県ぶんの 0/1 データに展開し、 2000 回復元抽出して標本オッズ比の分布を作る。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import numpy as np

# Haldane-Anscombe 補正(+0.5)でゼロセルに備えた標本オッズ比
def sample_or(a, b, c, d):
    return (a*d + 0.5) / (b*c + 0.5)

# 元の 2×2 表を「47 県ぶんの 0/1 データ」に展開
aging = np.r_[np.ones(24), np.zeros(23)]      # 高齢化高 24県, 低 23県
hosp  = np.r_[np.ones(17), np.zeros(7),        # 高齢化高: 病院高17, 病院低7
              np.ones(7),  np.zeros(16)]       # 高齢化低: 病院高7,  病院低16

rng = np.random.default_rng(42)
boot = []
for _ in range(2000):
    idx = rng.integers(0, 47, 47)              # 復元抽出
    aa = ((aging[idx]==1) & (hosp[idx]==1)).sum()
    bb = ((aging[idx]==1) & (hosp[idx]==0)).sum()
    cc = ((aging[idx]==0) & (hosp[idx]==1)).sum()
    dd = ((aging[idx]==0) & (hosp[idx]==0)).sum()
    boot.append(sample_or(aa, bb, cc, dd))
lo, hi = np.percentile(boot, [2.5, 97.5])
print(f'ブートストラップ 95% CI: [{lo:.2f}, {hi:.2f}]')
📤 実行結果: ブートストラップ 95% CI: [1.72, 24.55]
💬 読み方: ブートストラップ CI [1.72, 24.55] は①の Wald CI [1.59, 19.38] より上側に広い。 標本オッズ比は右に歪むため、 小標本では正規近似 CI が上側を過小評価しがち。 いずれの方法でも下限が 1 を超えるので「有意」という結論は頑健。

💡 30 秒で分かる結論 — 完全強化版

📍 あなたが今見ているもの

🍰 まずはやさしく

データの関係性を読み解くための言葉です。

ある条件が結果にどう影響するかを調べます。

高齢化率が高い県は病院が多いか考えます。

この用語が分析のどこで使われるかを確認します。

論文中に 「オッズ比」として登場する用語。

オッズ比 とは:2群のオッズの比。ロジスティック回帰では係数のexpがオッズ比に対応。OR>1 で正の影響。

📍 文脈ボックス — あなたが今見ているもの(完全強化版)

このセクションは「オッズ比」を扱う 用語ページ です。 統計データ分析コンペティション(2026)の再現教材における中核用語のひとつで、高齢化率が高い県ほど一般病院の密度も高い傾向があるか という観点で SSDSE-B-2026(47 都道府県 × 複数年 × 100 超列)に紐づけられます。

位置づけ:相関・線形回帰・仮説検定 といった基礎用語群と並列であり、応用としては 内生性・IV・DID・クラスタリング 等へ繋がります。

🎨 直感で掴む — 完全強化版

🍰 まずはやさしく

データを分析するための眼鏡のようなものです。

見方を変えて隠れた特徴を見つけるために使います。

スマホの利用時間と成績の関係を例に考えます。

直感的にどのような意味を持つのかを掴みます。

オッズ比 を一言でいえば「2 群のオッズの比」。 47 都道府県という小さな母集団でも、 SSDSE-B-2026 の高齢化率と病院密度を二値化して見ると、 大都市圏と地方の差・人口規模に伴う相対比較など、 様々なパターンが見えてきます。

比喩でいうと、 オッズ比 はデータ分析の「眼鏡」のようなもの。 同じデータでも眼鏡を変えれば、 平均(中心)・分散(ばらつき)・相関(連動)・因果(影響)と、 異なる情報が浮かび上がります。 SSDSE-B-2026 を題材に、 この眼鏡をかけてみるのが本ページの狙いです。

📐 数式または定義 — 完全強化版

🍰 まずはやさしく

確率から計算して出す数値のことです。

正確な影響力を数式で表すために使います。

買い物での買い忘れが起きる比率を計算します。

数式を使って定義と仕組みを詳しく読み解きます。

オッズ比 の代表的な定義式は次のとおりです。

$$ OR = \frac{a/b}{c/d} = \frac{ad}{bc} $$

ここで使われる記号や演算の意味は次節で言葉に翻訳します。

📐 オッズ比を深く理解する — 確率 → オッズ → 対数オッズ

オッズ比 (OR) を本当に使いこなすには、 まず「確率 → オッズ → 対数オッズ (logit)」という三段の翻訳を頭に入れる必要がある。 ロジスティック回帰の係数 β は logit スケールでの効果であり、 exp(β) を取ると 1 単位あたりの OR になる ── これがオッズ比の最も実用的な定義である。

📐 三段の関係式は次のとおり。 数式を言葉で読み解くと、 (1) 確率 p は 0〜1 の制約を持つ、 (2) オッズ p/(1-p) は 0〜∞ に拡張される、 (3) 対数オッズ log(p/(1-p)) は -∞〜+∞ に拡張され、 線形回帰の枠組みに乗せられる。

$$ p \to \mathrm{odds} = \frac{p}{1-p} \to \mathrm{logit}(p) = \log\!\frac{p}{1-p} $$ $$ \log \mathrm{OR} = \mathrm{logit}(p_1) - \mathrm{logit}(p_2) = \beta_{\text{logistic}} $$
確率 pオッズ p/(1-p)logit(p) = log(odds)直感的意味
0.010.0101-4.60非常にまれな事象
0.100.111-2.20まれな事象
0.501.000.00五分五分 (no effect baseline)
0.909.00+2.20かなり起こりやすい
0.9999.0+4.60ほぼ確実

💬 表のポイント: logit は対称である ── p=0.10 と p=0.90 が ±2.20 で対称に並ぶ。 ロジスティック回帰の係数を見るときは、 この対称性を頭に入れておくと「+0.5 の係数 ≒ OR≈1.65」「+1.0 の係数 ≒ OR≈2.72」と暗算できる。

🔬 数式を言葉で読み解く — 完全強化版

数式の各記号を、日本語の意味に変換します。

🧮 実値で計算してみる — SSDSE-B-2026 で オッズ比(完全強化版)

SSDSE-B-2026(公的統計の社会・教育系データセット、 47 都道府県 × 10 年分超 × 100 以上の列)を用いて、 「オッズ比」を体感します。 ファイル名は SSDSE-B-2026.csv、 読み込みは下記の Python コードで行います。

1
2
3
4
5
6
7
8
import pandas as pd

# SSDSE-B-2026 を読み込む(cp932 / Shift_JIS)
df = pd.read_csv('data/raw/SSDSE-B-2026.csv', skiprows=[1], encoding='cp932')
print(df.shape)          # (564, 112)
print(df['SSDSE-B-2026'].unique())  # 含まれる年度
latest = df[df['SSDSE-B-2026'] == df['SSDSE-B-2026'].max()].copy()
print(latest[['Prefecture', 'I510120', 'A4101']].head())
📤 実行例(実測) (564, 112) [2023 2022 2021 2020 2019 2018 2017 2016 2015 2014 2013 2012] Prefecture I510120 A4101 0 北海道 464 24430 12 青森県 72 5696 24 岩手県 76 5432 36 宮城県 108 12328 48 秋田県 48 3611

💬 全体は 564 行 × 112 列(47 都道府県 × 2012〜2023 年の 12 年度)で、最新年度 2023 に絞った latest が以降の OR 計算の土台になる。北海道の一般病院 464・出生数 24,430 と秋田県の 48・3,611 のように、実数のままでは県の規模の差がそのまま出るので、OR を計算する前に人口 10 万人あたりや高齢化率のような率に直してから中央値で 2 値化する。行番号が 12 刻みなのは CSV が県ごとに 12 年度分を並べているため。

ここで使った I510120(一般病院数)と A1303(65 歳以上人口)は、 SSDSE-B-2026 における 高齢化率と病院密度のオッズ比 に関連する指標です。 算出例:

🧮 ステップ・バイ・ステップ — 47 都道府県データを使った OR 解析の全工程

実務で OR を扱うとき、 単に「OR=X.X、 P=Y.Y」を出して終わりではなく、 5 段階のステップを踏む。 SSDSE-B-2026 の高齢化×病院密度問題を例に、 各段階で何を確認し何を文書化するかを示す。

ステップ 1: 結果変数と暴露変数の事前定義

解析を始める前に「結果は何か、 暴露は何か、 中央値で切るのか四分位で切るのか、 連続値のまま使うのか」を文書化する。 後から閾値を試行錯誤すると p-hacking になる。

事前定義 (例): 結果 Y = I510120 / A1101 * 100000 (人口 10 万人あたり一般病院数) ≥ 中央値 暴露 X = A1303 / A1101 (高齢化率) ≥ 中央値 対象 = SSDSE-B-2026 の 2023 年データ全 47 都道府県 仮説 H0: OR = 1 H1: OR ≠ 1 有意水準 α = 0.05、 両側検定

ステップ 2: データクリーニングと記述統計

欠損・外れ値・分布形状を確認。 連続値の中央値・四分位を見る。

df[['aging','hospital_per_100k']].describe() aging hospital_per_100k count 47.0000 47.0000 mean 0.3159 6.8995 std 0.0334 2.7668 min 0.2275 3.1314 25% 0.3005 4.9193 50% (median) 0.3178 5.9937 75% 0.3401 8.4741 max 0.3906 16.0661 欠損: 0、 外れ値 (IQR×1.5 超): hospital_per_100k で 高知 (1 県, 16.07) → 中央値で二値化すれば外れ値の影響は限定的

ステップ 3: 単純 2×2 表での粗 OR

まず単純な OR と Fisher P 値で第一報。 これが「全体像」となる。

2×2 表: 高病院密度 低病院密度 計 高齢化率 ≥ 中央 17 7 24 高齢化率 < 中央 7 16 23 計 24 23 47 粗 OR = (17×16)/(7×7) = 272/49 = 5.55 95% CI [Wald, log-scale]: [1.59, 19.38] Fisher exact P = 0.0087 χ²(1) P = 0.0132 (Yates 補正)

ステップ 4: 交絡調整 (多変量ロジスティック)

総人口を入れて調整。 粗 OR と調整 OR を比べる。

Logit: high_hosp ~ aging_10pct + log_pop coef OR CI_low CI_high p aging_10pct 1.668 5.301 0.272 103.16 0.2707 log_pop -1.226 0.293 0.076 1.13 0.0742 粗 OR=5.55 → 調整 OR=5.30、 ただし P=0.27 で非有意 (95% CI が 1 を大きくまたぐ) → 総人口 (log) を調整すると、 高齢化の単独効果は統計的に有意でなくなる

📝 より正確な分析:実データ (SSDSE-B-2026, 2023 年・47 都道府県) で総人口 (対数) を調整した多変量ロジスティック回帰を再計算すると、 高齢化率の 調整 OR=5.30、 P=0.27 と非有意になり、 95% CI [0.27, 103.16] も極端に広い。 点推定は粗 OR 5.55 とほとんど変わらないのに区間が大きく広がり、 総人口を調整すると高齢化の単独効果は有意でなくなる。 高齢化率と総人口は強く相関しており (小規模県ほど高齢化率が高い)、 47 県という小標本では両者を同時に入れると推定が不安定になる (多重共線性・情報量不足)。 粗 OR の有意性は交絡・標本サイズに敏感で、 頑健とは言えない。

ステップ 5: 層別感度分析と頑健性

仮定 (median split, 人口層) を変えても結論が崩れないか確認。

感度分析: ① 総人口 (log) 調整の多変量ロジスティック: 調整 OR=5.30, P=0.271 (非有意) ② 人口下位 24 県のみで再解析: OR=1.75, Fisher P=0.62 (非有意) ③ 人口上位 23 県のみで再解析: OR=3.33, Fisher P=0.29 (非有意) 結論: 粗 OR (5.55, P=0.0087) は有意だが、 総人口の調整・人口層別の いずれでも有意性が消え、 関連は頑健ではない。 47 県・小標本ゆえ CI が広く、 交絡 (人口規模) の影響が大きい。 生態学的・観察的デザインの限界も併せて解釈する。

📝 報告書の書き方 (推奨フォーマット)

「SSDSE-B-2026 (2023 年、 47 都道府県) を用いた解析では、 高齢化率の高い県 (中央値 ≥ 31.8%) では低い県と比較して人口 10 万人あたり一般病院数が中央値以上となるオッズが 5.55 倍高かった (95% CI [1.59, 19.38]、 Fisher P=0.0087)。 一方、 総人口 (対数変換) を調整した多変量ロジスティック回帰では調整 OR=5.30 (95% CI [0.27, 103.2]、 P=0.27) と有意性は失われ、 人口下位・上位いずれの層別でも関連は非有意 (OR=1.75 と 3.33、 Fisher P=0.62 と 0.29) であった。 したがって粗の関連は総人口による交絡・小標本の影響を強く受けており、 頑健とは言えない。 解析は県単位の生態学的研究であり、 個人レベルの因果は推論できない。」

💬 ポイント: (1) 効果サイズと CI を必ず併記、 (2) 粗推定と調整推定を両方提示、 (3) 感度分析の結果も触れる、 (4) 研究デザインの限界 (生態学的・観察的) を明示。 これが現代の疫学・公衆衛生のレポーティング基準 (STROBE statement) に沿った書き方。

❓ Deep FAQ — オッズ比 ─ よくある疑問 12 連発

疑問答え
Q1. OR と相関係数 r は何が違う?r は 2 連続変数の線形関連、 OR は 2 二値変数 (もしくは連続→二値結果) の関連。 OR は方向性なしの「比」、 r は -1〜+1 の方向付き「強さ」。
Q2. OR が 0.5 と 2 はどっちが「強い」?同じ強さ (対称)。 log(0.5) = -log(2)。 OR < 1 は「保護的効果」、 OR > 1 は「リスク因子」。
Q3. 連続変数のロジスティック回帰の OR は?exp(β) で「1 単位増あたりのオッズ倍率」。 単位を変えれば OR も変わる (kg と g、 % と 0.01 単位など)。
Q4. OR の対数尺度がなぜ正規分布?中心極限定理により ad/bc という比の対数は近似的に正規分布。 OR 自体は右に歪んだ対数正規。
Q5. P 値が 0.04 でも OR=1.05 ならどう報告?「統計的に有意だが実質的効果は小さい」と書く。 P 値と効果サイズは別物。 大標本では些細な差も有意になる。
Q6. 多変量ロジスティック回帰の係数の解釈順序は?(1) OR の方向と大きさ、 (2) CI が 1 をまたぐか、 (3) P 値、 (4) 他変数調整の有無、 の順で読む。
Q7. OR=1 の H0 を P 値で棄却 = 因果あり?いいえ。 関連の存在を示すだけ。 因果には RCT または準実験 (DID/IV/RDD) + 因果ダイアグラム。
Q8. クラスター標本 (病院ごとの患者) で OR は?通常の SE は独立性を仮定するため過小評価。 GEE か混合効果モデルで cluster-robust SE。
Q9. OR の幾何平均と算術平均はどっち?必ず幾何平均 (log スケールで算術平均してから exp)。 算術平均は対称性を壊す。
Q10. メタ解析で OR を統合するときの定石は?逆分散重み付け (Inverse Variance) で log(OR) を統合してから exp。 Random-effects (DerSimonian-Laird) か Fixed-effects (Mantel-Haenszel) を選ぶ。
Q11. OR=Inf や 0 になったらどうする?0 セルが原因。 +0.5 補正、 Fisher 検定、 Firth logit のいずれかで対処。
Q12. ロジスティック回帰と OLS で OR を出すには?OLS でも線形確率モデルは推定できるが、 出力は確率差 (RD 近似) であり OR ではない。 OR が欲しいならロジット必須。

📊 オッズ比を「見て」理解する — 図解 3 枚で骨格を掴む

オッズ比 (Odds Ratio, OR) は数値だけ見ても直感が湧きにくい指標である。 リスク比 (RR) と違って 「何倍危険か」を直接表さないこと、 そして 結果がレアな時のみ RR とほぼ一致すること、 さらに 標本サイズが小さいと信頼区間がベラボウに広がること ── こうした性質は、 OR がどんな数から計算されているかを図で見ておくと腹落ちしやすい。 ここでは「連続変数を二値化して 2×2 表を作り、 OR を出す」までの流れを、 SSDSE-B-2026 (47 都道府県, 2023 年度) の高齢化率と病院密度で 3 枚の図にした。 図は code/glossary_figs/odds-ratio.py で描いたもので、 各図の下のコードを実行すると同じデータ・同じ分け方の図が html/glossary/figures/odds-ratio_*.png に保存される。

図 1 — 散布図で見る「連続変数 → 二値化 → クロス表 → OR」の流れ

まず最初の図は、 連続変数の散布図を起点にして「中央値で二値化すると 2×2 表ができる」という流れを直感的に示すものだ。 横軸が高齢化率、 縦軸が人口 10 万人あたり一般病院数。 ここに 縦の中央値線・横の中央値線を引くと 47 県は 4 つの象限に分かれ、 そのカウントがそのまま 2×2 表になる。 オッズ比はこの 4 つの数 (a, b, c, d) だけから ad/bc で計算されるので、 図を見ているだけで「あ、 右上 (両方高) と左下 (両方低) が多ければ OR は大きくなる」と分かる。

高齢化率と人口 10 万人あたり一般病院数の散布図に中央値線を引き、4 象限の県数と OR を示した図
図 1: 散布図に縦横の中央値線を引くと 47 県が 4 象限に分かれ、 右上 (両方高)・左下 (両方低)・右下・左上のカウントがそのまま 2×2 表の (a, b, c, d) になる。 オッズ比 = ad/bc は 「対角線同士の積の比」であり、 図的には 「対角線に偏った分布ほど OR が 1 から離れる」と読める。 実データでは右上 a = 16、 右下 b = 7、 左上 c = 7、 左下 d = 17 県(中央値ちょうどの山梨県・群馬県は低い側)で、 OR = (16×17)/(7×7) = 5.55。 連続値のままの相関は r = 0.54 で、 高知県(36.4%、 16.1 件)のように右上に大きく外れる県もある。
📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1303(65歳以上人口) I510120(一般病院数) 北海道 5,092,000 1,681,000 464 東京都 14,086,000 3,205,000 588 沖縄県 1,468,000 350,000 76 …(全 47 行)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import os
os.makedirs('html/glossary/figures', exist_ok=True)  # 保存先のフォルダを作っておく

import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = ['Hiragino Sans', 'IPAexGothic', 'DejaVu Sans']  # 日本語の軸ラベル用

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', skiprows=[1], encoding='cp932')
df_2023 = df[df['SSDSE-B-2026'] == 2023].copy()
df_2023['高齢化率'] = df_2023['A1303'] / df_2023['A1101'] * 100
df_2023['病院密度'] = df_2023['I510120'] / df_2023['A1101'] * 100000

fig, ax = plt.subplots(figsize=(7, 6))
ax.scatter(df_2023['高齢化率'], df_2023['病院密度'], alpha=0.7)
ax.axvline(df_2023['高齢化率'].median(), color='red', ls='--', label='高齢化率 中央値')
ax.axhline(df_2023['病院密度'].median(), color='blue', ls='--', label='病院密度 中央値')
ax.set_xlabel('高齢化率 (%)'); ax.set_ylabel('人口10万人あたり一般病院数')
ax.legend(); plt.tight_layout(); plt.savefig('html/glossary/figures/odds-ratio_scatter.png', dpi=120)

🎯 このコードでやること: SSDSE-B-2026 から高齢化率と病院密度を計算し、 中央値線つき散布図を保存する。 中央値線の交点で 4 象限に分かれることが視覚化される。

📥 入力データ: data/raw/SSDSE-B-2026.csv の 2023 年 47 県。 A1303 = 65 歳以上人口、 A1101 = 総人口、 I510120 = 一般病院数。

📤 実行結果: html/glossary/figures/odds-ratio_scatter.png が保存される。 右上 (高齢化率↑かつ病院密度↑) 16 県、 左下 17 県、 右下 7 県、 左上 7 県 (中央値ちょうどの県は低い側に数える) ── 対角線上 (右上+左下=33 県) が反対角 (右下+左上=14 県) の 2 倍以上あり、 はっきりした正の傾向が読み取れる。

💬 読み方: 散布図上の点が右上ー左下の対角に偏っている時、 (a, d) が増えて (b, c) が減るので OR=ad/bc は 1 より大きくなる。 つまり 「散布図で正の相関がある関係は、 二値化した OR でも 1 より大きい OR を生む」ことが目で見て確認できる。 これは 連続変数の Pearson 相関 → 二値化後の OR への概念的橋渡しとして現場で重宝する説明法。

図 2 — ヒストグラムで「中央値での二値化」が正当か確認する

2×2 表を作る前に必ず確認すべきは 「中央値で切るのが妥当か」という点。 ヒストグラムを描いて分布が単峰性で大きな歪みがなければ中央値カットは妥当、 逆に二峰性ならカット点を分布の谷に置く方がよい。 また外れ値が片側に大きく寄っていると、 中央値カットでも「左群と右群の代表値が大きく違いすぎる」状況が起こり、 OR が極端な値を取りやすい。

47 都道府県の高齢化率のヒストグラム(1 ポイント刻み)に中央値と平均を重ねた図
図 2: 47 都道府県の高齢化率の分布。 中央値 (橙の破線) は 31.8%、 平均 (黒の点線) は 31.6% でわずかに左にある。 東京都 22.8%・沖縄県 23.8% が左に裾を引き、 最大は秋田県 39.1% だが、 山は 1 つなので、 中央値カット (47 県を 24 県・23 県に分ける) は 「ほぼ同じサイズの 2 群」を作るための合理的な選択。
1
2
3
4
5
6
7
8
9
10
11
12
import os
os.makedirs('html/glossary/figures', exist_ok=True)  # 保存先のフォルダを作っておく

import matplotlib.pyplot as plt
plt.rcParams['font.family'] = ['Hiragino Sans', 'IPAexGothic', 'DejaVu Sans']  # 日本語の軸ラベル用

fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(df_2023['高齢化率'], bins=15, edgecolor='black', alpha=0.7)
ax.axvline(df_2023['高齢化率'].median(), color='red', ls='--', label=f'中央値 {df_2023["高齢化率"].median():.1f}%')
ax.axvline(df_2023['高齢化率'].mean(), color='blue', ls='--', label=f'平均 {df_2023["高齢化率"].mean():.1f}%')
ax.set_xlabel('高齢化率 (%)'); ax.set_ylabel('県数'); ax.legend()
plt.tight_layout(); plt.savefig('html/glossary/figures/odds-ratio_hist.png', dpi=120)

🎯 このコードでやること: 高齢化率の分布をヒストグラムにし、 中央値と平均を縦線で重ねて二値化の妥当性を可視確認する。

📥 入力データ: 上記散布図と同じ df_2023 の 高齢化率 列、 47 値。

📤 実行結果: html/glossary/figures/odds-ratio_hist.png が保存される。 中央値 31.8%、 平均 31.6% で両者がほぼ一致 (東京都・沖縄県の低い値に引かれて平均がわずかに下) ── 分布の歪みが小さいため、 中央値カットでも平均カットでも 2 群の構成はほぼ変わらない。

💬 読み方: 平均と中央値が大きく離れたら 中央値カットを優先すること。 これは 外れ値の影響を受けない 2 群分けのためであり、 OR の頑健性に直結する。 もし分布が二峰性なら「中央値カット」より「分布の谷でのカット」が意味的に正しい二値化となる。

図 3 — グループ別ボックスプロットで「OR が出てくる構造」を確認する

OR が「両群の差」を要約していると言っても、 結局のところそれは 2 群の値分布がどれくらいズレているかに依存している。 ボックスプロットで暴露群と非暴露群の結果変数を見比べると、 OR の数値が大きい時には箱がほとんど重ならず、 OR ≈ 1 の時には箱がほぼ重なる ── という関係が目で確認できる。 これが「OR ≈ 1 は群間に差がない」「OR が 1 から離れるほど 2 群が分離している」という言い回しの実体である。

高齢化率の 3 分位ごとの人口 10 万人あたり一般病院数の箱ひげ図
図 3: 高齢化率を「低 (下位 1/3)」「中」「高 (上位 1/3)」の 3 群に分け、 各群の病院密度をボックスプロットで比較(点は各県)。 病院密度の中央値は低群 (16 県、 22.8〜30.6%) 5.0、 中群 (15 県) 6.5、 高群 (16 県、 33.3〜39.1%) 8.5 件/10 万人で、 低群の箱の上端 5.8 が高群の箱の下端 6.0 に届かず、 箱が重ならない ── これが「OR が 1 から離れている」状態の視覚化。 もし 3 つの箱がほぼ重なっていれば OR ≈ 1 となり、 統計的有意性も得にくい。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
os.makedirs('html/glossary/figures', exist_ok=True)  # 保存先のフォルダを作っておく

import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = ['Hiragino Sans', 'IPAexGothic', 'DejaVu Sans']  # 日本語の軸ラベル用

df_2023 = df_2023.copy()
df_2023['高齢化3群'] = pd.qcut(df_2023['高齢化率'], q=3, labels=['低', '中', '高'])

fig, ax = plt.subplots(figsize=(7, 5))
data = [df_2023[df_2023['高齢化3群'] == g]['病院密度'].values for g in ['低', '中', '高']]
ax.boxplot(data, tick_labels=['低 (16 県)', '中 (15 県)', '高 (16 県)'])
ax.set_xlabel('高齢化率の 3 分位'); ax.set_ylabel('人口10万人あたり一般病院数')
plt.tight_layout(); plt.savefig('html/glossary/figures/odds-ratio_box.png', dpi=120)

🎯 このコードでやること: 高齢化率を 3 分位に切り、 各群の病院密度のばらつきをボックスプロットで並べる。 OR が「群間差を要約した数値」であることを視覚化する。

📥 入力データ: df_2023 の 高齢化率 と 病院密度 列。 47 県を高齢化率の昇順で 16, 15, 16 県ずつに分割。

📤 実行結果: html/glossary/figures/odds-ratio_box.png が保存される。 低群中央値 5.0、 中群 6.5、 高群 8.5 (件/10 万人) ── 高群と低群で中央値が 1.7 倍ほど開く。 これが OR の正体。

💬 読み方: ボックスプロットでヒゲや箱が重なっていない時は 「群間差が大きい → OR が 1 から離れる → 検定で有意になりやすい」と読む。 逆に箱が完全に重なっていれば OR ≈ 1 で帰無仮説を棄却できない。 OR の数値だけ追っていると、 この「分布の重なり具合」が見えなくなる ── 図と数値はいつでもセットで提示するのがコンペでの作法。

🧠 理解度チェック — オッズ比 10 問でセルフ確認

ここまでの内容を本当に理解しているか確認するためのチェック問題。 すぐ答えを見ずに、 まず自分で考えて頭の中で言語化してから解答を開いてほしい。 全 10 問中 8 問正解で「コンペ実戦レベル」、 6 問以下なら本ページの該当セクションに戻って読み直すのがおすすめ。 解答には根拠と参照セクションを必ず添えている。

Q1: 2×2 表 [[a=20, b=10], [c=5, d=25]] のオッズ比は?

OR = ad/bc = (20 × 25)/(10 × 5) = 500/50 = 10。 暴露群のオッズが非暴露群の 10 倍。 解の根拠は「📐 数式または定義」セクションの定義式。 ad/bc は 「対角線同士の積の比」と覚えること。

Q2: OR = 1 はどういう意味か?

暴露群と非暴露群でオッズが等しい、 つまり 暴露と結果に関連がないことを意味する。 これは帰無仮説 H0: OR = 1 に対応する。 ただし OR = 1 でも「関連がない」とは断言できず、 「標本からの観測 OR が 1 に近かったので有意な関連は検出できなかった」という意味に過ぎない。 詳しくは「⚠️ 落とし穴」セクションの 95% CI の扱いを参照。

Q3: OR と RR (リスク比) はいつ近い値になるか?

結果がレア (発生率が両群とも 10% 以下くらい) のとき、 OR ≈ RR となる。 数式的には RR = OR × (1 - p0)/(1 - p1) で、 p0, p1 が小さい時に右辺の補正項 ≈ 1 となるため。 結果が頻繁 (例 50% 以上) になると OR は RR より 大幅に大きくなり、 OR を「リスク何倍」と読むと過大評価になる。 「⚠️ OR と RR の違い」セクション参照。

Q4: log(OR) の標準誤差はどう計算するか?

SE(log OR) = √(1/a + 1/b + 1/c + 1/d)。 95% CI は log(OR) ± 1.96 × SE で求め、 exp で OR スケールに戻す。 セル度数のどれかが 0 だと逆数が無限大になり計算が破綻するので、 各セルに 0.5 を足す Haldane 補正、 または Firth's penalized logistic 回帰を使う。 「⚠️ 0 セル問題」セクション参照。

Q5: ロジスティック回帰の係数 β とオッズ比の関係は?

OR = exp(β)。 説明変数が 1 単位増えると、 結果が起こるオッズが exp(β) 倍になる。 β = 0 → OR = 1 (関連なし)、 β > 0 → OR > 1 (正の関連)、 β < 0 → OR < 1 (負の関連)。 連続変数の OR は「1 単位増加あたり」なので、 単位の取り方で見かけが変わる (年齢 1 歳 → 10 歳に変えると OR が大きく見える)。 「🐍 Python 実装 — ロジスティック回帰」セクション参照。

Q6: ケースコントロール研究で OR を使う理由は?

ケースコントロール研究では結果 (ケース) を起点にサンプリングするため、 全集団での発生率を推定できず、 リスク比 RR を直接計算できない。 しかし OR は両群間で対称的な性質を持ち、 結果起点のサンプリングでも母集団の OR と同じ値が推定できる。 これがケースコントロール研究で OR が標準指標となっている理論的根拠 (Cornfield 1951)。

Q7: Simpson's paradox とは何か、 OR の文脈で説明せよ。

全体で OR > 1 (正の関連) なのに、 層別すると各層で OR < 1 (負の関連) になる現象。 第 3 因子 (交絡因子) が暴露と結果の両方に関連していると発生する。 防ぐには 層別解析 (Mantel-Haenszel 法) または多変量ロジスティック回帰で交絡を調整する。 例: 大学院入学率の性別差データ (Berkeley 1973) ── 全体では男性合格率が高いが、 学科別では女性が高い。 「🧮 層別解析で Simpson's paradox を検出する」セクション参照。

Q8: OR の 95% CI が 1 を含む / 含まない時、 それぞれ何を意味するか?

1 を含まない: 帰無仮説 OR = 1 を有意水準 5% で棄却 → 統計的に有意な関連あり。 1 を含む: 帰無仮説を棄却できない → 「関連がない」ではなく「あるかないか今のデータでは判定できない」。 サンプルサイズを増やせば狭い CI が得られる可能性がある。 P 値だけでなく CI 幅を見るのが正しい報告様式。

Q9: 「個人レベルで喫煙のリスクは OR = 10」と県別データから言えるか?

言えない。 県別データから計算される OR は 集団 (県) を単位とした関連であり、 個人レベルの因果ではない。 この混同は 生態学的誤謬 (ecological fallacy) と呼ばれる古典的な誤りで、 公衆衛生では最も有名な落とし穴の一つ。 個人レベルのリスクを評価したければ、 個人レベルのコホート研究やケースコントロール研究のデータが必要。 「🧮 SSDSE-B-2026 で実際にオッズ比を計算する」セクションの 💬 読み方を参照。

Q10: 結果が頻発 (発生率 40%) なのに OR を「リスク 6 倍」と説明するのは正しいか?

正しくない。 発生率 40% で OR = 6 のとき、 実際のリスク比 RR は約 2.0 ── OR を「リスク何倍」と読むと 過大評価 (overestimation) になる。 公衆衛生コミュニケーションでは絶対リスク減少 (ARR) や治療必要数 (NNT) を併記するのがゴールドスタンダードとされる。 「⚠️ OR と RR の違い — まれな結果と頻繁な結果の罠」セクション参照。

採点と次のステップ

10 問正解: コンペの推測統計領域でオッズ比は完璧に使える。 logistic 回帰の係数解釈と CI の報告、 layer 解析もすべて押さえている状態。 次は ロジスティック回帰 や 分散分析 など、 関連する推測統計手法に進むとよい。

7-9 問正解: 基本は押さえている。 ただし「OR ≠ RR」「生態学的誤謬」「Simpson's paradox」「0 セル問題」のうち間違えた問題があれば、 対応するセクションに戻ってもう一度読み込むこと。 これらはコンペ・実務両方で頻出する落とし穴。

4-6 問正解: 概念の輪郭は掴めているが運用の細部が抜けている状態。 「📐 オッズ比を深く理解する — 確率 → オッズ → 対数オッズ」と「⚠️ オッズ比の現場での落とし穴」の 2 セクションを集中的に再読推奨。

3 問以下: ページ冒頭の「💡 30 秒で分かる結論」と「🎨 直感で掴む」から再読し、 数式の意味を確実にしてから 🧮 セクションの実値計算を手を動かしてやり直すのが近道。 オッズ比は「ad/bc」という一見シンプルな式の裏に、 確率論・尤度・対数オッズ・最尤推定という統計学の中核概念がすべて詰まっている。 焦らず一段ずつ。

📜 オッズ比の歴史 — Cornfield, Doll, Mantel が築いた疫学の柱

オッズ比という統計量は、 20 世紀後半の疫学 (epidemiology) で爆発的に発展した。 特に 喫煙と肺がんの関係を巡る 1950 年代の論争が、 OR を統計学の主役級指標に押し上げた歴史的契機である。 ここを知っておくと、 OR がなぜケースコントロール研究の標準指標として確立したのか、 なぜ logistic 回帰の係数解釈が exp(β) で OR になるのか ── という現代の慣習が腑に落ちる。

1950 年代 — 喫煙と肺がんの巨大論争

第二次世界大戦後、 イギリスや米国で肺がん死亡率が急増した。 原因として喫煙が疑われていたが、 当時はランダム化臨床試験 (RCT) を「人に喫煙させ続ける」形で行うのは倫理的に不可能。 そこで Richard Doll と Austin Bradford Hill (英) は ケースコントロール研究を実施 ── 肺がん患者 (ケース) と肺がんでない患者 (コントロール) を集め、 過去の喫煙歴を聞き取って比較した (Doll & Hill 1950)。 同時期に米国でも Hammond と Horn が大規模コホート研究を実施。

しかしケースコントロール研究では「全集団の発生率」が分からないため、 RR を直接計算できない。 ここで Jerome Cornfield が 1951 年に 「ケースコントロール研究では RR の代わりに OR を使えばよい」という決定的な論文を発表した。 Cornfield の証明は「結果がレアな時 OR ≈ RR」「両群間で OR は対称的」という 2 つの性質に基づくもので、 これにより OR がケースコントロール研究の標準指標として確立した。

1959 年 — Mantel-Haenszel 推定量

次の大進展は 1959 年。 Nathan Mantel と William Haenszel が 「層別データから単一の共通 OR を推定する公式」を発表 (Mantel & Haenszel 1959)。 この推定量 OR_MH = Σ(a_i d_i / n_i) / Σ(b_i c_i / n_i) は、 層 (年齢・性別・地域など) ごとの 2×2 表をまとめて 1 つの調整済み OR にする画期的な方法だった。 当時はコンピュータがほぼなかったため、 多変量ロジスティック回帰の代わりに 手計算で交絡調整できるこの推定量が広く使われた。 現在でも医療統計の現場では Mantel-Haenszel 推定量が標準ツールとして残っている。

1970 年代 — ロジスティック回帰の確立

1970 年代に入ると David Cox や Norman Breslow らによって 多変量ロジスティック回帰が疫学領域に持ち込まれた。 連続変数も含めた多くの交絡因子を一度に調整でき、 各係数 β を exp(β) すれば調整済み OR が直接得られる ── この枠組みが現代の疫学・社会科学・データサイエンスの標準となった。 Logistic 回帰の係数解釈が OR なのは、 そのリンク関数が logit (= log of odds) だからであり、 つまり「OR を線形モデルで推定するための関数」がロジスティック回帰そのものなのである。

2000 年代以降 — 機械学習との接続

21 世紀に入ると機械学習が興隆し、 ロジスティック回帰は機械学習における二値分類の最古のベースラインモデルとなった。 ニューラルネットワークの最終層も logit (シグモイド前の値) を出力するという意味で、 オッズ比の概念は深層学習にまで通底している。 現代のデータサイエンティストにとって OR は「疫学の遺物」ではなく 「機械学習モデルの内部で常に流通している量」として理解しておくべき概念である。

歴史から学ぶべき 4 つの教訓

  1. OR は RR の代用品として生まれた ── 観察研究で RR が計算できない状況での実用的選択。 だから RR を計算できる場面では RR を報告するのが原則。
  2. OR の対称性は数学的偶然ではない ── ad/bc は分母分子を入れ替えても同じ構造なので、 結果起点のサンプリングでも母集団 OR と一致する。 これが疫学で OR が生き残った最大の理由。
  3. Mantel-Haenszel は今でも有効 ── 小サンプルや層が少ない時はロジスティック回帰より安定。 ベイズや Firth と並ぶ「サンプル数が少ない時の頼れる道具」として現役。
  4. logit リンクの普遍性 ── 二値分類の確率を線形モデルで扱う最自然な変換が logit。 これが深層学習・GLM・GAM すべてで使われている理由であり、 オッズ比を理解する = 二値分類の数理を理解することに等しい。

📋 コンペ実戦コードブック — オッズ比に関する 10 のレシピ

コンペや実務で「オッズ比を出して」と言われた時、 すぐに使える 10 個の Python レシピを 1 ページに集約した。 単に動くだけでなく、 どんな場面で使い、 結果のどこを見るかを解説付きで示している。 全部一度に通読しなくても、 必要になったブロックだけコピペして使える設計。

レシピ 1 — 基本: 2×2 表からの OR と CI 一気出し

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import numpy as np
from scipy.stats import fisher_exact

table = np.array([[18, 6], [5, 18]])  # [[a, b], [c, d]]
a, b, c, d = table.flatten()
OR = (a * d) / (b * c)
log_OR = np.log(OR)
SE = np.sqrt(1/a + 1/b + 1/c + 1/d)
CI_low, CI_high = np.exp(log_OR - 1.96 * SE), np.exp(log_OR + 1.96 * SE)
_, p_fisher = fisher_exact(table)
print(f'OR = {OR:.3f}, 95% CI = [{CI_low:.2f}, {CI_high:.2f}], Fisher P = {p_fisher:.4f}')

📤 出力例: OR = 10.800, 95% CI = [2.79, 41.86], Fisher P = 0.0004

💬 ポイント: 1 ブロックで OR・CI・P 値の 3 点セットが得られる。 これがレポートの基本形。 セル度数が小さい (5 未満) があれば Fisher P を、 すべて 5 以上なら χ² 検定の P 値で報告する。

レシピ 2 — 0 セル対策: Haldane 補正

1
2
3
4
5
table_with_zero = np.array([[15, 0], [5, 20]])
a, b, c, d = (table_with_zero + 0.5).flatten()
OR_corrected = (a * d) / (b * c)
SE_corrected = np.sqrt(1/a + 1/b + 1/c + 1/d)
print(f'Haldane 補正後 OR = {OR_corrected:.2f}, log SE = {SE_corrected:.3f}')

📤 出力例: Haldane 補正後 OR = 115.55, log SE = 1.515

💬 ポイント: 0 セルがあると ad/bc が 0 または無限大になる。 各セルに 0.5 を足すと最小限の補正で計算可能になる。 ただし OR が極端に大きくなることがあるので、 補正後の CI 幅も併記すること。 より洗練された方法は Firth's penalized logistic 回帰。

レシピ 3 — Mantel-Haenszel 共通 OR

1
2
3
4
5
6
7
from statsmodels.stats.contingency_tables import StratifiedTable

tables = [np.array([[10, 5], [3, 12]]), np.array([[20, 10], [8, 22]])]
strat = StratifiedTable(tables)
print(f'MH 共通 OR = {strat.oddsratio_pooled:.3f}')
print(f'95% CI = {strat.oddsratio_pooled_confint()}')
print(f'同質性検定 P = {strat.test_equal_odds().pvalue:.3f}')

📤 出力例: MH 共通 OR = 6.182, 95% CI = (2.46, 15.52), 同質性検定 P = 0.713

💬 ポイント: 層別データから単一の調整済み OR を出すには StratifiedTable。 同質性検定が有意 (P<0.05) なら層間で OR が違うので共通 OR を報告するのは不適切 ── その時は層別 OR を並べて記述する。

レシピ 4 — ロジスティック回帰 (statsmodels)

📥 入力例(SSDSE-B-2026 の 2023 年・47 都道府県から 3 行) 都道府県 A1101(総人口) A1303(65歳以上人口) I510120(一般病院数) 北海道 5,092,000 1,681,000 464 東京都 14,086,000 3,205,000 588 沖縄県 1,468,000 350,000 76 …(全 47 行)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import pandas as pd
import statsmodels.api as sm

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', skiprows=[1], encoding='cp932')
df = df[df['SSDSE-B-2026'] == 2023].copy()
df['高齢化率'] = df['A1303'] / df['A1101'] * 100
df['病院密度'] = df['I510120'] / df['A1101'] * 100000
df['y'] = (df['病院密度'] > df['病院密度'].median()).astype(int)

X = sm.add_constant(df[['高齢化率']])
model = sm.Logit(df['y'], X).fit(disp=False)
print(model.summary())
print('調整済み OR:')
print(np.exp(model.params))
print(np.exp(model.conf_int()))

📤 出力例: const OR≈0.000, 高齢化率 OR=1.42 (高齢化率 1% 増加で OR が 1.42 倍)。

💬 ポイント: np.exp(model.params) で係数 β を OR に変換、 np.exp(model.conf_int()) で CI を OR スケールに変換する。 連続変数の OR は単位次第なので、 「10% 増加あたり」にしたい場合は df['高齢化率10'] = df['高齢化率']/10 としてから回帰。

レシピ 5 — sklearn でロジスティック回帰 + OR 抽出

1
2
3
4
5
6
7
8
from sklearn.linear_model import LogisticRegression

X = df[['高齢化率']].values
y = df['y'].values
clf = LogisticRegression(penalty=None, solver='newton-cg').fit(X, y)
beta = clf.coef_[0][0]
OR = np.exp(beta)
print(f'β = {beta:.4f}, OR = {OR:.3f}')

📤 出力例: β = 0.3504, OR = 1.420 (レシピ 4 の statsmodels の 1.42 とほぼ同じ)

💬 ポイント: sklearn は CI を直接出さないが、 速度と pipeline 統合性で有利。 CI が必要なら statsmodels の方が便利。 sklearn の penalty='l2' (デフォルト) だと L2 正則化で OR が縮小推定されることに注意 ── 純粋な MLE と一致させたければ penalty=None。

レシピ 6 — クロス集計表の自動作成 (pd.crosstab)

1
2
3
4
5
6
df['高齢化2値'] = (df['高齢化率'] > df['高齢化率'].median()).astype(int)
df['病院密度2値'] = df['y']
ct = pd.crosstab(df['高齢化2値'], df['病院密度2値'])
print(ct)
OR = (ct.iat[1,1] * ct.iat[0,0]) / (ct.iat[1,0] * ct.iat[0,1])
print(f'OR = {OR:.3f}')

📤 出力例: クロス表 (行 0: 17, 7 / 行 1: 7, 16) + OR = 5.551

💬 ポイント: pd.crosstab で 2×2 表を一発生成 → .iat[行, 列] でセル取り出し → OR 計算。 シンプルで間違いにくいパターン。 行・列の順序を間違えると OR が逆数になるので、 必ず crosstab の出力を print して目視確認すること。

レシピ 7 — Fisher の正確検定 (小サンプル)

1
2
3
4
5
from scipy.stats import fisher_exact

table = np.array([[3, 1], [0, 5]])
odds_ratio, p_value = fisher_exact(table)
print(f'scipy OR = {odds_ratio:.3f} (条件付き OR), P = {p_value:.4f}')

📤 出力例: scipy OR = inf, P = 0.0476

💬 ポイント: scipy.stats.fisher_exact が返す OR は 条件付き最尤推定値 (cMLE) であり、 単純な ad/bc とは値が違う場合がある。 セル度数が極端 (0 を含む) だと cMLE は無限大になる。 0 を含む時の OR と P の解釈には特に注意。

レシピ 8 — Firth's penalized logistic (0 セル対応)

1
2
3
4
5
6
7
8
9
# R の logistf パッケージ相当を Python で。
# statsmodels の GLM は Firth を直接サポートしないため、
# firthlogist パッケージなどを利用 (pip install firthlogist)。
from firthlogist import FirthLogisticRegression

X_zero = np.array([[1]*15 + [0]*5 + [1]*0 + [0]*20]).T.astype(float)
y_zero = np.array([1]*15 + [1]*5 + [0]*0 + [0]*20)
flr = FirthLogisticRegression().fit(X_zero, y_zero)
print(f'Firth β = {flr.coef_[0]:.4f}, Firth OR = {np.exp(flr.coef_[0]):.3f}')

📤 出力例: Firth β = 4.7497, Firth OR = 115.545 (firthlogist が入らない環境のため、 同じ Firth の罰則付き尤度を numpy で解いて確かめた値。 2 値の説明変数 1 本 + 切片のモデルでは、 Firth の推定はレシピ 2 の Haldane 補正 (+0.5) と同じ OR になる)。

💬 ポイント: 0 セルや完全分離 (perfect separation) で通常の MLE が破綻する時、 Firth's penalized likelihood は 有限の OR 推定値を返す。 医療統計の小サンプル研究で標準的に使われる手法。

レシピ 9 — Bayesian ロジスティック回帰 (PyMC)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import pymc as pm
import numpy as np

with pm.Model() as model:
    beta = pm.Normal('beta', 0, 10)
    alpha = pm.Normal('alpha', 0, 10)
    p = pm.math.sigmoid(alpha + beta * df['高齢化率'].values)
    y_obs = pm.Bernoulli('y', p=p, observed=df['y'].values)
    trace = pm.sample(2000, tune=1000, chains=4, progressbar=False)

OR_samples = np.exp(trace.posterior['beta'].values.flatten())
print(f'OR 事後中央値 = {np.median(OR_samples):.3f}')
print(f'95% 信用区間 = [{np.percentile(OR_samples, 2.5):.2f}, {np.percentile(OR_samples, 97.5):.2f}]')

📤 出力例: OR 事後中央値 = 1.385, 95% 信用区間 = [1.14, 1.77] (乱数の seed を固定していないので実行ごとに小数第 2〜3 位が変わる)

💬 ポイント: ベイズでは OR の事後分布全体が得られるので、 「OR が 1.5 以上である確率は何 %」のような直感的な確率言明が可能。 小サンプル・事前情報がある時に強力。

レシピ 10 — OR を直感的に解釈するヘルパー関数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def interpret_OR(OR, baseline_risk):
    """OR を RR と ARR に変換して直感的に解釈"""
    p0 = baseline_risk
    odds0 = p0 / (1 - p0)
    odds1 = OR * odds0
    p1 = odds1 / (1 + odds1)
    RR = p1 / p0
    ARR = p1 - p0
    NNT = 1 / abs(ARR) if ARR != 0 else float('inf')
    print(f'OR = {OR}, 基線リスク p0 = {p0*100:.1f}%')
    print(f'→ 暴露群リスク p1 = {p1*100:.1f}%')
    print(f'→ リスク比 RR = {RR:.2f}')
    print(f'→ 絶対リスク差 ARR = {ARR*100:+.1f} ポイント')
    print(f'→ NNT (1 例予防に必要な人数) = {NNT:.1f}')

interpret_OR(OR=6, baseline_risk=0.4)
interpret_OR(OR=6, baseline_risk=0.02)

📤 出力例: 基線 40% の時 RR=2.00 (OR が誇張)、 基線 2% の時 RR=5.45 (OR に近づく)。

💬 ポイント: OR をそのまま「N 倍危険」と読むと過大評価になる。 この関数で 基線リスクごとの実リスク差を計算すれば、 患者・読者にも伝わる説明ができる。 公衆衛生コミュニケーションの基本ツール。

✅ オッズ比レポート品質チェックリスト — コンペ提出前に必ず確認

コンペや論文・実務レポートで OR を報告する際、 最低限満たすべき品質基準をチェックリスト形式でまとめた。 7 項目すべてにチェックが入って初めて「査読に耐える OR の報告」と言える。 1 つでも欠けると評価者から指摘されるか、 最悪は結論そのものを撤回せざるを得ない事態になりうる ── 必ず提出前に通読すること。

このチェックリストは、 本ページ全体の要点を「提出時に最後に見るもの」として 1 画面に圧縮したもの。 ここに書いた 7 項目は、 すべて本ページ内のいずれかのセクションで詳しく解説しているので、 1 つでも自信がない項目があれば該当セクションに戻って再確認すること。 オッズ比は 「単純な公式 ad/bc の裏に、 統計学・疫学・機械学習の中核概念が全部詰まっている」指標であり、 だからこそ報告の質がそのまま分析者の質を表す。 コンペで差をつけたければ、 このチェックリストを毎回必ず通すべし。

🧮 数式に値を入れて手で計算する: オッズ比

合成 2x2 表でオッズ比を計算する。

Step 1: 表

 疾患+疾患-
暴露+3070
暴露-1090

Step 2: オッズ比

OR = (30·90)/(70·10) = 2700/700 ≈ 3.857 暴露あり群は疾患リスクが約 3.86 倍

🐍 Python で再現

1
2
3
a, b, c, d = 30, 70, 10, 90
OR = (a * d) / (b * c)
print(f"OR = {OR:.3f}")

📤 実行結果

OR = 3.857

💬 手計算 (Step 2) 3.86 と Python 出力が完全一致。

🐍 Python 実装 — 完全強化版

scipy / pandas / scikit-learn / statsmodels を中心とした標準的な実装例です。 まず CSV を読み込み、 次に オッズ比 の解析を行います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import pandas as pd
import numpy as np
from scipy import stats

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', skiprows=[1], encoding='cp932')
df = df[df['SSDSE-B-2026'] == df['SSDSE-B-2026'].max()].copy()   # 2023 年・47 都道府県

aging = df['A1303'] / df['A1101']                   # 高齢化率
hosp = df['I510120'] / df['A1101'] * 100_000        # 人口 10 万人あたり一般病院数
print('n =', len(df))

# 中央値以上を 1 とする 2 値化(ステップ 1 の事前定義どおり)
X = (aging >= aging.median()).astype(int)           # 暴露: 高齢化率が高い
Y = (hosp >= hosp.median()).astype(int)             # 結果: 病院密度が高い
tab = pd.crosstab(X.rename('高齢化率高'), Y.rename('病院密度高')).loc[[1, 0], [1, 0]]         # 行: 高齢化 高/低, 列: 病院密度 高/低
print(tab)

# オッズ比の代表的計算: OR = (a·d)/(b·c) と Wald 95% CI、Fisher 正確検定
a, b = tab.iloc[0]
c, d = tab.iloc[1]
OR = (a * d) / (b * c)
se = np.sqrt(1/a + 1/b + 1/c + 1/d)                 # log OR の標準誤差
lo, hi = np.exp(np.log(OR) + np.array([-1.96, 1.96]) * se)
_, p = stats.fisher_exact(tab.values)
print(f'OR = ({a}×{d})/({b}×{c}) = {OR:.3f}')
print(f'95% CI = [{lo:.2f}, {hi:.2f}],  Fisher P = {p:.4f}')
📤 実行例(実測) n = 47 病院密度高 1 0 高齢化率高 1 17 7 0 7 16 OR = (17×16)/(7×7) = 5.551 95% CI = [1.59, 19.38], Fisher P = 0.0087

💬 高齢化率が中央値以上の 24 県のうち 17 県が病院密度も中央値以上で、中央値未満の 23 県では 7 県にとどまる。OR = (17×16)/(7×7) = 5.551 で、ステップ 3 の手計算 272/49 = 5.55 と一致し、95% CI [1.59, 19.38] も 1 をまたがず、Fisher P = 0.0087 で有意。ただし区間の上限が下限の 12 倍ほどあるのは 47 県・各セル 7〜17 と小さいためで、総人口で調整すると P = 0.27 まで弱まる(ステップ 4)ので、この 5.55 は交絡を含んだ粗 OR として読む。

用途別の追加実装:

1
2
3
4
5
6
7
8
9
# 標準化と簡易クラスタリングの例
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans

X = df[['I510120', 'A4101']].astype(float).values
Xs = StandardScaler().fit_transform(X)
km = KMeans(n_clusters=4, n_init=10, random_state=0).fit(Xs)
df['cluster'] = km.labels_
print(df[['Prefecture', 'I510120', 'A4101', 'cluster']].head(10))
📤 実行例(実測) Prefecture I510120 A4101 cluster 0 北海道 464 24430 2 12 青森県 72 5696 3 24 岩手県 76 5432 3 36 宮城県 108 12328 3 48 秋田県 48 3611 3 60 山形県 52 5151 3 72 福島県 99 9019 3 84 茨城県 152 14898 1 96 栃木県 89 9958 3 108 群馬県 114 9950 3

💬 一般病院数と出生数(相関 0.895)を z 化して k=4 に分けると、東京都が単独、北海道・埼玉・千葉・神奈川・愛知・大阪・兵庫・福岡の 8 道府県、茨城・静岡・京都・岡山・広島・熊本・鹿児島の 7 府県、残り 31 県に分かれた。表示された先頭 10 行でも北海道(病院 464)と茨城県(152)だけが別の番号で、他の東北・北関東の県は同じ 3 番に入る。どちらも実数なので、分かれ方は主に県の規模で決まっており、OR で使う率(10 万人あたり)で分けた場合とは別物になる。

1
2
3
4
5
6
7
8
9
10
11
# 時系列(北海道の I510120)— 例として ARIMA 系の前処理
import pandas as pd
import statsmodels.api as sm

full = pd.read_csv('data/raw/SSDSE-B-2026.csv', skiprows=[1], encoding='cp932')
ts = (full[full['Prefecture'] == '北海道']
      .sort_values('SSDSE-B-2026')          # CSV は新しい年度が先なので昇順に並べ直す
      .set_index('SSDSE-B-2026')['I510120'])  # 2012〜2023 年の 12 点
print(ts.tail())
res = sm.tsa.stattools.adfuller(ts, maxlag=1)   # 12 点しかないのでラグは 1 まで
print('ADF stat:', round(res[0], 3), 'p:', round(res[1], 4))
📤 実行例(実測) SSDSE-B-2026 2019 484 2020 479 2021 471 2022 465 2023 464 Name: I510120, dtype: int64 ADF stat: 0.855 p: 0.9925

💬 北海道の一般病院数は 2012 年の 504 から 2023 年の 464 まで 12 年で 40 減り、直近 5 年も 484 → 464 と毎年減っている。ADF 統計量 0.855・p 値 0.9925 で単位根を棄却できず、減少トレンドをもつ非定常な系列とみなせるので、ARIMA に当てはめるなら差分をとってからにする。12 点しかないので検定の力は弱い。2023 年だけに絞ったデータで同じことをすると系列が 1 点になり、adfuller は「x is constant」で止まる。

⚠️ 落とし穴 — 完全強化版

オッズ比 を実務で扱う際に踏みやすい落とし穴を 5 件挙げます。

⚠️ オッズ比の現場での落とし穴 — Deep FAQ

よくある誤解 / 罠なぜ起こるか対処法
OR を RR と混同して解釈する短い記号で「比」だから同じに見える結果頻度が 10% 以上なら必ず RR/RD も報告する
2×2 表に 0 セルが入って OR が 0 or ∞小標本やまれな事象Haldane–Anscombe 補正 (全セルに 0.5 を足す) or Fisher 検定
「OR=5 だから因果関係が強い」と結論関連 ≠ 因果の混同交絡調整 (層別 or ロジスティック) + 因果ダイアグラム検討
ケースコントロール研究で RR を計算しようとする分母が「リスク集団」でないためケースコントロールでは OR のみ意味あり (RR は推定不能)
マッチドペアで通常のロジスティックを使う独立性の仮定が崩れる条件付きロジスティック (clogit) や McNemar 検定
調整オッズ比と粗オッズ比の符号が逆転Simpson's paradox / 交絡の方向DAG を描き、 collider を adjustment set に入れていないか確認
CI が無限大に近い・極めて広いセル度数が極小 (n < 10)標本拡張 or Firth's logistic (penalized MLE)
複数比較で OR を多数並列にチェックα インフレ → 偽陽性Bonferroni 補正 or False Discovery Rate (BH)

⚠️ 0 セル問題と Firth's penalized logistic

2×2 表に 0 セルがあると OR は 0 または ∞ になり、 ロジスティック回帰も「完全分離 (complete separation)」で MLE が発散する。 古典的対処は Haldane–Anscombe 補正 (全セル +0.5)、 現代的なベストプラクティスは Firth's penalized likelihood (Jeffrey's prior を加えてバイアス除去 + 有限推定を保証)。

手法どんなとき特徴
通常の Logit MLEn ≥ 100、 全セル ≥ 10標準・教科書的
Haldane–Anscombe 補正2×2 表に 0 セルあり、 単純解析全セルに 0.5 を足す、 簡便
Fisher 正確検定n < 30、 期待度数 < 5P 値は正確、 OR の CI は条件付き
Firth Logit小標本・rare event・separation推定常に有限、 バイアス O(1/n)、 多変量対応
Bayesian Logistic (weak prior)事前分布で正則化したいPyMC や rstanarm、 CI が credible interval
Exact Logistic (LogXact)極小標本、 完全分離条件付き尤度を完全列挙、 計算コスト大

📜 オッズ比の歴史と公衆衛生での位置

年出来事意義
1900sKarl Pearson が χ² 検定を考案2×2 表解析の出発点
1935Fisher が正確検定を発表 (Lady Tasting Tea)小標本でも厳密な P 値
1951Doll & Hill の喫煙・肺がんケースコントロール研究疫学で OR が定着、 OR ≈ 14
1959Mantel-Haenszel 法層別解析でプール OR を計算
1972Cox の比例ハザード回帰時間軸を含む拡張 (HR)
1993Firth's penalized likelihood完全分離問題の決定打
2000s〜因果推論の隆盛 (potential outcomes)関連の OR から因果 OR へ

💬 OR は 1950 年の Doll-Hill による喫煙・肺がん研究で疫学の主役に躍り出た。 ケースコントロール研究では暴露率 (= 結果がもう起きた人の中での暴露頻度) しか測れないため、 RR は推定できず OR が唯一の選択肢になる ── これが OR が公衆衛生のデフォルト指標になった歴史的理由。

📋 オッズ比 — 実務早見表

状況推奨アプローチPython 1 行
2×2 表、 大標本手計算 ad/bc + log CIstats.contingency.odds_ratio(tab)
2×2 表、 小標本 / 0 セルFisher 正確検定stats.fisher_exact(tab)
多変量で交絡調整ロジスティック回帰smf.logit('y~x1+x2', data=df).fit()
層別データ (年齢階級など)Mantel-HaenszelStratifiedTable(tables).oddsratio_pooled
マッチドペアMcNemar 検定 + 条件付き ORstats.contingency.mcnemar(tab, exact=True)
完全分離 (separation)Firth penalized logitfirthlogist.FirthLogisticRegression()
階層構造あり (病院内患者)混合効果ロジスティックsmf.mixedlm(...).fit() or PyMC
直接「確率言明」したいベイズロジスティックPyMC で pm.Bernoulli + pm.sample()

💬 実務では「2×2 表 → Fisher → ロジスティック調整 → 必要に応じ Firth/ベイズ」という階段で詰めるのが定石。 OR は「効果サイズ」と「統計的有意性」を一度に提示できる便利な指標だが、 RR/RD で補完すること、 結果頻度が高いときは OR を強調しすぎないことが現場での礼儀。

🗺 概念マップ — 完全強化版

odds ratio OR (オッズ比) RR (リスク比) RD (リスク差) NNT HR (ハザード比) IRR (発生率比)

🔗 隣接手法への橋渡し

「オッズ比」は単独で完結せず、 前後の手法と組み合わさって価値が発揮される。 入力データの準備 (上流)・同目的の代替手法との比較 (並列)・結果の活用 (下流) という 3 軸で隣接領域を整理する。

この上流・並列・下流の対応を地図化することで、 「オッズ比」を中核に据えた分析パイプライン (データ準備 → 手法選択 → 結果の検証と展開) の全体像が見えてくる。

🌳 手法選択フロー

「オッズ比」を実際に使うとき、 何をどう選ぶかを順に判断する。 上から順に答えていくと、 使うべき手法と評価の仕方が決まる。

  1. 研究デザインは何か
    ケースコントロール研究ではリスク比を推定できないので、 オッズ比を使う。 コホートや介入研究でリスクが直接測れるなら、 解釈しやすいリスク比を報告する。
  2. 事象はまれか
    まれ(数 % 以下)なら、 オッズ比はリスク比の近似になる。 よく起きる事象では両者が大きく離れ、 オッズ比を「何倍起きやすい」と読むと誇張になる。
  3. 調整が要るか
    交絡がある場合、 単純な 2×2 表のオッズ比は歪む。 ロジスティック回帰に交絡変数を入れれば、 調整済みオッズ比が係数の指数として得られる。
  4. 信頼区間を付けたか
    オッズ比は比なので分布が非対称。 対数を取ってから区間を作り、 指数で戻す。 区間が 1 をまたぐかどうかで判断する。

オッズ比 1 は「差が無い」。 2 は「オッズが 2 倍」であって「確率が 2 倍」ではない。 この読み替えの誤りが、 報道でも論文でも最も多い。

🧮 SSDSE-B-2026 で実際にオッズ比を計算する (高齢化率 × 病院密度)

都道府県データで「高齢化率が高い県は、 人口あたりの一般病院数も多いか?」をオッズ比で検証する。 高齢化率 = A1303 (65 歳以上人口) / A1101 (総人口)、 病院密度 = I510120 (一般病院数) / A1101 × 100000 とし、 47 都道府県をそれぞれ中央値で二分する。

🎯 このコードでやること: SSDSE-B-2026 から 2023 年データを抽出し、 高齢化率と人口 10 万人あたり一般病院数を計算、 中央値で 2 値化して 2×2 表を構築、 オッズ比と 95% CI、 Fisher の正確検定 P 値を一度に求める。

📥 入力データ (SSDSE-B-2026 抜粋, 2023 年):

Prefecture A1101(総人口) A1303(65+) I510120(病院数) 高齢化率 病院/10万人 北海道 5,092,000 1,681,000 464 0.3301 9.11 青森県 1,184,000 417,000 72 0.3522 6.08 東京都 14,086,000 3,205,000 588 0.2275 4.17 ← 高齢化低・病院少 高知県 666,000 242,000 107 0.3634 16.07 ← 高齢化高・病院多 神奈川県 9,229,000 2,390,000 289 0.2590 3.13 ← 高齢化低・病院少 median — — — 0.3178 5.99 (47県の中央値)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import pandas as pd
import numpy as np
from scipy import stats

df = pd.read_csv('data/raw/SSDSE-B-2026.csv', encoding='cp932', skiprows=[1])
df = df[df['SSDSE-B-2026'] == 2023].copy()

# 高齢化率と人口 10 万人あたり病院数を作る
df['aging'] = df['A1303'] / df['A1101']
df['hospital_per_100k'] = df['I510120'] / df['A1101'] * 100000

# 中央値で二値化
df['high_aging']    = (df['aging']             > df['aging'].median()).astype(int)
df['high_hospital'] = (df['hospital_per_100k'] > df['hospital_per_100k'].median()).astype(int)

tab = pd.crosstab(df['high_aging'], df['high_hospital'])
print(tab)

a, b, c, d = tab.loc[1,1], tab.loc[1,0], tab.loc[0,1], tab.loc[0,0]
or_ = (a*d) / (b*c)
se  = np.sqrt(1/a + 1/b + 1/c + 1/d)
fish_or, pval = stats.fisher_exact(tab.values)
print(f'OR={or_:.4f}, 95% CI=[{np.exp(np.log(or_)-1.96*se):.3f}, {np.exp(np.log(or_)+1.96*se):.3f}], Fisher p={pval:.4f}')

📤 実行結果 (実際に走らせた出力):

high_hospital 0 1 high_aging 0 17 7 1 7 16 OR=5.5510, 95% CI=[1.590, 19.384], Fisher p=0.0087

💬 読み方: OR = 5.55 は 高齢化率が高い県では、 人口あたり一般病院数も多いオッズが 5.55 倍であることを示す。 95% CI が [1.59, 19.38] と 1 を含まないので、 P=0.0087 (Fisher) で 5% 水準で有意 ── 高齢化と病院供給は連動している (生態学的相関)。 ただし CI 幅が広いのは 47 県という小標本のため。 また「県単位」での話なので、 個人レベルで「歳をとった人は近所に病院が増える」とは限らない ── 古典的な ecological fallacy。

🐍 ロジスティック回帰で「調整済みオッズ比」を求める

単純な 2×2 OR は交絡因子を無視している。 上の例では「総人口」が交絡している可能性 (人口が多い県ほど病院も多い、 一方で人口が多い県ほど若い)。 ロジスティック回帰で総人口を投入して調整すれば、 「総人口を一定としたときの高齢化率の効果」が出る。

🎯 このコードでやること: 結果変数 = 高病院密度ダミー、 説明変数 = 高齢化率 (連続) + 総人口 (連続) で statsmodels の Logit を推定し、 係数を exp して調整済み OR と 95% CI を出す。

📥 入力データ (前ブロックの df を継続使用):

y = high_hospital (0/1) X = aging (連続値 0.23〜0.39), A1101 (総人口、 537,000〜14,086,000) n = 47 都道府県
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import statsmodels.api as sm

# 説明変数 = aging (10% きざみで読みやすく), log(総人口)
df['aging_10pct'] = df['aging'] * 10           # 1 単位 = 10% 増
df['log_pop']     = np.log(df['A1101'])

X = sm.add_constant(df[['aging_10pct', 'log_pop']])
y = df['high_hospital']

model = sm.Logit(y, X).fit(disp=0)
out = pd.DataFrame({'coef': model.params, 'OR': np.exp(model.params),
                    'CI_low': np.exp(model.conf_int()[0]),
                    'CI_high': np.exp(model.conf_int()[1]),
                    'p': model.pvalues})
print(out.round(4))

📤 実行結果 (実測):

coef OR CI_low CI_high p coef OR CI_low CI_high p const 12.2469 208330.0048 0.0000 2.667630e+16 0.3480 aging_10pct 1.6680 5.3014 0.2724 1.031580e+02 0.2707 log_pop -1.2261 0.2934 0.0764 1.127400e+00 0.0742

💬 読み方: 高齢化率を 10% きざみで見ると調整 OR=5.30 だが、 P=0.27 で有意ではなく、 95% CI [0.27, 103.2] も極端に広い。 つまり単純 OR=5.55 は総人口 (log) を調整すると有意性を失う ── 高齢化率と総人口が強く相関する (小規模県ほど高齢化率が高い) ため、 47 県の小標本で両者を同時に入れると推定が不安定になる。 log_pop の OR=0.293 (P=0.07) は「人口が多い県ほど人口あたり病院数は少ない」傾向を示すが、 これも有意ではない。 粗 OR の関連は交絡と標本サイズに敏感で、 頑健とは言えない。

⚠️ OR と RR の違い — まれな結果と頻繁な結果の罠

オッズ比 (OR) はリスク比 (RR、 = p1/p2) と「結果がまれなとき」だけほぼ等しい。 結果頻度が高くなると OR は RR より外側に張り出す。 ジャーナリストや臨床家はしばしば OR を RR のように語って誇張する。

結果頻度 (対照群 p2)処理群 p1RR = p1/p2OR = (p1/(1-p1))/(p2/(1-p2))OR / RR の差
0.01 (まれ)0.022.002.02+1%
0.100.202.002.25+12.5%
0.300.602.003.50+75%
0.40 (普通)0.802.006.00+200%

💬 たとえば「投薬で副作用リスクが OR=6 倍」と言われると怖いが、 元のリスクが 40% なら実際のリスク比はわずか 2 倍に過ぎない。 公衆衛生コミュニケーションでは RR (または絶対リスク減少 ARR) を併記するのがゴールドスタンダード。

🎯 このコードでやること: OR と RR の乖離を可視化するため、 ①で作った高齢化率 × 病院密度の 2×2 表 (17, 7, 7, 16) から OR と RR を両方計算する。

📥 入力データ:

df は前ブロックと同じ (SSDSE-B-2026 の 2023 年抽出) 高齢化率 = A1303 / A1101、 病院密度 = I510120 / A1101 を中央値で二値化 (「病院多」が約半数という頻繁な結果のシナリオ)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 2x2 表: 行=高齢化高低 (頻繁な結果)、 列=病院密度高低 (処置代用)
a,b,c,d = 17, 7, 7, 16

p1 = a/(a+b)            # 高齢化高 群での「病院多」確率 = 17/24 = 0.708
p2 = c/(c+d)            # 高齢化低 群での「病院多」確率 =  7/23 = 0.304
OR = (a*d)/(b*c)
RR = p1/p2
RD = p1 - p2           # Risk Difference
NNT = 1/RD                # Number Needed to Treat (絶対リスク差の逆数)
print(f'p1={p1:.3f}, p2={p2:.3f}, OR={OR:.2f}, RR={RR:.2f}, RD={RD:.3f}, NNT={NNT:.1f}')

📤 実行結果:

p1=0.708, p2=0.304, OR=5.55, RR=2.33, RD=0.404, NNT=2.5

💬 読み方: 同じデータでも OR=5.55 と RR=2.33 で 2.4 倍も違って見える ── 結果頻度が 30〜70% と高いから。 公衆衛生メッセージとしては「高齢化県では病院多の確率が 30% → 71% (絶対 40 ポイント増)」と RD/NNT で伝える方が正確。 OR=5.55 と言うのはミスリーディングのリスクが高い。

🧮 層別解析で Simpson's paradox を検出する

都道府県データで「総合的な OR」と「人口規模で層別した OR」が食い違う場合、 人口規模が交絡していることを示す。 これが古典的な Simpson's paradox: 全体では効果ありに見えるが、 部分集団に分けると効果が消えたり逆転したりする。

🎯 このコードでやること: 47 都道府県を「人口下位 24 県 (層0)」「上位 23 県 (層1)」で層別し、 各層内で別々に高齢化 × 病院密度の OR を計算する。 層内 OR がそろっていれば人口は交絡因子ではない (Mantel-Haenszel 法でプール可)、 食い違えば効果修飾 (interaction) ありと判断する。

📥 入力データ:

df (2023, n=47) を A1101 (総人口) の中央値で層別 上位層: 神奈川県、 大阪府、 愛知県、 埼玉県、 千葉県、 兵庫県、 北海道、 福岡県 … 下位層: 鳥取県、 島根県、 高知県、 福井県、 徳島県、 山形県、 佐賀県、 秋田県 …
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 人口で 2 層に分けて、 各層内で 2x2 表 → OR を計算
df['pop_stratum'] = (df['A1101'] > df['A1101'].median()).astype(int)

for s in [0, 1]:
    sub = df[df['pop_stratum']==s]
    tab = pd.crosstab(sub['high_aging'], sub['high_hospital'])
    a,b,c,d = tab.iloc[1,1], tab.iloc[1,0], tab.iloc[0,1], tab.iloc[0,0]
    # Haldane–Anscombe 補正 (0 セル対策)
    a,b,c,d = a+0.5, b+0.5, c+0.5, d+0.5
    or_s = (a*d)/(b*c)
    print(f'層{s} (n={len(sub)}): OR={or_s:.3f}')

# Mantel-Haenszel プール OR (statsmodels)
from statsmodels.stats.contingency_tables import StratifiedTable
tables = [pd.crosstab(df[df['pop_stratum']==s]['high_aging'],
                       df[df['pop_stratum']==s]['high_hospital']).values for s in [0,1]]
st = StratifiedTable(tables); print(f'MH common OR={st.oddsratio_pooled:.3f}, test of homogeneity p={st.test_equal_odds().pvalue:.3f}')

📤 実行結果 (実測):

層0 (n=24): OR=1.790 層1 (n=23): OR=3.163 MH common OR=2.336, test of homogeneity p=0.670 【層別 OR: 層0 = 1.79 vs 層1 = 3.16】← 層で差はあるが、homogeneity の p=0.670 なので 「層ごとに効果が違う」とまでは言えない。 MH 共通 OR 2.336 を代表値として報告してよい。

💬 読み方: 全体 OR 5.55 に対して、 人口で層別すると層内 OR は人口下位層 1.79、 上位層 3.16 (どちらも各セルに 0.5 を足した値) に下がる。 同質性検定は p=0.670 で層間の差は検出されず、 MH 共通 OR は 2.336 と粗 OR の半分以下になる。 人口規模が高齢化率と病院密度の両方に関わる交絡因子として粗 OR を押し上げていたと読める。 ただし各層の 2×2 表には度数 2〜4 のセルがあり (上位層の「高齢化高・病院多」は 2 県だけ)、 層内 OR の区間は非常に広いので、 1.79 と 3.16 の違いを効果修飾の証拠と読むのも早い。

🐍 Bayesian Logistic で「オッズ比の事後分布」を出す

頻度主義のロジスティック回帰は「点推定 + 95% CI」を返すが、 ベイズ流ならオッズ比の事後分布が直接得られる。 「OR が 2 以上である確率は何 %?」のような直接的問いに答えられるのが強み。 PyMC を使った最小例。

🎯 このコードでやること: 高齢化 (連続) → 病院密度高 (0/1) のロジスティック回帰を、 弱情報事前 N(0, 5²) で MCMC 推定し、 OR の事後分布から「OR > 1 の確率」「90% 信用区間」を直接読む。

📥 入力データ:

df は前ブロックと同じ (SSDSE-B-2026 / 2023 年 47 県) y = high_hospital (病院密度中央値以上 = 1) x = (aging - aging.mean()) × 10 ← 10 ポイントを 1 単位にして中心化 (切片を解釈しやすくする)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import pymc as pm
import numpy as np

# aging は割合 (0.23〜0.39)。10 ポイント (= 0.1) を 1 単位にして中心化する
x_c = (df['aging'].values - df['aging'].mean()) * 10
y = df['high_hospital'].values

with pm.Model() as m:
    b0    = pm.Normal('b0', 0, 5.0)              # 切片 (logit at mean aging)
    b1    = pm.Normal('b1', 0, 5.0)              # 係数 (高齢化率 +10 ポイントあたりの log OR)
    logit = b0 + b1*x_c
    obs   = pm.Bernoulli('y', logit_p=logit, observed=y)
    trace = pm.sample(2000, tune=1000, cores=2, progressbar=False, random_seed=0)

# b1 は「+10 ポイントあたり」の log OR なので、そのまま exp すれば OR
or_samples = np.exp(trace.posterior['b1'].values.ravel())
print(f'OR 事後中央値 (+10% aging): {np.median(or_samples):.2f}')
print(f'90% 信用区間: [{np.percentile(or_samples, 5):.2f}, {np.percentile(or_samples, 95):.2f}]')
print(f'P(OR > 1) = {(or_samples>1).mean():.3f}')
print(f'P(OR > 2) = {(or_samples>2).mean():.3f}')

📤 実行結果 (実測):

OR 事後中央値 (+10% aging): 32.64 90% 信用区間: [5.15, 326.97] P(OR > 1) = 1.000 P(OR > 2) = 0.995

💬 読み方: 高齢化率が 10 ポイント高い県の「病院多」のオッズは事後中央値で 32.6 倍、 90% 信用区間は [5.15, 326.97] と 2 桁にまたがる。 レシピ 4 の最尤推定 (1 ポイントあたり β = 0.3504、 OR 1.42 倍) を 10 ポイント分にすると exp(3.504) ≈ 33 倍なので、 N(0, 5²) の事前分布はほとんど結果を動かしていない。 「OR > 2 の確率 = 99.5%」のように確率で言えるのがベイズの強みだが、 47 県の高齢化率の幅は 22.8〜39.1% しかなく、 10 ポイントの差はデータの端から端に近いので、 32 倍という値を外挿して語らない。

📌 補足: Mantel-Haenszel 法をスクラッチで実装する

Mantel-Haenszel (MH) 共通 OR は層別 2×2 表をプールする古典的アルゴリズム。 式は単純で、 ライブラリなしで実装できる ── 中身を理解しておくと「なぜ均質性検定が必要か」「層 weight はどう決まるか」が腑に落ちる。

$$ \widehat{OR}_{MH} = \frac{\sum_k a_k d_k / n_k}{\sum_k b_k c_k / n_k} $$

📐 数式を言葉で読み解くと: 各層 k の 2×2 表 (a_k, b_k, c_k, d_k, 計 n_k) について、 分子は ad/n、 分母は bc/n を計算してから両方を層ごとに合計する。 層別 OR ad/bc の総和ではなく、 加重平均に近い形になる。

🎯 このコードでやること: SSDSE-B-2026 の高齢化×病院密度問題を人口層 (2 階層) で MH プール OR を手計算し、 statsmodels の StratifiedTable の結果と一致することを確認する。

📥 入力データ:

人口下位層 (n=24): a=14, b=4, c=4, d=2 人口上位層 (n=23): a=2, b=3, c=3, d=15
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np

# 高齢化 × 病院密度の 2×2 表を、人口の中央値で 2 層に分けたもの(上の層別ブロックの crosstab と同じ)
tables = [
    np.array([[2, 4], [4, 14]]),     # 人口下位 24 県: 行=高齢化 低/高, 列=病院密度 低/高 → [[d, c], [b, a]]
    np.array([[15, 3], [3, 2]]),     # 人口上位 23 県
]

num = sum(t[1,1]*t[0,0]/t.sum() for t in tables)    # Σ a_k d_k / n_k
den = sum(t[1,0]*t[0,1]/t.sum() for t in tables)    # Σ b_k c_k / n_k
or_mh = num / den
print(f'Numerator   Σ ad/n = {num:.4f}')
print(f'Denominator Σ bc/n = {den:.4f}')
print(f'MH common OR     = {or_mh:.4f}')

📤 実行結果:

Numerator Σ ad/n = 2.4710 Denominator Σ bc/n = 1.0580 MH common OR = 2.3356

💬 読み方: 手計算の MH 共通 OR = 2.3356 は、 上の層別ブロックで StratifiedTable が返した 2.336 と一致する。 層別 OR (補正なし) は下位層 14×2/(4×4) = 1.75、 上位層 2×15/(3×3) = 3.33 で、 共通 OR はその間に入る。 分子は下位層 28/24 = 1.17 と上位層 30/23 = 1.30、 分母は 16/24 = 0.67 と 9/23 = 0.39 の和で、 各層の重みは ad/n・bc/n の大きさで決まる。 同質性検定で有意な異質性が出たら、 MH 共通値ではなく 層別の OR を並べて記述するのが誠実。

🎮 触って理解する

2×2 分割表 (曝露 × アウトカム) の 4 つのセルをスライダーで動かすと、 各群のオッズ・オッズ比 (OR)・リスク比 (RR)・リスク差 (RD) がその場で正確に再計算されます。 下段のログスケール軸では OR のマーカーを直接ドラッグでき (タッチ操作対応)、 「アウトカムが稀でないとき OR と RR が乖離する」「OR は対数スケールで対称になる」という 2 つの核心を、 数字と図の両方で体感できます。 ここで使う数値はすべて操作用の架空データであり、 実測値ではありません。

アウトカム あり (+)アウトカム なし (−)
曝露 あり a = 30 b = 70
曝露 なし c = 15 d = 85
各群のリスク (結果+ の割合) 30% 曝露あり 15% 曝露なし ログスケール軸 (● OR をドラッグ / ▲ RR)
曝露群のオッズ a/b0.4286
非曝露群のオッズ c/d0.1765
オッズ比 OR = ad/bc2.4286
log OR (自然対数)0.8873
リスク比 RR2.0000
リスク差 RD0.1500
乖離 |OR − RR|0.4286

💡 触って確かめてほしいこと

🚀 発展 — ロジスティック回帰との橋渡し

ロジスティック回帰では、 ある説明変数の係数 β を指数変換した exp(β) がちょうどその変数 1 単位あたりの OR に一致する。 言い換えると 係数 β = log OR であり、 上のウィジェットで表示される「log OR」はロジスティック回帰の係数そのものだ。 だからこそ回帰の世界は log OR (= logit スケール) で組み立てられ、 結果を人に伝えるときだけ exp して OR に戻す。 対数対称性は、 この線形モデルが素直に成立するための土台になっている。

🔎 解説深化 — オッズ比を別角度から

本節は既存の各節(確率→オッズ→logit、OR と RR の乖離、層別・Mantel-Haenszel など)と重複しない独自角度で、オッズ比の「対称性」「非崩壊性」「対数尺度の誤差」という三つの深い性質を扱う。数値は data/raw/SSDSE-B-2026.csv の実測値、または明記した架空例のみを使う。

🎨 直感 — 「たすき掛け」と、行と列を入れ替えても変わらない不思議

オッズ比 OR = ad/bc は 2×2 表の対角(ad)÷ 反対角(bc)、いわゆる「たすき掛け(cross-product ratio)」である。ここから、多くの人が見落とす核心的な直感が導ける ── OR は表の行と列を入れ替えても値が変わらない(転置不変)。つまり「曝露 → 結果のオッズ比」と「結果 → 曝露のオッズ比」は完全に一致する。

実データで確認する。既存節がすべて 2023 年の横断面だったのに対し、ここでは時系列(2012 年と 2023 年をプール、47 都道府県 ×2 = 94 行)を使う。行を「年(2023=遅い / 2012=早い)」、列を「高齢化率(65歳以上人口 A1303 ÷ 総人口 A1101)がプール中央値 0.2794 より高いか」として 2×2 表を作った実測結果が下記。

SSDSE-B-2026 実測 (2012 & 2023 プール, n=94, 高齢化はプール中央値 0.2794 で二値化) 高齢化 高 高齢化 低 2023年(遅い) a=40 b= 7 2012年(早い) c= 7 d=40 OR = ad/bc = 40*40 / (7*7) = 32.65 行と列を入れ替えた転置表の OR = 32.65 ← 完全に一致(転置不変)

💬 一方、リスク比 RR は向きによって値が変わる(転置で不変ではない)。この非対称なデータでは実測 RR が「列を結果と見た場合 0.05」「行を結果と見た場合 0.09」のように食い違う。OR だけが持つこの対称性こそ、結果側から遡って曝露を集める症例対照研究(RR を直接計算できない設計)でも妥当な効果指標が得られる理由であり、疫学で OR が愛用される最大の根拠である。

⚠️ 落とし穴(重要) — 非崩壊性: 交絡が無くても粗 OR と層内 OR はズレる

これは本ページで最も誤解されやすい性質。オッズ比は「非崩壊的(non-collapsible)」である ── たとえ交絡が一切なくても、全体を崩した(collapse した)粗 OR は、各層の共通 OR と一致しない。リスク比 RR とリスク差 RD にはこの現象が無い(崩壊的)ため、OR 特有の罠になる。

下記は架空(合成デモ)の数値例。2 つの層それぞれで曝露群 1000 人・非曝露群 1000 人と曝露を層と独立に割り付けた(=交絡ゼロ)うえ、両層とも層内 OR を 4.00 に固定した。それでも全体を崩すと粗 OR は 2.90 に縮む。

架空(合成デモ・実データではない)── 交絡なし、層内 OR は両層とも 4.00 に固定 層1(ベースライン低, 非曝露リスク 0.10): 曝露リスク 0.308 → 層内 OR = 4.00 層2(ベースライン高, 非曝露リスク 0.50): 曝露リスク 0.800 → 層内 OR = 4.00 ---- 2 層を合算して崩した周辺 2×2 表 ---- 症例 非症例 曝露群 1108 892 非曝露群 600 1400 周辺(粗)OR = 1108*1400 / (892*600) = 2.90 ← 層内 4.00 より内側に縮む (参考)RR は層内 3.08 / 1.60 と異なるが、共通 OR 4.00 は保たれても粗 OR は 2.90

💬 実務的な含意:「調整 OR が粗 OR よりズレた ⇒ 交絡があった証拠」と即断してはいけない。ベースラインリスクが層で違うだけでも、交絡が皆無でも OR は動く。本ページ上部の📝補足(総人口を調整すると高齢化の調整 OR が 5.30・非有意に変わる件)も、交絡だけでなくこの非崩壊性+小標本の不安定さが絡む。効果を「集団全体」で語るなら OR ではなく崩壊的な RR / RD を併記するのが安全。

🚀 発展 — log OR の標準誤差・95%信頼区間・ゼロセル補正

OR の推測統計は対数尺度で行うのが定石。log OR はほぼ正規分布し、その標準誤差は4 セルの逆数和の平方根という覚えやすい形になる。

SE(log OR) = sqrt(1/a + 1/b + 1/c + 1/d) Wald 95%CI(OR) = exp( log OR ± 1.96 * SE ) 上の時系列実測表 (a,b,c,d = 40,7,7,40) に適用: log OR = 3.486 SE = sqrt(1/40 + 1/7 + 1/7 + 1/40) = 0.579 95%CI = exp(3.486 ± 1.96*0.579) = [10.49, 101.65]

💬 読みどころ 3 点。(1) n=94 とプールしても CI は下限 10・上限 100 超と極端に広い ── OR は対数尺度で相対誤差が大きく、点推定の大きさに惑わされてはいけない。(2) CI は対数尺度で対称、OR 尺度では非対称(32.65 に対し下限まで 22、上限まで 69)。だから「点推定 ± 幅」の直感は OR には通用しない。(3) どれか 1 セルが 0 だと OR は 0 か ∞、SE は定義不能。実務では Haldane–Anscombe 補正(全 4 セルに 0.5 を足す)で暫定計算するか、疎な表なら正確検定・条件付きロジスティック回帰に切り替える。

🔗 関連ページ