| """ |
| Simon Two-stage Design 互動式教學工具 |
| 賽門式二階段試驗設計 - 基於 Tan et al. (2002) BJC 論文 |
| 完整版:含 Trial C、Trial P、4種事前分佈 |
| 修改版:使用 Google Gemini API |
| """ |
|
|
| import gradio as gr |
| import numpy as np |
| from scipy import stats |
| import matplotlib.pyplot as plt |
|
|
| |
|
|
| def beta_mean(a, b): |
| """Beta 分佈平均值""" |
| return a / (a + b) |
|
|
| def beta_var(a, b): |
| """Beta 分佈變異數""" |
| return (a * b) / ((a + b)**2 * (a + b + 1)) |
|
|
| def beta_ci(a, b, level=0.95): |
| """Beta 分佈可信區間""" |
| lower = (1 - level) / 2 |
| return stats.beta.ppf(lower, a, b), stats.beta.ppf(1-lower, a, b) |
|
|
| def calc_clinical_prior(r0, r1): |
| """ |
| Clinical Prior - 使用論文的精確數值 |
| Trial C: Beta(0.7, 2.1) |
| Trial P: Beta(0.6, 3.0) |
| """ |
| if r0 == 0.10 and r1 == 0.30: |
| return 0.7, 2.1 |
| elif r0 == 0.05 and r1 == 0.20: |
| return 0.6, 3.0 |
| else: |
| |
| m = (r0 + r1) / 2 |
| return m * 2, (1 - m) * 2 |
|
|
| def calc_sceptical_prior(r0, r1): |
| """ |
| Sceptical Prior - 使用論文的精確數值 |
| Trial C: Beta(1, 9) |
| Trial P: Beta(0.4, 7.6) |
| """ |
| if r0 == 0.10 and r1 == 0.30: |
| return 1, 9 |
| elif r0 == 0.05 and r1 == 0.20: |
| return 0.4, 7.6 |
| else: |
| return r0 * 10, (1 - r0) * 10 |
|
|
| def calc_enthusiastic_prior(r0, r1): |
| """ |
| Enthusiastic Prior - 使用論文的精確數值 |
| Trial C: Beta(3, 7) |
| Trial P: Beta(2.4, 9.6) |
| """ |
| if r0 == 0.10 and r1 == 0.30: |
| return 3, 7 |
| elif r0 == 0.05 and r1 == 0.20: |
| return 2.4, 9.6 |
| else: |
| return r1 * 10, (1 - r1) * 10 |
|
|
| |
|
|
| def analyze(trial_type, |
| s1_success, s1_total, s2_success, s2_total, |
| prior_type, custom_a, custom_b): |
| |
| |
| if trial_type == "Trial C (初治組)": |
| r0, r1 = 0.10, 0.30 |
| trial_name = "Trial C:Chemotherapy-naïve(初治組)" |
| trial_name_en = "Trial C: Chemotherapy-naive" |
| else: |
| r0, r1 = 0.05, 0.20 |
| trial_name = "Trial P:Previously treated(曾治療組)" |
| trial_name_en = "Trial P: Previously treated" |
| |
| |
| s1_success, s1_total = int(s1_success), int(s1_total) |
| s2_success, s2_total = int(s2_success), int(s2_total) |
| total_success = s1_success + s2_success |
| total_n = s1_total + s2_total |
| |
| |
| if prior_type == "Clinical Prior(臨床事前分佈)": |
| prior_a, prior_b = calc_clinical_prior(r0, r1) |
| prior_desc = f"根據專家意見,P(θ<R₀) = P(R₀<θ<R₁) = P(θ>R₁) = 1/3" |
| prior_name_en = "Clinical Prior" |
| elif prior_type == "Reference Prior(參考事前分佈)": |
| prior_a, prior_b = 1, 1 |
| prior_desc = "無資訊事前分佈(Uniform),代表完全不確定" |
| prior_name_en = "Reference Prior" |
| elif prior_type == "Sceptical Prior(懷疑性事前分佈)": |
| prior_a, prior_b = calc_sceptical_prior(r0, r1) |
| prior_desc = f"平均值 = R₀ = {r0*100:.0f}%,P(θ>R₁) = 5%(偏向不相信藥效)" |
| prior_name_en = "Sceptical Prior" |
| elif prior_type == "Enthusiastic Prior(樂觀性事前分佈)": |
| prior_a, prior_b = calc_enthusiastic_prior(r0, r1) |
| prior_desc = f"平均值 = R₁ = {r1*100:.0f}%,P(θ<R₀) = 5%(偏向相信藥效)" |
| prior_name_en = "Enthusiastic Prior" |
| else: |
| prior_a, prior_b = custom_a, custom_b |
| prior_desc = "使用者自訂參數" |
| prior_name_en = "Custom Prior" |
| |
| |
| |
| post1_a = prior_a + s1_success |
| post1_b = prior_b + (s1_total - s1_success) |
| |
| |
| post2_a = prior_a + total_success |
| post2_b = prior_b + (total_n - total_success) |
| |
| |
| |
| prior_prob_lt_r0 = stats.beta.cdf(r0, prior_a, prior_b) |
| prior_prob_r0_r1 = stats.beta.cdf(r1, prior_a, prior_b) - prior_prob_lt_r0 |
| prior_prob_gt_r1 = 1 - stats.beta.cdf(r1, prior_a, prior_b) |
| |
| |
| post1_prob_lt_r0 = stats.beta.cdf(r0, post1_a, post1_b) |
| post1_prob_r0_r1 = stats.beta.cdf(r1, post1_a, post1_b) - post1_prob_lt_r0 |
| post1_prob_gt_r1 = 1 - stats.beta.cdf(r1, post1_a, post1_b) |
| |
| |
| post2_prob_lt_r0 = stats.beta.cdf(r0, post2_a, post2_b) |
| post2_prob_r0_r1 = stats.beta.cdf(r1, post2_a, post2_b) - post2_prob_lt_r0 |
| post2_prob_gt_r1 = 1 - stats.beta.cdf(r1, post2_a, post2_b) |
| |
| |
| if trial_type == "Trial C (初治組)": |
| stage1_threshold = 2 |
| final_threshold = 6 |
| else: |
| stage1_threshold = 1 |
| final_threshold = 4 |
| |
| stage1_pass = s1_success >= stage1_threshold |
| final_pass = total_success >= final_threshold |
| |
| |
| result = f""" |
| ## 📋 試驗資訊:{trial_name} |
| |
| ### 設計參數 |
| - **R₀(無興趣反應率)**: {r0*100:.0f}% |
| - **R₁(理想反應率)**: {r1*100:.0f}% |
| |
| --- |
| |
| ## 📊 頻率學派分析 |
| |
| ### 試驗結果 |
| | 階段 | 成功 (R) | 失敗 (N-R) | 總數 (N) | 反應率 | |
| |------|----------|------------|----------|--------| |
| | Stage I | {s1_success} | {s1_total - s1_success} | {s1_total} | {s1_success/s1_total*100:.1f}% | |
| | Stage II | {s2_success} | {s2_total - s2_success} | {s2_total} | {s2_success/s2_total*100:.1f}% | |
| | **合計** | **{total_success}** | **{total_n - total_success}** | **{total_n}** | **{total_success/total_n*100:.1f}%** | |
| |
| ### Simon Minimax Design 判定 |
| - Stage I:需 ≥ {stage1_threshold} 人有反應 → {'✅ 通過' if stage1_pass else '❌ 未通過'}(實際 {s1_success} 人) |
| - 最終:需 ≥ {final_threshold} 人有反應 → {'✅ 有效' if final_pass else '❌ 無效'}(實際 {total_success} 人) |
| |
| --- |
| |
| ## 🔮 貝氏分析 |
| |
| ### 事前分佈:{prior_type} |
| {prior_desc} |
| |
| **Beta({prior_a:.1f}, {prior_b:.1f})**,平均值 = {beta_mean(prior_a, prior_b)*100:.1f}% |
| |
| ### 機率表(對應論文 Table 1 & 2) |
| |
| | 階段 | Beta 參數 | π(平均值) | θ ≤ R₀ | R₀ < θ ≤ R₁ | θ > R₁ | |
| |------|-----------|-------------|--------|-------------|--------| |
| | Prior | Beta({prior_a:.1f}, {prior_b:.1f}) | **{beta_mean(prior_a, prior_b):.3f}** | {prior_prob_lt_r0:.3f} | {prior_prob_r0_r1:.3f} | {prior_prob_gt_r1:.3f} | |
| | Stage 1 posterior | Beta({post1_a:.1f}, {post1_b:.1f}) | **{beta_mean(post1_a, post1_b):.3f}** | {post1_prob_lt_r0:.3f} | {post1_prob_r0_r1:.3f} | {post1_prob_gt_r1:.3f} | |
| | Stage 2 posterior | Beta({post2_a:.1f}, {post2_b:.1f}) | **{beta_mean(post2_a, post2_b):.3f}** | {post2_prob_lt_r0:.3f} | {post2_prob_r0_r1:.3f} | {post2_prob_gt_r1:.3f} | |
| |
| ### 貝氏更新過程(練習用) |
| |
| | 事前分佈 | Stage I 結果 | Stage I 後分佈 | Stage II 結果 | Stage II 後分佈 | |
| |----------|-------------|---------------|--------------|----------------| |
| | Beta({prior_a:.1f},{prior_b:.1f}), π={beta_mean(prior_a, prior_b):.2f} | 成功:{s1_success}, 失敗:{s1_total - s1_success} | Beta({post1_a:.1f},{post1_b:.1f}), π={beta_mean(post1_a, post1_b):.2f} | 成功:{s2_success}, 失敗:{s2_total - s2_success} | Beta({post2_a:.1f},{post2_b:.1f}), π={beta_mean(post2_a, post2_b):.3f} | |
| |
| ### 結果解讀 |
| - **P(θ > R₁) = {post2_prob_gt_r1:.1%}**:藥物反應率超過理想值的機率 |
| - **P(θ > R₀) = {(1-post2_prob_lt_r0):.1%}**:藥物值得繼續研究的機率 |
| |
| """ |
| |
| if post2_prob_gt_r1 > 0.5: |
| result += "📌 **結論**:強烈支持進入 Phase III 試驗\n" |
| elif post2_prob_gt_r1 > 0.25: |
| result += "📌 **結論**:中度支持,可考慮進一步研究\n" |
| else: |
| result += "📌 **結論**:證據不足,需謹慎評估\n" |
| |
| |
| fig, axes = plt.subplots(1, 2, figsize=(14, 5)) |
| x = np.linspace(0, 1, 500) |
| |
| |
| ax1 = axes[0] |
| ax1.plot(x, stats.beta.pdf(x, prior_a, prior_b), 'b--', lw=2, |
| label=f'Prior Beta({prior_a:.1f},{prior_b:.1f})') |
| ax1.plot(x, stats.beta.pdf(x, post1_a, post1_b), 'g-.', lw=2, |
| label=f'Stage 1 Post Beta({post1_a:.1f},{post1_b:.1f})') |
| ax1.plot(x, stats.beta.pdf(x, post2_a, post2_b), 'r-', lw=2.5, |
| label=f'Stage 2 Post Beta({post2_a:.1f},{post2_b:.1f})') |
| ax1.axvline(r0, color='gray', ls=':', alpha=0.7, label=f'R0={r0}') |
| ax1.axvline(r1, color='orange', ls=':', alpha=0.7, label=f'R1={r1}') |
| ax1.set_xlabel('Response Rate (theta)', fontsize=12) |
| ax1.set_ylabel('Density', fontsize=12) |
| ax1.set_title(f'{prior_name_en} - Bayesian Update\n{trial_name_en}', fontsize=12) |
| ax1.legend(fontsize=9) |
| ax1.grid(alpha=0.3) |
| ax1.set_xlim(0, 0.8) |
| |
| |
| ax2 = axes[1] |
| |
| |
| clin_a, clin_b = calc_clinical_prior(r0, r1) |
| ref_a, ref_b = 1, 1 |
| scep_a, scep_b = calc_sceptical_prior(r0, r1) |
| enth_a, enth_b = calc_enthusiastic_prior(r0, r1) |
| |
| ax2.plot(x, stats.beta.pdf(x, clin_a, clin_b), 'b-', lw=1.5, alpha=0.7, |
| label=f'Clinical ({clin_a:.1f},{clin_b:.1f})') |
| ax2.plot(x, stats.beta.pdf(x, ref_a, ref_b), 'gray', ls='--', lw=1.5, alpha=0.7, |
| label=f'Reference ({ref_a},{ref_b})') |
| ax2.plot(x, stats.beta.pdf(x, scep_a, scep_b), 'r-', lw=1.5, alpha=0.7, |
| label=f'Sceptical ({scep_a:.1f},{scep_b:.1f})') |
| ax2.plot(x, stats.beta.pdf(x, enth_a, enth_b), 'g-', lw=1.5, alpha=0.7, |
| label=f'Enthusiastic ({enth_a:.1f},{enth_b:.1f})') |
| ax2.axvline(r0, color='gray', ls=':', alpha=0.5) |
| ax2.axvline(r1, color='orange', ls=':', alpha=0.5) |
| ax2.set_xlabel('Response Rate (theta)', fontsize=12) |
| ax2.set_ylabel('Density', fontsize=12) |
| ax2.set_title(f'Comparison of 4 Prior Types\n{trial_name_en} (Figure 1 & 4 style)', fontsize=12) |
| ax2.legend(fontsize=9) |
| ax2.grid(alpha=0.3) |
| ax2.set_xlim(0, 0.8) |
| |
| plt.tight_layout() |
| |
| |
| summary = f""" |
| Trial: {trial_name} |
| R0={r0}, R1={r1} |
| Stage1: {s1_success}/{s1_total}, Stage2: {s2_success}/{s2_total}, Total: {total_success}/{total_n} |
| Prior: {prior_type}, Beta({prior_a:.1f},{prior_b:.1f}) |
| Posterior: Beta({post2_a:.1f},{post2_b:.1f}) |
| P(theta>R0)={1-post2_prob_lt_r0:.3f}, P(theta>R1)={post2_prob_gt_r1:.3f} |
| Frequentist: Stage1={'Pass' if stage1_pass else 'Fail'}, Final={'Effective' if final_pass else 'Ineffective'} |
| """ |
| |
| return result, fig, summary |
|
|
| |
|
|
| def get_llm_explanation(summary_str, question, api_key): |
| """呼叫 OpenAI API (ChatGPT) 獲得 LLM 解釋""" |
| |
| if not api_key or api_key.strip() == "": |
| return "⚠️ 請先輸入 OpenAI API Key\n\n(到 https://platform.openai.com/api-keys 取得)" |
| |
| if not summary_str: |
| return "⚠️ 請先執行分析,再請 AI 解釋" |
| |
| try: |
| from openai import OpenAI |
| |
| client = OpenAI(api_key=api_key) |
| |
| system_prompt = """你是一位專業的生物統計學家和臨床試驗專家,專門教導學生理解 Simon Two-stage Design 和貝氏分析。 |
| |
| 你的教學基於 Tan et al. (2002) 發表在 British Journal of Cancer 的論文: |
| "A Bayesian re-assessment of two Phase II trials of gemcitabine in metastatic nasopharyngeal cancer" |
| |
| 請用繁體中文回答,語氣友善且易懂,像是老師在跟學生解釋。回答時請: |
| 1. 用簡單的語言解釋統計結果的意義 |
| 2. 說明頻率學派和貝氏方法的差異 |
| 3. 解釋不同事前分佈(Clinical, Reference, Sceptical, Enthusiastic)的意義 |
| 4. 給出是否建議進入下一期試驗的建議""" |
|
|
| user_prompt = f"""以下是一個 Simon Two-stage Design 臨床試驗的分析結果: |
| |
| {summary_str} |
| |
| 學生的問題:{question if question else "請幫我解釋這個結果代表什麼意思?不同的事前分佈會如何影響結論?"} |
| |
| 請用簡單易懂的方式回答。""" |
|
|
| response = client.chat.completions.create( |
| model="gpt-4o-mini", |
| messages=[ |
| {"role": "system", "content": system_prompt}, |
| {"role": "user", "content": user_prompt} |
| ], |
| max_tokens=1500, |
| temperature=0.7 |
| ) |
| |
| return response.choices[0].message.content |
| |
| except Exception as e: |
| return f"❌ API 呼叫失敗:{str(e)}\n\n請確認:\n1. API Key 是否正確\n2. 帳戶是否有餘額" |
|
|
| |
|
|
| with gr.Blocks( |
| title="Simon Two-stage Design 教學工具", |
| theme=gr.themes.Soft(primary_hue="blue") |
| ) as demo: |
| |
| |
| analysis_summary = gr.State("") |
| |
| |
| gr.Markdown(""" |
| # 🔬 Simon Two-stage Design 互動式教學工具 |
| ## 基於 Tan et al. (2002) British Journal of Cancer 論文 |
| **A Bayesian re-assessment of two Phase II trials of gemcitabine in metastatic nasopharyngeal cancer** |
| """) |
| |
| |
| with gr.Accordion("📖 教學故事線", open=True): |
| gr.Markdown(""" |
| > 🎯 **核心問題**:新藥開發很燒錢,怎麼「早點知道該不該繼續」? |
| |
| - 💰 一個新藥開發平均要 **10-20 億美元** |
| - ⏰ 耗時 **10-15 年** |
| - 📉 Phase II 失敗率高達 **70%** |
| |
| ↓ |
| |
| 「如果藥沒效,能不能早點停?」 |
| 「如果有希望,再多收病人確認」 |
| |
| ↓ |
| |
| > **1989 年 Richard Simon 提出解決方案:** |
| > 👉 **Simon Two-stage Design** |
| |
| --- |
| |
| ### 🏥 今天的案例:Gemcitabine 治療鼻咽癌 |
| |
| 新加坡國家癌症中心要測試 **gemcitabine(健擇)** 對轉移性鼻咽癌的療效,設計了兩個試驗: |
| |
| | 試驗 | 對象 | R₀(沒興趣) | R₁(理想) | |
| |------|------|-------------|-----------| |
| | **Trial C** | 初治患者 | 10% | 30% | |
| | **Trial P** | 曾治療患者 | 5% | 20% | |
| |
| - 若反應率 ≤ R₀ → 藥物不值得繼續研究 |
| - 若反應率 ≥ R₁ → 值得進入 Phase III |
| |
| 👇 **請選擇試驗、輸入數據,看看結果如何!** |
| """) |
| |
| with gr.Accordion("📐 方法說明:四種事前分佈", open=False): |
| gr.Markdown(""" |
| 貝氏分析需要設定「事前分佈」,代表看到數據**之前**的信念: |
| |
| | 類型 | 定義 | 立場 | |
| |------|------|------| |
| | **Clinical** | P(θ<R₀) = P(R₀<θ<R₁) = P(θ>R₁) = 1/3 | 均衡看法 | |
| | **Reference** | Uniform (1,1) | 完全不確定 | |
| | **Sceptical** | 平均=R₀,P(θ>R₁)=5% | 懷疑,不太信 | |
| | **Enthusiastic** | 平均=R₁,P(θ<R₀)=5% | 樂觀,相信藥效 | |
| |
| **Beta-Binomial 共軛更新**: |
| ``` |
| 事前:Beta(α, β) + 數據:n人中r人成功 → 事後:Beta(α+r, β+n-r) |
| ``` |
| |
| 📚 *Tan SB et al. Br J Cancer. 2002;86(6):843-850.* |
| """) |
| |
| gr.Markdown("---") |
| |
| |
| with gr.Row(): |
| with gr.Column(scale=1): |
| gr.Markdown("### ⚙️ 試驗設定") |
| |
| trial_type = gr.Radio( |
| choices=["Trial C (初治組)", "Trial P (曾治療組)"], |
| value="Trial C (初治組)", |
| label="選擇試驗類型" |
| ) |
| |
| gr.Markdown("### 📝 輸入試驗數據") |
| gr.Markdown("*請根據論文填入數據,或點下方按鈕載入*") |
| |
| with gr.Row(): |
| s1_success = gr.Number(value=None, label="Stage I 成功人數", precision=0) |
| s1_total = gr.Number(value=None, label="Stage I 總人數", precision=0) |
| |
| with gr.Row(): |
| s2_success = gr.Number(value=None, label="Stage II 成功人數", precision=0) |
| s2_total = gr.Number(value=None, label="Stage II 總人數", precision=0) |
| |
| gr.Markdown("### 🔮 選擇事前分佈") |
| |
| prior_type = gr.Radio( |
| choices=[ |
| "Clinical Prior(臨床事前分佈)", |
| "Reference Prior(參考事前分佈)", |
| "Sceptical Prior(懷疑性事前分佈)", |
| "Enthusiastic Prior(樂觀性事前分佈)", |
| "Custom(自訂)" |
| ], |
| value="Clinical Prior(臨床事前分佈)", |
| label="事前分佈類型" |
| ) |
| |
| with gr.Row(visible=False) as custom_row: |
| custom_a = gr.Number(value=1, label="自訂 α", precision=1) |
| custom_b = gr.Number(value=1, label="自訂 β", precision=1) |
| |
| |
| gr.Markdown("### 📚 載入論文數據(或自己填完後對答案)") |
| with gr.Row(): |
| load_c_btn = gr.Button("📥 載入 Trial C 數據", size="sm") |
| load_p_btn = gr.Button("📥 載入 Trial P 數據", size="sm") |
| |
| btn = gr.Button("🚀 執行分析", variant="primary", size="lg") |
| |
| with gr.Column(scale=2): |
| gr.Markdown("### 📊 分析結果") |
| output_text = gr.Markdown() |
| output_plot = gr.Plot() |
| |
| |
| def load_trial_c(): |
| return "Trial C (初治組)", 3, 15, 4, 10 |
| |
| def load_trial_p(): |
| return "Trial P (曾治療組)", 7, 13, 6, 14 |
| |
| load_c_btn.click(load_trial_c, outputs=[trial_type, s1_success, s1_total, s2_success, s2_total]) |
| load_p_btn.click(load_trial_p, outputs=[trial_type, s1_success, s1_total, s2_success, s2_total]) |
| |
| |
| def toggle_custom(prior): |
| return gr.Row(visible=(prior == "Custom(自訂)")) |
| |
| prior_type.change(toggle_custom, prior_type, custom_row) |
| |
| |
| btn.click( |
| fn=analyze, |
| inputs=[trial_type, s1_success, s1_total, s2_success, s2_total, |
| prior_type, custom_a, custom_b], |
| outputs=[output_text, output_plot, analysis_summary] |
| ) |
| |
| |
| gr.Markdown("---") |
| gr.Markdown("### 🤖 AI 助教解釋") |
| |
| with gr.Row(): |
| with gr.Column(scale=2): |
| user_question = gr.Textbox( |
| label="你的問題(選填)", |
| placeholder="例如:為什麼 Sceptical Prior 和 Enthusiastic Prior 的結論會不同?", |
| lines=2 |
| ) |
| with gr.Column(scale=1): |
| api_key = gr.Textbox( |
| label="OpenAI API Key", |
| placeholder="sk-...", |
| type="password" |
| ) |
| |
| ask_ai_btn = gr.Button("🧠 請 AI 助教解釋", variant="secondary") |
| ai_response = gr.Markdown() |
| |
| ask_ai_btn.click( |
| fn=get_llm_explanation, |
| inputs=[analysis_summary, user_question, api_key], |
| outputs=[ai_response] |
| ) |
| |
| |
| gr.Markdown("---") |
| with gr.Accordion("✏️ 論文導讀問題", open=False): |
| gr.Markdown(""" |
| ### 問題 1:頻率學派 vs 貝氏方法 |
| 論文中提到「The conclusions drawn using the Bayesian approach were in general agreement with those obtained from the frequentist analysis」,但貝氏方法有什麼額外的優點? |
| |
| ### 問題 2:事前分佈的影響 |
| 比較 Trial C 使用 Sceptical Prior 和 Enthusiastic Prior 的結果(論文 Table 1),為什麼 P(θ>R₁) 的值差異很大? |
| |
| ### 問題 3:Trial C vs Trial P |
| Trial P 的結果(48% 反應率)比 Trial C(28%)好很多,論文作者如何解釋這個現象? |
| |
| ### 問題 4:實務應用 |
| 如果你是藥廠的統計師,你會選擇哪種事前分佈來分析結果?為什麼? |
| """) |
| |
| |
| gr.Markdown(""" |
| --- |
| <center> |
| 🔬 Simon Two-stage Design 教學工具<br> |
| Based on Tan et al. (2002) British Journal of Cancer<br> |
| <small>Powered by Gradio + OpenAI</small> |
| </center> |
| """) |
|
|
| if __name__ == "__main__": |
| demo.launch() |