Msk7000's picture
Upload 4 files
37611c4 verified
"""
ロジスティック回帰モデルとオッズ比 — インタラクティブ解説ダッシュボード
Hansen et al. (2015) BMC Public Health — Young HUNT 研究の統計手法解説
━━ ローカル実行 (VS Code) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
pip install -r requirements.txt
python app.py
ブラウザで http://localhost:7860 を開く
━━ HuggingFace Spaces ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
リポジトリに以下を配置:
app.py
requirements.txt
NotoSansJP-Regular.ttf
NotoSansJP-Bold.ttf
README.md の YAML ヘッダーに sdk: gradio を記載
例 README.md:
---
title: Logistic Regression HUNT Study
sdk: gradio
sdk_version: "4.44.0"
app_file: app.py
pinned: false
---
"""
import os
import math
import warnings
import numpy as np
import matplotlib
matplotlib.use("Agg") # GUI 不要(サーバー環境用)
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import matplotlib.lines as mlines
from matplotlib.transforms import blended_transform_factory
import gradio as gr
warnings.filterwarnings("ignore")
# ══════════════════════════════════════════════════════════════════
# §1. フォント登録
# ══════════════════════════════════════════════════════════════════
_HERE = os.path.dirname(os.path.abspath(__file__))
def _register_fonts():
for fname in ("NotoSansJP-Regular.ttf", "NotoSansJP-Bold.ttf"):
fpath = os.path.join(_HERE, fname)
if os.path.exists(fpath):
fm.fontManager.addfont(fpath)
print(f"[font] 登録完了: {fname}")
else:
print(f"[font] ファイルが見つかりません(スキップ): {fpath}")
_register_fonts()
def _detect_font() -> str:
"""Noto Sans JP が利用可能か確認し、フォント名を返す"""
for name in ("Noto Sans JP", "NotoSansJP"):
try:
fp = fm.findfont(fm.FontProperties(family=name), fallback_to_default=False)
if "Noto" in fp or "noto" in fp:
print(f"[font] 使用フォント: {name}")
return name
except Exception:
pass
print("[font] Noto Sans JP が見つかりません。DejaVu Sans にフォールバックします。")
return "DejaVu Sans"
JP_FONT = _detect_font()
# ══════════════════════════════════════════════════════════════════
# §2. カラー定数 / スタイルユーティリティ
# ══════════════════════════════════════════════════════════════════
BG = "#FAF8F0" # メイン背景(アイボリー)
BG2 = "#F0EDE0" # カード背景(やや濃いアイボリー)
C_DARK = "#2C2C2A" # 本文テキスト
C_MID = "#5F5E5A" # サブテキスト
C_LIGHT = "#888780" # 補助テキスト
C_LG = "#D3D1C7" # グリッド・薄ボーダー
C_BR = "#B4B2A9" # 通常ボーダー
C_BLUE = "#185FA5"
C_BLUE2 = "#378ADD"
C_TEAL = "#0F6E56"
C_TEAL2 = "#1D9E75"
C_CORAL = "#D85A30"
C_PURP = "#534AB7"
C_AMBR = "#BA7517"
C_RED = "#A32D2D"
def _rc():
"""matplotlib グローバルスタイルを設定(各プロット関数の先頭で呼ぶ)"""
plt.rcParams.update({
"font.family": JP_FONT,
"figure.facecolor": BG,
"axes.facecolor": BG,
"axes.edgecolor": C_BR,
"axes.linewidth": 0.7,
"text.color": C_DARK,
"axes.labelcolor": C_DARK,
"xtick.color": C_MID,
"ytick.color": C_MID,
"xtick.labelsize": 9,
"ytick.labelsize": 9,
"grid.color": C_LG,
"grid.linewidth": 0.5,
"axes.spines.top": False,
"axes.spines.right": False,
"legend.frameon": False,
"legend.fontsize": 8.5,
"figure.autolayout": False,
})
def _notebox(ax, text: str, loc: str = "upper left"):
"""グラフ内テキストボックスを描画するユーティリティ"""
x = 0.03 if "left" in loc else 0.97
ha = "left" if "left" in loc else "right"
y = 0.97 if "upper" in loc else 0.03
va = "top" if "upper" in loc else "bottom"
ax.text(
x, y, text,
transform=ax.transAxes, fontsize=8.5, va=va, ha=ha,
bbox=dict(boxstyle="round,pad=0.45", facecolor=BG2,
edgecolor=C_BR, lw=0.7, alpha=0.95),
)
# ══════════════════════════════════════════════════════════════════
# §3. Tab 1 ─ シグモイド関数
# ══════════════════════════════════════════════════════════════════
def _sig(x, b0: float, b1: float) -> np.ndarray:
"""数値オーバーフローを防いだシグモイド関数"""
return 1.0 / (1.0 + np.exp(np.clip(-(b0 + b1 * np.asarray(x, float)), -500, 500)))
def plot_sigmoid(b0: float, b1: float, xv: float):
"""
シグモイド曲線(単一パネル)。
β 表記には matplotlib mathtext ($\\beta_0$, $\\beta_1$) を使用し、
フォント依存の Unicode 添字文字化けを回避する。
"""
plt.close("all") # 前の Figure を解放してメモリ蓄積を防ぐ
_rc()
x = np.linspace(-5, 5, 400)
y = _sig(x, b0, b1)
p_cur = float(_sig(xv, b0, b1))
odds = p_cur / (1.0 - p_cur) if p_cur < 0.9999 else 9999.0
OR_ = math.exp(b1)
fig, ax = plt.subplots(figsize=(8, 5), facecolor=BG, constrained_layout=True)
ax.set_facecolor(BG)
# ── S 字曲線 ──────────────────────────────────────────
ax.plot(x, y, color=C_BLUE, lw=2.4, zorder=3)
ax.axhline(0.5, color=C_LG, lw=0.9, ls="--", zorder=1)
# ── ドロップライン(オレンジ点線)────────────────────
ax.plot([xv, xv], [0, p_cur], color=C_CORAL, lw=1.0, ls=":", zorder=2)
ax.plot([-5, xv], [p_cur, p_cur], color=C_CORAL, lw=1.0, ls=":", zorder=2)
ax.scatter([xv], [p_cur], color=C_CORAL, s=80, zorder=5)
# ── 軸・タイトル ──────────────────────────────────────
ax.set_xlabel("説明変数 X", fontsize=11)
ax.set_ylabel("確率 P(Y=1)", fontsize=11)
# タイトルに mathtext を使用 → β₀・β₁ が確実に描画される
ax.set_title(
r"シグモイド関数 "
r"$P(Y\!=\!1)=\dfrac{1}{1+\exp[-(\beta_0+\beta_1 X)]}$",
fontsize=11, fontweight="bold", pad=14,
)
ax.set_xlim(-5, 5)
ax.set_ylim(-0.03, 1.06)
ax.grid(True, alpha=0.35)
# ── 情報ボックス(mathtext でβ添字を描画)────────────
# $\beta_0$ 記法はフォントに依存せず matplotlib が描画するため文字化けしない
_notebox(
ax,
f"$\\beta_0$ = {b0:.1f}\n"
f"$\\beta_1$ = {b1:.1f}\n"
f"OR = exp($\\beta_1$) = {OR_:.3f}\n"
f"{'─' * 14}\n"
f"X = {xv:.1f}\n"
f"P = {p_cur:.3f}\n"
f"オッズ = {odds:.3f}",
loc="lower right",
)
return fig
# ══════════════════════════════════════════════════════════════════
# §4. Tab 2 ─ P → オッズ → ロジット
# ══════════════════════════════════════════════════════════════════
def plot_pol(p_in: float):
"""
Panel 1: P vs オッズ曲線
Panel 2: オッズ vs ロジット曲線
Panel 3: 対応参照テーブル(選択値をハイライト)
"""
plt.close("all") # 前の Figure を解放
_rc()
p_in = float(np.clip(p_in, 0.001, 0.999))
odds_in = p_in / (1.0 - p_in)
logit_in = math.log(odds_in)
# 参照テーブル用データ
p_tbl = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]
o_tbl = [p / (1 - p) for p in p_tbl]
l_tbl = [math.log(o) for o in o_tbl]
hi_row = min(range(len(p_tbl)), key=lambda i: abs(p_tbl[i] - p_in))
fig = plt.figure(figsize=(12, 5.5), facecolor=BG, constrained_layout=True)
gs = fig.add_gridspec(1, 3, wspace=0.42)
# ── Panel 1: P → オッズ ─────────────────────────────────
ax1 = fig.add_subplot(gs[0])
ax1.set_facecolor(BG)
p_lin = np.linspace(0.001, 0.999, 400)
ymax1 = min(20.0, max(odds_in * 1.65 + 1.0, 5.0))
ax1.plot(p_lin, p_lin / (1 - p_lin), color=C_TEAL, lw=2.0)
ax1.axhline(1.0, color=C_LG, lw=0.9, ls="--")
ax1.axvline(0.5, color=C_LG, lw=0.9, ls="--")
odds_plot1 = min(odds_in, ymax1 * 0.95)
ax1.scatter([p_in], [odds_plot1], color=C_CORAL, s=75, zorder=5)
ax1.plot([p_in, p_in], [0, odds_plot1],
color=C_CORAL, lw=0.9, ls=":", zorder=3)
ax1.plot([0.0, p_in], [odds_plot1, odds_plot1],
color=C_CORAL, lw=0.9, ls=":", zorder=3)
ax1.set_xlabel("確率 P", fontsize=10)
ax1.set_ylabel("オッズ P/(1-P)", fontsize=10)
ax1.set_title("① 確率 → オッズ", fontsize=10.5, fontweight="bold", pad=8)
ax1.set_xlim(0.0, 1.0)
ax1.set_ylim(0.0, ymax1)
ax1.grid(True, alpha=0.35)
_notebox(ax1, f"P = {p_in:.3f}\nオッズ = {odds_in:.3f}", "upper left")
# ── Panel 2: オッズ → ロジット ───────────────────────────
ax2 = fig.add_subplot(gs[1])
ax2.set_facecolor(BG)
xmax2 = min(15.0, max(odds_in * 1.65 + 1.0, 5.0))
lmax2 = min(4.0, max(abs(logit_in) * 1.4, 2.0))
o_lin = np.linspace(0.01, xmax2, 400)
ax2.plot(o_lin, np.log(o_lin), color=C_BLUE, lw=2.0)
ax2.axhline(0.0, color=C_LG, lw=0.9, ls="--")
ax2.axvline(1.0, color=C_LG, lw=0.9, ls="--")
odds_plot2 = min(odds_in, xmax2 * 0.95)
logit_plot = np.clip(logit_in, -lmax2, lmax2)
ax2.scatter([odds_plot2], [logit_plot], color=C_CORAL, s=75, zorder=5)
ax2.plot([odds_plot2, odds_plot2], [-lmax2, logit_plot],
color=C_CORAL, lw=0.9, ls=":", zorder=3)
ax2.set_xlabel("オッズ P/(1-P)", fontsize=10)
ax2.set_ylabel("ロジット log[P/(1-P)]", fontsize=10)
ax2.set_title("② オッズ → ロジット", fontsize=10.5, fontweight="bold", pad=8)
ax2.set_xlim(0.0, xmax2)
ax2.set_ylim(-lmax2, lmax2)
ax2.grid(True, alpha=0.35)
_notebox(ax2, f"オッズ = {odds_in:.3f}\nロジット = {logit_in:+.3f}", "upper left")
# ── Panel 3: 対応参照テーブル ─────────────────────────
ax3 = fig.add_subplot(gs[2])
ax3.set_facecolor(BG)
ax3.axis("off")
ax3.set_title("P・オッズ・ロジット 対応表", fontsize=10.5, fontweight="bold", pad=8)
rows = [[f"{p:.2f}", f"{o:.3f}", f"{l:+.3f}"]
for p, o, l in zip(p_tbl, o_tbl, l_tbl)]
tbl = ax3.table(
cellText=rows,
colLabels=["確率 P", "オッズ", "ロジット"],
loc="center", cellLoc="center",
)
tbl.auto_set_font_size(False)
tbl.set_fontsize(9.5)
tbl.scale(1.15, 1.58)
for (r, c), cell in tbl.get_celld().items():
cell.set_edgecolor(C_LG)
cell.set_linewidth(0.5)
if r == 0:
cell.set_facecolor(BG2)
cell.set_text_props(fontweight="bold", color=C_DARK)
elif r - 1 == hi_row:
cell.set_facecolor("#FFE5CC") # ハイライト行
else:
cell.set_facecolor(BG)
ax3.text(0.5, 0.02,
f"オレンジ行: P = {p_in:.3f} に最近傍",
transform=ax3.transAxes, fontsize=8,
ha="center", va="bottom", color=C_CORAL)
return fig
# ══════════════════════════════════════════════════════════════════
# §5. Tab 3 ─ OR 計算機(2×2 分割表)
# ══════════════════════════════════════════════════════════════════
def calc_or(a, b, c, d):
"""
2×2 分割表からオッズ比・95% CI を計算し、グラフと Markdown を返す。
Returns: (fig, summary_markdown)
"""
a, b, c, d = max(1, int(a)), max(1, int(b)), max(1, int(c)), max(1, int(d))
odds1 = a / b
odds2 = c / d
OR = odds1 / odds2
lnOR = math.log(OR)
SE = math.sqrt(1/a + 1/b + 1/c + 1/d)
lo = math.exp(lnOR - 1.96 * SE)
hi = math.exp(lnOR + 1.96 * SE)
if lo > 1:
sig_txt = "✅ 統計的に有意(正の関連)"
sig_col = C_TEAL
elif hi < 1:
sig_txt = "⚠️ 統計的に有意(負の関連)"
sig_col = C_RED
else:
sig_txt = "❌ 統計的に有意でない(CI が OR=1 をまたぐ)"
sig_col = C_LIGHT
# ── Markdown サマリー ──────────────────────────────────
summary = (
"## 計算結果\n\n"
"| 指標 | 値 |\n|---|---|\n"
f"| オッズ(参加あり) a/b | {odds1:.4f} |\n"
f"| オッズ(参加なし) c/d | {odds2:.4f} |\n"
f"| **オッズ比 OR** | **{OR:.4f}** |\n"
f"| ln(OR) | {lnOR:.4f} |\n"
f"| 標準誤差 SE | {SE:.4f} |\n"
f"| 95% CI 下限 | {lo:.4f} |\n"
f"| 95% CI 上限 | {hi:.4f} |\n\n"
f"**判定: {sig_txt}**\n\n"
"```\n"
f"OR = (a × d) / (b × c)\n"
f" = ({a} × {d}) / ({b} × {c})\n"
f" = {a*d:,} / {b*c:,}\n"
f" = {OR:.4f}\n"
"```"
)
# ── 可視化 ────────────────────────────────────────────
plt.close("all") # 前の Figure を解放
_rc()
fig, (ax1, ax2) = plt.subplots(
1, 2, figsize=(11, 5.2), facecolor=BG, constrained_layout=True
)
for ax in (ax1, ax2):
ax.set_facecolor(BG)
# 左: 2×2 表 -----------------------------------------
ax1.axis("off")
ax1.set_title("2×2 分割表", fontsize=10.5, fontweight="bold", pad=10)
cell_data = [
[f"{a:,}", f"{b:,}", f"{a+b:,}"],
[f"{c:,}", f"{d:,}", f"{c+d:,}"],
[f"{a+c:,}", f"{b+d:,}", f"{a+b+c+d:,}"],
]
col_lbl = ["良好な健康\n(Y=1)", "不良な健康\n(Y=0)", "計"]
row_lbl = ["参加あり (X=1)", "参加なし (X=0)", "計"]
tbl = ax1.table(
cellText=cell_data, colLabels=col_lbl, rowLabels=row_lbl,
loc="center", cellLoc="center",
)
tbl.auto_set_font_size(False)
tbl.set_fontsize(9.5)
tbl.scale(1.12, 2.1)
fc_map = {(1, 0): "#D6E8F7", (1, 1): "#FAE0D4",
(2, 0): "#FAE0D4", (2, 1): "#EBEBEB"}
for (r, ci), cell in tbl.get_celld().items():
cell.set_edgecolor(C_BR)
cell.set_linewidth(0.6)
if r == 0 or ci == -1:
cell.set_facecolor(BG2)
cell.set_text_props(fontweight="bold", color=C_DARK)
elif (r, ci) in fc_map:
cell.set_facecolor(fc_map[(r, ci)])
else:
cell.set_facecolor(BG)
ax1.text(
0.5, 0.06,
f"OR = (a×d) / (b×c) = ({a}×{d}) / ({b}×{c}) = {OR:.3f}",
transform=ax1.transAxes, fontsize=9, ha="center",
bbox=dict(boxstyle="round,pad=0.4", facecolor=BG2, edgecolor=C_BR, lw=0.7),
)
# 右: OR + CI バー ------------------------------------
log_r = max(abs(math.log(lo)), abs(math.log(hi))) * 1.55
x_lo = max(0.08, math.exp(-log_r))
x_hi = math.exp(log_r)
ax2.set_xscale("log")
ax2.set_xlim(x_lo, x_hi)
ax2.set_ylim(0.15, 1.85)
ax2.set_yticks([])
ax2.axvline(1.0, color=C_CORAL, lw=1.2, ls="--", alpha=0.85, zorder=1)
ax2.set_xlabel("オッズ比 OR(対数スケール)", fontsize=10)
ax2.set_title("OR と 95% 信頼区間", fontsize=10.5, fontweight="bold", pad=10)
ax2.grid(True, which="both", alpha=0.3)
ax2.text(
0.5, 0.06, "赤破線: OR=1(帰無仮説)",
transform=ax2.transAxes, fontsize=8,
ha="center", color=C_CORAL,
bbox=dict(boxstyle="round,pad=0.3", facecolor=BG2, edgecolor=C_BR, lw=0.6),
)
dot_c = C_TEAL if lo > 1 else (C_RED if hi < 1 else C_LIGHT)
ax2.plot([lo, hi], [1.0, 1.0], color=dot_c, lw=2.5, solid_capstyle="round", zorder=3)
ax2.plot([lo, lo], [0.88, 1.12], color=dot_c, lw=1.8, zorder=3)
ax2.plot([hi, hi], [0.88, 1.12], color=dot_c, lw=1.8, zorder=3)
ax2.scatter([OR], [1.0], color=dot_c, s=90, zorder=5)
ax2.text(OR, 1.28, f"OR = {OR:.3f}", ha="center", fontsize=10,
fontweight="bold", color=dot_c)
ax2.text(lo, 0.70, f"{lo:.3f}", ha="center", fontsize=8.5, color=C_MID)
ax2.text(hi, 0.70, f"{hi:.3f}", ha="center", fontsize=8.5, color=C_MID)
ax2.text(
0.5, 0.88, sig_txt,
transform=ax2.transAxes, fontsize=9, ha="center", color=sig_col,
bbox=dict(boxstyle="round,pad=0.4", facecolor=BG2, edgecolor=C_BR, lw=0.7),
)
return fig, summary
# ══════════════════════════════════════════════════════════════════
# §6. Tab 4 ─ Forest Plot(論文 Table 3 の主要 OR)
# ══════════════════════════════════════════════════════════════════
FOREST_DATA: dict[str, list[tuple]] = {
"自己評価健康": [
("女子 13-15歳", 2.70, 2.00, 3.66, C_BLUE),
("女子 16-19歳", 2.43, 1.83, 3.24, C_BLUE),
("男子 13-15歳", 2.80, 2.02, 3.88, C_TEAL),
("男子 16-19歳", 3.07, 2.14, 4.41, C_TEAL),
],
"生活満足度": [
("女子 13-15歳", 1.57, 1.30, 1.91, C_BLUE),
("女子 16-19歳", 1.66, 1.34, 2.06, C_BLUE),
("男子 13-15歳", 1.71, 1.35, 2.17, C_TEAL),
("男子 16-19歳", 1.71, 1.29, 2.27, C_TEAL),
],
"自尊感情": [
("女子 13-15歳", 1.76, 1.34, 2.30, C_BLUE),
("女子 16-19歳", 1.67, 1.22, 2.28, C_BLUE),
("男子 13-15歳", 1.61, 0.98, 2.63, C_TEAL), # CI が 1 をまたぐ→非有意
],
"低い不安・抑うつ": [
("女子 16-19歳", 1.40, 1.12, 1.76, C_BLUE),
("男子 16-19歳", 1.40, 1.11, 1.77, C_TEAL),
],
}
def plot_forest(outcome: str):
"""
選択されたアウトカム(または全アウトカム)の Forest Plot を描画する。
★: CI が OR=1 をまたがない(統計的有意)
"""
_rc()
# データ収集
if outcome == "全アウトカム":
items = []
for oc, rows in FOREST_DATA.items():
for lbl, OR, lo, hi, col in rows:
items.append((f"[{oc}] {lbl}", OR, lo, hi, col, lo > 1))
else:
items = [
(lbl, OR, lo, hi, col, lo > 1)
for lbl, OR, lo, hi, col in FOREST_DATA[outcome]
]
plt.close("all") # 前の Figure を解放
n = len(items)
fig_h = max(5.0, n * 0.62 + 2.8)
fig, ax = plt.subplots(figsize=(12, fig_h), facecolor=BG, constrained_layout=True)
ax.set_facecolor(BG)
# 軸設定
ax.set_xscale("log")
ax.set_xlim(0.35, 9.0)
ax.set_ylim(-0.65, n - 0.35)
ax.set_yticks(range(n))
ax.set_yticklabels([r[0] for r in reversed(items)], fontsize=9)
ax.set_xlabel("オッズ比(OR)— 対数スケール", fontsize=10)
ax.set_title(
"Forest Plot — 会合・練習参加 × 各健康アウトカム(SES 調整済み)\n"
"Hansen et al., BMC Public Health 2015, Table 3(Model II)",
fontsize=10.5, fontweight="bold", pad=12,
)
# OR=1 の基準線
ax.axvline(1.0, color=C_CORAL, lw=1.2, ls="--", alpha=0.85, zorder=1)
ax.text(0.95, -0.55, "OR=1(帰無仮説)",
fontsize=7.5, color=C_CORAL, ha="right",
transform=blended_transform_factory(ax.transData, ax.transData))
# グリッド
ax.xaxis.grid(True, which="both", alpha=0.3)
ax.set_xticks([0.5, 1.0, 1.5, 2.0, 3.0, 5.0, 7.0])
ax.set_xticklabels(["0.5", "1.0", "1.5", "2.0", "3.0", "5.0", "7.0"])
ax.spines["left"].set_visible(False)
# 各行のプロット
for i, (_, OR, lo, hi, col, sig) in enumerate(reversed(items)):
y = float(i)
# CI バー
ax.plot([lo, hi], [y, y], color=col, lw=1.8, solid_capstyle="round", zorder=2)
ax.plot([lo, lo], [y - 0.13, y + 0.13], color=col, lw=1.5, zorder=2)
ax.plot([hi, hi], [y - 0.13, y + 0.13], color=col, lw=1.5, zorder=2)
# OR 点(菱形)
ax.scatter([OR], [y], color=col, s=60, marker="D", zorder=4)
# 有意マーク
if sig:
ax.text(lo * 0.91, y, "★", va="center", ha="right",
fontsize=8, color=col)
# OR 値ラベル(右端に)
ax.text(9.2, y, f"{OR:.2f} [{lo:.2f}{hi:.2f}]",
va="center", fontsize=8, color=C_MID, clip_on=False)
# 凡例
legend_handles = [
mlines.Line2D([], [], color=C_BLUE, lw=1.8,
marker="D", ms=5, label="女子"),
mlines.Line2D([], [], color=C_TEAL, lw=1.8,
marker="D", ms=5, label="男子"),
mlines.Line2D([], [], color=C_CORAL, lw=1.2,
ls="--", label="OR=1 基準線"),
mlines.Line2D([], [], lw=0, marker="$★$", ms=7,
color=C_MID, label="★: CI が OR=1 をまたがない(有意)"),
]
ax.legend(
handles=legend_handles, fontsize=8.5,
facecolor=BG2, edgecolor=C_BR, frameon=True,
framealpha=0.92, loc="lower right",
)
return fig
# ══════════════════════════════════════════════════════════════════
# §7. Gradio UI 定義
# ══════════════════════════════════════════════════════════════════
CSS = """
body,
.gradio-container,
#root,
.main,
.wrap {
background-color: #FAF8F0 !important;
}
.block, .form, .panel {
background-color: #F0EDE0 !important;
border-color: #B4B2A9 !important;
}
.tabs > .tab-nav > button {
background: transparent;
border-bottom: 2px solid transparent;
color: #5F5E5A;
}
.tabs > .tab-nav > button.selected {
border-bottom: 2px solid #185FA5 !important;
color: #185FA5 !important;
font-weight: 500;
}
footer { display: none !important; }
"""
# ──────────────────────────────────────────────────────────────────
# OR 計算ラッパー(不要なため削除 → calc_or を直接参照)
# ──────────────────────────────────────────────────────────────────
with gr.Blocks(
title="ロジスティック回帰とオッズ比 — Young HUNT 研究",
css=CSS,
) as demo:
gr.Markdown(
"# ロジスティック回帰モデルとオッズ比\n"
"### Hansen et al. (2015) BMC Public Health — Young HUNT 研究の統計手法インタラクティブ解説\n"
"---"
)
with gr.Tabs() as tabs:
# ─── Tab 1: シグモイド ──────────────────────────────────
with gr.TabItem("① シグモイド関数") as tab1:
gr.Markdown(
"#### 数式\n"
"$$P(Y=1) \\;=\\; \\frac{1}{1 + e^{-(\\beta_0 + \\beta_1 X)}}$$\n\n"
"スライダーを動かして S 字曲線の形を確認してください。"
"**β₁ を指数変換すると OR = exp(β₁)** が得られます。"
)
with gr.Row():
b0_sl = gr.Slider(
-4, 4, value=0, step=0.1,
label="切片 β₀(曲線の上下シフト)",
)
b1_sl = gr.Slider(
-3, 3, value=1.0, step=0.1,
label="傾き β₁(OR = exp(β₁))",
)
xv_sl = gr.Slider(
-4, 4, value=0.0, step=0.1,
label="X の値(オレンジ点の位置)",
)
sig_plt = gr.Plot(show_label=False)
# スライダー操作 → 即時更新
for sl in [b0_sl, b1_sl, xv_sl]:
sl.release(
plot_sigmoid,
inputs=[b0_sl, b1_sl, xv_sl],
outputs=sig_plt,
)
# ─── Tab 2: P → Odds → Logit ───────────────────────────
with gr.TabItem("② P → オッズ → ロジット") as tab2:
gr.Markdown(
"#### 変換の仕組み\n"
"| 変換 | 式 | 範囲 |\n"
"|---|---|---|\n"
"| 確率 P | — | 0 〜 1 |\n"
"| オッズ | P / (1-P) | 0 〜 ∞ |\n"
"| ロジット | log(P/(1-P)) | −∞ 〜 +∞ |\n\n"
"P=0.5 のとき オッズ=1・ロジット=0 が基準点です。"
"ロジットは −∞〜+∞ に広がるため、通常の線形回帰の数学が使えます。"
)
p_sl = gr.Slider(
0.01, 0.99, value=0.75, step=0.01,
label="確率 P(スライダーで変更)",
)
pol_plt = gr.Plot(show_label=False)
# スライダー操作 → 即時更新
p_sl.release(plot_pol, inputs=p_sl, outputs=pol_plt)
# ─── Tab 3: OR 計算機 ───────────────────────────────────
with gr.TabItem("③ OR 計算機(2×2 表)") as tab3:
gr.Markdown(
"#### 2×2 分割表 → オッズ比・95% CI の計算\n"
"セル a〜d の値を入力し「**OR を計算**」を押してください。\n"
"デフォルト値は OR = 2.70 となるよう設定してあります(論文の女子13-15歳と同値)。\n\n"
"| | 良好(Y=1) | 不良(Y=0) |\n"
"|---|---|---|\n"
"| 文化活動あり (X=1) | **a** | **b** |\n"
"| 文化活動なし (X=0) | **c** | **d** |"
)
with gr.Row():
a_in = gr.Number(value=4050, precision=0,
label="a:参加あり・良好な健康")
b_in = gr.Number(value=200, precision=0,
label="b:参加あり・不良な健康")
with gr.Row():
c_in = gr.Number(value=3000, precision=0,
label="c:参加なし・良好な健康")
d_in = gr.Number(value=400, precision=0,
label="d:参加なし・不良な健康")
calc_btn = gr.Button("OR を計算", variant="primary")
or_plt = gr.Plot(show_label=False)
or_md = gr.Markdown()
calc_btn.click(
calc_or,
inputs=[a_in, b_in, c_in, d_in],
outputs=[or_plt, or_md],
)
# ─── Tab 4: Forest Plot ─────────────────────────────────
with gr.TabItem("④ Forest Plot") as tab4:
gr.Markdown(
"#### 論文 Table 3 の主要 OR — 会合・練習参加と各健康アウトカム(SES 調整済み)\n"
"★ = 95% CI が OR=1 をまたがない(統計的に有意)\n"
"全ての OR > 1 かつ CI が 1 をまたがない(★付き)は、"
"文化活動参加が健康アウトカムと**正の関連**であることを示します。"
)
oc_dd = gr.Dropdown(
choices=["全アウトカム"] + list(FOREST_DATA.keys()),
value="全アウトカム",
label="表示するアウトカムを選択",
)
fp_plt = gr.Plot(show_label=False)
oc_dd.change(plot_forest, inputs=oc_dd, outputs=fp_plt)
gr.Markdown(
"---\n"
"*Hansen E, Sund E, Knudtsen MS, Krokstad S, Holmen TL. "
"Cultural activity participation and associations with self-perceived health, "
"life-satisfaction and mental health: the Young HUNT Study, Norway. "
"BMC Public Health. 2015;15:544. DOI: 10.1186/s12889-015-1873-4*"
)
# ──────────────────────────────────────────────────────────────
# イベント配線(タブ選択時に遅延ロード)
# ──────────────────────────────────────────────────────────────
# Tab1 のみ起動時に描画(最初に表示されるタブ)
demo.load(plot_sigmoid, inputs=[b0_sl, b1_sl, xv_sl], outputs=sig_plt)
# Tab2〜4 はタブを開いたときに初めて描画(遅延ロード)
tab2.select(plot_pol, inputs=p_sl, outputs=pol_plt)
tab3.select(calc_or, inputs=[a_in, b_in, c_in, d_in],
outputs=[or_plt, or_md])
tab4.select(plot_forest, inputs=oc_dd, outputs=fp_plt)
# ══════════════════════════════════════════════════════════════════
# §8. 起動
# queue() を有効化することでイベントの並走ブロックを解消する
# ══════════════════════════════════════════════════════════════════
if __name__ == "__main__":
demo.queue(max_size=20) # ← フリーズ・スライダー無反応の主な原因を修正
demo.launch()