""" ロジスティック回帰モデルとオッズ比 — インタラクティブ解説ダッシュボード 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()