| """ |
| ロジスティック回帰モデルとオッズ比 — インタラクティブ解説ダッシュボード |
| 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") |
| 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") |
|
|
|
|
| |
| |
| |
| _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() |
|
|
|
|
| |
| |
| |
| 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), |
| ) |
|
|
|
|
| |
| |
| |
| 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") |
| _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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| |
| _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 |
|
|
|
|
| |
| |
| |
| def plot_pol(p_in: float): |
| """ |
| Panel 1: P vs オッズ曲線 |
| Panel 2: オッズ vs ロジット曲線 |
| Panel 3: 対応参照テーブル(選択値をハイライト) |
| """ |
| plt.close("all") |
| _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) |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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") |
| _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) |
|
|
| |
| 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), |
| ) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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), |
| ], |
| "低い不安・抑うつ": [ |
| ("女子 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") |
| 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, |
| ) |
|
|
| |
| 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) |
| |
| 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) |
| |
| 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) |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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; } |
| """ |
|
|
| |
| |
| |
|
|
| 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: |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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], |
| ) |
|
|
| |
| 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*" |
| ) |
|
|
| |
| |
| |
|
|
| |
| demo.load(plot_sigmoid, inputs=[b0_sl, b1_sl, xv_sl], outputs=sig_plt) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| |
| if __name__ == "__main__": |
| demo.queue(max_size=20) |
| demo.launch() |
|
|