Upload 6 files
Browse files- README.md +177 -10
- app.py +540 -0
- mcnemar_core.py +241 -0
- mcnemar_llm_assistant.py +251 -0
- mcnemar_utils.py +338 -0
- requirements.txt +6 -0
README.md
CHANGED
|
@@ -1,12 +1,179 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
| 1 |
+
# McNemar 檢定分析系統 - 寶可夢對戰特徵分析
|
| 2 |
+
|
| 3 |
+
## 📋 系統簡介
|
| 4 |
+
|
| 5 |
+
這是一個基於 Streamlit 的 McNemar 檢定分析系統,專為分析寶可夢對戰數據設計,結合 AI 助手提供統計解釋和對戰策略建議。
|
| 6 |
+
|
| 7 |
+
## 🎯 主要功能
|
| 8 |
+
|
| 9 |
+
### 1. McNemar 檢定分析
|
| 10 |
+
- ✅ 統計顯著性檢定(p 值)
|
| 11 |
+
- ✅ 勝算比 (Odds Ratio) 計算
|
| 12 |
+
- ✅ 95% 信賴區間估計
|
| 13 |
+
- ✅ 不一致配對分析
|
| 14 |
+
- ✅ 效果大小評估
|
| 15 |
+
|
| 16 |
+
### 2. 視覺化圖表
|
| 17 |
+
- 📊 列聯表熱力圖
|
| 18 |
+
- 📈 勝算比森林圖
|
| 19 |
+
- 📉 不一致配對分布圖
|
| 20 |
+
- 🎨 顯著性水準視覺化
|
| 21 |
+
|
| 22 |
+
### 3. AI 智能助手
|
| 23 |
+
- 💬 自然語言對話
|
| 24 |
+
- 📖 統計指標解釋
|
| 25 |
+
- 🎮 對戰策略建議
|
| 26 |
+
- 📚 McNemar 檢定教學
|
| 27 |
+
- 🔍 結果深度分析
|
| 28 |
+
|
| 29 |
+
## 📦 安裝步驟
|
| 30 |
+
|
| 31 |
+
### 1. 安裝依賴套件
|
| 32 |
+
```bash
|
| 33 |
+
pip install -r requirements.txt
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### 2. 準備資料
|
| 37 |
+
將寶可夢對戰資料 CSV 檔放在同一目錄下,檔名為 `poke_mc_hong_2.csv`
|
| 38 |
+
|
| 39 |
+
**資料格式要求:**
|
| 40 |
+
- 必須包含 `cs_特徵名稱` 和 `cn_特徵名稱` 欄位
|
| 41 |
+
- cs_ = 勝方 (Champion/Winner)
|
| 42 |
+
- cn_ = 敗方 (Challenger/Loser)
|
| 43 |
+
- 數值為 0 或 1 (0=較低, 1=較高)
|
| 44 |
+
|
| 45 |
+
**範例欄位:**
|
| 46 |
+
```
|
| 47 |
+
cs_HP, cn_HP # 血量
|
| 48 |
+
cs_Attack, cn_Attack # 攻擊
|
| 49 |
+
cs_Speed, cn_Speed # 速度
|
| 50 |
+
cs_Defense, cn_Defense # 防禦
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
### 3. 設定 OpenAI API Key
|
| 54 |
+
- 在系統左側邊欄輸入您的 OpenAI API Key
|
| 55 |
+
- API Key 用於 AI 助手功能
|
| 56 |
+
|
| 57 |
+
### 4. 執行程式
|
| 58 |
+
```bash
|
| 59 |
+
streamlit run mcnemar_app.py
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## 🔧 檔案結構
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
mcnemar_app/
|
| 66 |
+
├── mcnemar_app.py # Streamlit 主程式
|
| 67 |
+
├── mcnemar_core.py # McNemar 檢定核心邏輯
|
| 68 |
+
├── mcnemar_llm_assistant.py # AI 對話助手
|
| 69 |
+
├── mcnemar_utils.py # 視覺化工具
|
| 70 |
+
├── requirements.txt # 依賴套件
|
| 71 |
+
├── README.md # 說明文件
|
| 72 |
+
└── poke_mc_hong_2.csv # 寶可夢資料(需自行準備)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## 📊 使用方式
|
| 76 |
+
|
| 77 |
+
### Step 1: 載入資料
|
| 78 |
+
1. 選擇「使用預設資料集」或「上傳您的資料」
|
| 79 |
+
2. 如果上傳,請確保 CSV 格式正確
|
| 80 |
+
|
| 81 |
+
### Step 2: 執行分析
|
| 82 |
+
1. 在「McNemar 分析」頁面選擇要分析的特徵
|
| 83 |
+
2. 點擊「開始分析」按鈕
|
| 84 |
+
3. 查看結果的四個子頁面:
|
| 85 |
+
- 📊 概覽:關鍵指標和摘要
|
| 86 |
+
- 📉 列聯表:配對數據分析
|
| 87 |
+
- 🎯 勝算比:效果大小評估
|
| 88 |
+
- 📋 詳細報告:完整文字報告
|
| 89 |
+
|
| 90 |
+
### Step 3: 使用 AI 助手
|
| 91 |
+
1. 切換到「AI 助手」頁面
|
| 92 |
+
2. 在聊天框輸入問題,或點擊快速問題按鈕
|
| 93 |
+
3. AI 會根據分析結果提供解釋和建議
|
| 94 |
+
|
| 95 |
+
## 💡 統計指標說明
|
| 96 |
+
|
| 97 |
+
### McNemar 統計量
|
| 98 |
+
用於檢定配對資料中比例是否有差異的卡方統計量
|
| 99 |
+
|
| 100 |
+
### p 值 (p-value)
|
| 101 |
+
- p < 0.05:顯著(拒絕虛無假設)
|
| 102 |
+
- p ≥ 0.05:不顯著(無法拒絕虛無假設)
|
| 103 |
+
|
| 104 |
+
### 勝算比 (Odds Ratio)
|
| 105 |
+
- OR > 1:勝方更可能較高
|
| 106 |
+
- OR = 1:無差異
|
| 107 |
+
- OR < 1:敗方更可能較高
|
| 108 |
+
|
| 109 |
+
### 不一致配對
|
| 110 |
+
- **b**: 勝方高且敗方低的配對數
|
| 111 |
+
- **c**: 勝方低且敗方高的配對數
|
| 112 |
+
- McNemar 檢定只使用這些不一致的配對
|
| 113 |
+
|
| 114 |
+
## 🎮 應用場景
|
| 115 |
+
|
| 116 |
+
### 1. 特徵重要性分析
|
| 117 |
+
判斷哪些寶可夢特徵(HP、攻擊、速度等)對勝負影響最大
|
| 118 |
+
|
| 119 |
+
### 2. 組隊策略制定
|
| 120 |
+
根據統計結果選擇重要特徵較高的寶可夢
|
| 121 |
+
|
| 122 |
+
### 3. 對戰機制理解
|
| 123 |
+
理解不同特徵在實戰中的作用
|
| 124 |
+
|
| 125 |
+
### 4. 教學用途
|
| 126 |
+
學習 McNemar 檢定的原理和應用
|
| 127 |
+
|
| 128 |
+
## ⚙️ 技術架構
|
| 129 |
+
|
| 130 |
+
### 核心技術
|
| 131 |
+
- **Streamlit**: Web 應用框架
|
| 132 |
+
- **pandas**: 資料處理
|
| 133 |
+
- **statsmodels**: McNemar 檢定
|
| 134 |
+
- **plotly**: 互動式視覺化
|
| 135 |
+
- **OpenAI GPT-4o-mini**: AI 助手
|
| 136 |
+
|
| 137 |
+
### 特色設計
|
| 138 |
+
- ✅ Session 隔離(多用戶支援)
|
| 139 |
+
- ✅ 執行緒安全
|
| 140 |
+
- ✅ 自動清理過期資料
|
| 141 |
+
- ✅ 響應式 UI 設計
|
| 142 |
+
- ✅ 完整錯誤處理
|
| 143 |
+
|
| 144 |
+
## 🔒 隱私與安全
|
| 145 |
+
|
| 146 |
+
- 所有分析在本地執行
|
| 147 |
+
- Session 資料獨立儲存
|
| 148 |
+
- 超過 1 小時自動清理
|
| 149 |
+
- API Key 不會被儲存
|
| 150 |
+
|
| 151 |
+
## 📝 範例問題(給 AI 助手)
|
| 152 |
+
|
| 153 |
+
- "為什麼這個特徵顯著/不顯著?"
|
| 154 |
+
- "勝算比 2.5 代表什麼意思?"
|
| 155 |
+
- "我該如何組建隊伍?"
|
| 156 |
+
- "不一致配對是什麼?"
|
| 157 |
+
- "McNemar 檢定和 t 檢定有什麼不同?"
|
| 158 |
+
- "這個結果對實戰有什麼啟示?"
|
| 159 |
+
|
| 160 |
+
## 🚀 未來功能規劃
|
| 161 |
+
|
| 162 |
+
- [ ] 批次分析多個特徵
|
| 163 |
+
- [ ] 特徵重要性排序
|
| 164 |
+
- [ ] RAG 文獻檢索系統
|
| 165 |
+
- [ ] 歷史分析紀錄
|
| 166 |
+
- [ ] 結果比較功能
|
| 167 |
+
- [ ] 匯出 PDF 報告
|
| 168 |
+
|
| 169 |
+
## 📧 聯絡資訊
|
| 170 |
+
|
| 171 |
+
如有問題或建議,歡迎聯繫開發團隊。
|
| 172 |
+
|
| 173 |
+
## 📄 授權
|
| 174 |
+
|
| 175 |
+
本專案僅供學術研究和教學使用。
|
| 176 |
+
|
| 177 |
---
|
| 178 |
|
| 179 |
+
**Powered by Streamlit & OpenAI GPT-4o-mini** 🚀
|
app.py
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
import atexit
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# 頁面配置
|
| 9 |
+
st.set_page_config(
|
| 10 |
+
page_title="McNemar Test Analysis - Pokémon Battles",
|
| 11 |
+
page_icon="⚔️",
|
| 12 |
+
layout="wide",
|
| 13 |
+
initial_sidebar_state="expanded"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# 自定義 CSS
|
| 17 |
+
st.markdown("""
|
| 18 |
+
<style>
|
| 19 |
+
.streamlit-expanderHeader {
|
| 20 |
+
background-color: #e8f1f8;
|
| 21 |
+
border: 1px solid #b0cfe8;
|
| 22 |
+
border-radius: 5px;
|
| 23 |
+
font-weight: 600;
|
| 24 |
+
color: #1b4f72;
|
| 25 |
+
}
|
| 26 |
+
.streamlit-expanderHeader:hover {
|
| 27 |
+
background-color: #d0e7f8;
|
| 28 |
+
}
|
| 29 |
+
.stMetric {
|
| 30 |
+
background-color: #f8fbff;
|
| 31 |
+
padding: 10px;
|
| 32 |
+
border-radius: 5px;
|
| 33 |
+
border: 1px solid #d0e4f5;
|
| 34 |
+
}
|
| 35 |
+
.stButton > button {
|
| 36 |
+
width: 100%;
|
| 37 |
+
border-radius: 20px;
|
| 38 |
+
font-weight: 600;
|
| 39 |
+
transition: all 0.3s ease;
|
| 40 |
+
}
|
| 41 |
+
.stButton > button:hover {
|
| 42 |
+
transform: translateY(-2px);
|
| 43 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
| 44 |
+
}
|
| 45 |
+
.success-box {
|
| 46 |
+
background-color: #d4edda;
|
| 47 |
+
border: 1px solid #c3e6cb;
|
| 48 |
+
border-radius: 5px;
|
| 49 |
+
padding: 10px;
|
| 50 |
+
margin: 10px 0;
|
| 51 |
+
}
|
| 52 |
+
.warning-box {
|
| 53 |
+
background-color: #fff3cd;
|
| 54 |
+
border: 1px solid #ffeaa7;
|
| 55 |
+
border-radius: 5px;
|
| 56 |
+
padding: 10px;
|
| 57 |
+
margin: 10px 0;
|
| 58 |
+
}
|
| 59 |
+
</style>
|
| 60 |
+
""", unsafe_allow_html=True)
|
| 61 |
+
|
| 62 |
+
# 導入自定義模組
|
| 63 |
+
from mcnemar_core import McNemarAnalyzer
|
| 64 |
+
from mcnemar_llm_assistant import McNemarLLMAssistant
|
| 65 |
+
from mcnemar_utils import (
|
| 66 |
+
plot_contingency_table_heatmap,
|
| 67 |
+
plot_odds_ratio_forest,
|
| 68 |
+
plot_discordant_pairs,
|
| 69 |
+
plot_p_value_significance,
|
| 70 |
+
create_results_summary_table,
|
| 71 |
+
export_results_to_text
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# 清理函數
|
| 75 |
+
def cleanup_old_sessions():
|
| 76 |
+
"""清理超過 1 小時的 session"""
|
| 77 |
+
current_time = datetime.now()
|
| 78 |
+
for session_id in list(McNemarAnalyzer._session_results.keys()):
|
| 79 |
+
result = McNemarAnalyzer._session_results.get(session_id)
|
| 80 |
+
if result:
|
| 81 |
+
result_time = datetime.fromisoformat(result['timestamp'])
|
| 82 |
+
if current_time - result_time > timedelta(hours=1):
|
| 83 |
+
McNemarAnalyzer.clear_session_results(session_id)
|
| 84 |
+
|
| 85 |
+
# 註冊清理函數
|
| 86 |
+
atexit.register(cleanup_old_sessions)
|
| 87 |
+
|
| 88 |
+
# 初始化 session state
|
| 89 |
+
if 'session_id' not in st.session_state:
|
| 90 |
+
st.session_state.session_id = str(uuid.uuid4())
|
| 91 |
+
if 'analysis_results' not in st.session_state:
|
| 92 |
+
st.session_state.analysis_results = None
|
| 93 |
+
if 'chat_history' not in st.session_state:
|
| 94 |
+
st.session_state.chat_history = []
|
| 95 |
+
if 'analyzer' not in st.session_state:
|
| 96 |
+
st.session_state.analyzer = None
|
| 97 |
+
|
| 98 |
+
# 標題
|
| 99 |
+
st.title("⚔️ McNemar Test Analysis System")
|
| 100 |
+
st.markdown("### 寶可夢對戰特徵顯著性分析")
|
| 101 |
+
st.markdown("---")
|
| 102 |
+
|
| 103 |
+
# Sidebar
|
| 104 |
+
with st.sidebar:
|
| 105 |
+
st.header("⚙️ 配置設定")
|
| 106 |
+
|
| 107 |
+
# OpenAI API Key
|
| 108 |
+
api_key = st.text_input(
|
| 109 |
+
"OpenAI API Key",
|
| 110 |
+
type="password",
|
| 111 |
+
help="輸入您的 OpenAI API key 以使用 AI 助手"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if api_key:
|
| 115 |
+
st.session_state.api_key = api_key
|
| 116 |
+
st.success("✅ API Key 已載入")
|
| 117 |
+
|
| 118 |
+
st.markdown("---")
|
| 119 |
+
|
| 120 |
+
# 清理按鈕
|
| 121 |
+
if st.button("🧹 清理過期資料"):
|
| 122 |
+
cleanup_old_sessions()
|
| 123 |
+
st.success("✅ 清理完成")
|
| 124 |
+
st.rerun()
|
| 125 |
+
|
| 126 |
+
st.markdown("---")
|
| 127 |
+
|
| 128 |
+
# 資料來源選擇
|
| 129 |
+
st.subheader("📊 資料來源")
|
| 130 |
+
data_source = st.radio(
|
| 131 |
+
"選擇資料來源:",
|
| 132 |
+
["使用預設資料集", "上傳您的資料"]
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
uploaded_file = None
|
| 136 |
+
if data_source == "上傳您的資料":
|
| 137 |
+
uploaded_file = st.file_uploader(
|
| 138 |
+
"上傳 CSV 檔案",
|
| 139 |
+
type=['csv'],
|
| 140 |
+
help="上傳寶可夢對戰資料(需包含 cs_特徵 和 cn_特徵 欄位)"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
with st.expander("📖 資料格式說明"):
|
| 144 |
+
st.markdown("""
|
| 145 |
+
**必要欄位格式:**
|
| 146 |
+
- `cs_特徵名稱`: 勝方的特徵值 (0 或 1)
|
| 147 |
+
- `cn_特徵名稱`: 敗方的特徵值 (0 或 1)
|
| 148 |
+
|
| 149 |
+
**範例:**
|
| 150 |
+
- `cs_HP`: 勝方 HP 是否較高
|
| 151 |
+
- `cn_HP`: 敗方 HP 是否較高
|
| 152 |
+
- `cs_Attack`: 勝方攻擊是否較高
|
| 153 |
+
- `cn_Attack`: 敗方攻擊是否較高
|
| 154 |
+
|
| 155 |
+
**數值含義:**
|
| 156 |
+
- 1 = 較高/較快/較重
|
| 157 |
+
- 0 = 較低/較慢/較輕
|
| 158 |
+
""")
|
| 159 |
+
|
| 160 |
+
st.markdown("---")
|
| 161 |
+
|
| 162 |
+
# 關於系統
|
| 163 |
+
with st.expander("ℹ️ 關於此系統"):
|
| 164 |
+
st.markdown("""
|
| 165 |
+
**McNemar 檢定分析系統**
|
| 166 |
+
|
| 167 |
+
本系統使用 McNemar 檢定來分析寶可夢對戰中,
|
| 168 |
+
勝方與敗方在各項特徵上是否有顯著差異。
|
| 169 |
+
|
| 170 |
+
**主要功能:**
|
| 171 |
+
- 🔬 統計顯著性檢定
|
| 172 |
+
- 📊 勝算比分析
|
| 173 |
+
- 📈 視覺化圖表
|
| 174 |
+
- 💬 AI 助手解釋
|
| 175 |
+
- 🎮 對戰策略建議
|
| 176 |
+
|
| 177 |
+
**適用場景:**
|
| 178 |
+
- 分析哪些特徵對勝負影響最大
|
| 179 |
+
- 理解特徵重要性排序
|
| 180 |
+
- 制定組隊策略
|
| 181 |
+
""")
|
| 182 |
+
|
| 183 |
+
# 主要內容區 - 雙 Tab
|
| 184 |
+
tab1, tab2 = st.tabs(["📊 McNemar 分析", "💬 AI 助手"])
|
| 185 |
+
|
| 186 |
+
# Tab 1: McNemar 分析
|
| 187 |
+
with tab1:
|
| 188 |
+
st.header("📊 McNemar 檢定分析")
|
| 189 |
+
|
| 190 |
+
# 載入資料
|
| 191 |
+
if data_source == "使用預設資料集":
|
| 192 |
+
# 檢查預設資料是否存在
|
| 193 |
+
default_data_path = "poke_mc_hong_2.csv"
|
| 194 |
+
if os.path.exists(default_data_path):
|
| 195 |
+
df = pd.read_csv(default_data_path)
|
| 196 |
+
st.success(f"✅ 已載入預設資料集({len(df)} 筆對戰記錄)")
|
| 197 |
+
else:
|
| 198 |
+
st.warning("⚠️ 找不到預設資料集,請上傳您的資料")
|
| 199 |
+
df = None
|
| 200 |
+
else:
|
| 201 |
+
if uploaded_file is not None:
|
| 202 |
+
df = pd.read_csv(uploaded_file)
|
| 203 |
+
st.success(f"✅ 已載入資料({len(df)} 筆對戰記錄)")
|
| 204 |
+
else:
|
| 205 |
+
df = None
|
| 206 |
+
st.info("📁 請在左側上傳 CSV 檔案")
|
| 207 |
+
|
| 208 |
+
if df is not None:
|
| 209 |
+
# 初始化分析器
|
| 210 |
+
if st.session_state.analyzer is None:
|
| 211 |
+
st.session_state.analyzer = McNemarAnalyzer(st.session_state.session_id)
|
| 212 |
+
st.session_state.analyzer.load_data(df)
|
| 213 |
+
|
| 214 |
+
# 取得可用特徵
|
| 215 |
+
available_features = st.session_state.analyzer.get_available_features()
|
| 216 |
+
|
| 217 |
+
if not available_features:
|
| 218 |
+
st.error("❌ 資料中找不到有效的特徵欄位(需要 cs_* 和 cn_* 格式)")
|
| 219 |
+
else:
|
| 220 |
+
# 參數設定區
|
| 221 |
+
st.subheader("🎯 選擇分析特徵")
|
| 222 |
+
|
| 223 |
+
col1, col2 = st.columns([3, 1])
|
| 224 |
+
|
| 225 |
+
with col1:
|
| 226 |
+
selected_feature = st.selectbox(
|
| 227 |
+
"選擇要分析的特徵:",
|
| 228 |
+
options=available_features,
|
| 229 |
+
format_func=lambda x: McNemarAnalyzer.FEATURE_LABELS.get(x, x),
|
| 230 |
+
help="選擇一個特徵來比較勝方與敗方的差異"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
with col2:
|
| 234 |
+
st.markdown("<br>", unsafe_allow_html=True)
|
| 235 |
+
analyze_button = st.button("🔬 開始分析", type="primary", use_container_width=True)
|
| 236 |
+
|
| 237 |
+
# 執行分析
|
| 238 |
+
if analyze_button:
|
| 239 |
+
with st.spinner("分析中..."):
|
| 240 |
+
try:
|
| 241 |
+
results = st.session_state.analyzer.run_analysis(selected_feature)
|
| 242 |
+
st.session_state.analysis_results = results
|
| 243 |
+
st.success("✅ 分析完成!")
|
| 244 |
+
except Exception as e:
|
| 245 |
+
st.error(f"❌ 分析失敗: {str(e)}")
|
| 246 |
+
|
| 247 |
+
# 顯示結果
|
| 248 |
+
if st.session_state.analysis_results:
|
| 249 |
+
results = st.session_state.analysis_results
|
| 250 |
+
|
| 251 |
+
st.markdown("---")
|
| 252 |
+
st.subheader(f"📈 分析結果:{results['feature_label']}")
|
| 253 |
+
|
| 254 |
+
# 建立結果 tabs
|
| 255 |
+
result_tabs = st.tabs([
|
| 256 |
+
"📊 概覽",
|
| 257 |
+
"📉 列聯表",
|
| 258 |
+
"🎯 勝算比",
|
| 259 |
+
"📋 詳細報告"
|
| 260 |
+
])
|
| 261 |
+
|
| 262 |
+
# Tab: 概覽
|
| 263 |
+
with result_tabs[0]:
|
| 264 |
+
# 關鍵指標
|
| 265 |
+
metric_cols = st.columns(4)
|
| 266 |
+
|
| 267 |
+
# 顯著性指示
|
| 268 |
+
sig_emoji = "✅" if results['interpretation']['is_significant'] else "⚠️"
|
| 269 |
+
metric_cols[0].metric(
|
| 270 |
+
"統計顯著性",
|
| 271 |
+
results['interpretation']['significance'].split('(')[0].strip(),
|
| 272 |
+
sig_emoji
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
metric_cols[1].metric(
|
| 276 |
+
"p 值",
|
| 277 |
+
f"{results['p_value']:.4f}",
|
| 278 |
+
"顯著" if results['p_value'] < 0.05 else "不顯著"
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
metric_cols[2].metric(
|
| 282 |
+
"勝算比 (OR)",
|
| 283 |
+
f"{results['odds_ratio']:.3f}",
|
| 284 |
+
results['interpretation']['effect_size'].split('(')[0].strip()
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
metric_cols[3].metric(
|
| 288 |
+
"不一致配對數",
|
| 289 |
+
results['discordant_n'],
|
| 290 |
+
f"b={results['discordant_b']}, c={results['discordant_c']}"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
st.markdown("---")
|
| 294 |
+
|
| 295 |
+
# 摘要表格
|
| 296 |
+
st.markdown("### 📝 結果摘要")
|
| 297 |
+
summary_df = create_results_summary_table(results)
|
| 298 |
+
st.dataframe(summary_df, use_container_width=True, hide_index=True)
|
| 299 |
+
|
| 300 |
+
st.markdown("---")
|
| 301 |
+
|
| 302 |
+
# 視覺化:p 值顯著性
|
| 303 |
+
st.markdown("### 🎨 顯著性水準視覺化")
|
| 304 |
+
p_value_fig = plot_p_value_significance(results['p_value'])
|
| 305 |
+
st.plotly_chart(p_value_fig, use_container_width=True)
|
| 306 |
+
|
| 307 |
+
# Tab: 列聯表
|
| 308 |
+
with result_tabs[1]:
|
| 309 |
+
st.markdown("### 📊 配對列聯表")
|
| 310 |
+
|
| 311 |
+
col1, col2 = st.columns([1, 1])
|
| 312 |
+
|
| 313 |
+
with col1:
|
| 314 |
+
st.dataframe(
|
| 315 |
+
results['contingency_table_labeled'],
|
| 316 |
+
use_container_width=True
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
with col2:
|
| 320 |
+
heatmap_fig = plot_contingency_table_heatmap(
|
| 321 |
+
results['contingency_table_labeled'],
|
| 322 |
+
results['feature_label']
|
| 323 |
+
)
|
| 324 |
+
st.plotly_chart(heatmap_fig, use_container_width=True)
|
| 325 |
+
|
| 326 |
+
st.markdown("---")
|
| 327 |
+
|
| 328 |
+
# 不一致配對分析
|
| 329 |
+
st.markdown("### 🔍 不一致配對分析")
|
| 330 |
+
|
| 331 |
+
st.info(f"""
|
| 332 |
+
**不一致配對** 是 McNemar 檢定的關鍵:
|
| 333 |
+
- **b = {results['discordant_b']}**: 勝方{results['label_pos'].split()[1]}且敗方{results['label_neg'].split()[1]}的配對數
|
| 334 |
+
- **c = {results['discordant_c']}**: 勝方{results['label_neg'].split()[1]}且敗方{results['label_pos'].split()[1]}的配對數
|
| 335 |
+
|
| 336 |
+
McNemar 檢定只使用這些不一致的配對來判斷是否有顯著差異。
|
| 337 |
+
""")
|
| 338 |
+
|
| 339 |
+
discordant_fig = plot_discordant_pairs(
|
| 340 |
+
results['discordant_b'],
|
| 341 |
+
results['discordant_c'],
|
| 342 |
+
results['label_pos'],
|
| 343 |
+
results['label_neg']
|
| 344 |
+
)
|
| 345 |
+
st.plotly_chart(discordant_fig, use_container_width=True)
|
| 346 |
+
|
| 347 |
+
# Tab: 勝算比
|
| 348 |
+
with result_tabs[2]:
|
| 349 |
+
st.markdown("### 🎯 勝算比 (Odds Ratio) 分析")
|
| 350 |
+
|
| 351 |
+
or_fig = plot_odds_ratio_forest(
|
| 352 |
+
results['odds_ratio'],
|
| 353 |
+
results['ci_low'],
|
| 354 |
+
results['ci_high'],
|
| 355 |
+
results['feature_label']
|
| 356 |
+
)
|
| 357 |
+
st.plotly_chart(or_fig, use_container_width=True)
|
| 358 |
+
|
| 359 |
+
st.markdown("---")
|
| 360 |
+
|
| 361 |
+
# 解釋勝算比
|
| 362 |
+
st.markdown("### 📖 勝算比解釋")
|
| 363 |
+
|
| 364 |
+
if results['odds_ratio'] > 1:
|
| 365 |
+
interpretation = f"""
|
| 366 |
+
勝算比為 **{results['odds_ratio']:.3f}**,表示:
|
| 367 |
+
- 勝方在 **{results['feature_label']}** 上較高的機率是敗方的 **{results['odds_ratio']:.2f} 倍**
|
| 368 |
+
- 95% 信賴區間: [{results['ci_low']:.3f}, {results['ci_high']:.3f}]
|
| 369 |
+
- 效果大小: {results['interpretation']['effect_size']}
|
| 370 |
+
|
| 371 |
+
**結論**:{results['feature_label']} 較高的寶可夢更容易獲勝。
|
| 372 |
+
"""
|
| 373 |
+
elif results['odds_ratio'] < 1:
|
| 374 |
+
interpretation = f"""
|
| 375 |
+
勝算比為 **{results['odds_ratio']:.3f}**,表示:
|
| 376 |
+
- 敗方在 **{results['feature_label']}** 上較高的機率是勝方的 **{1/results['odds_ratio']:.2f} 倍**
|
| 377 |
+
- 95% 信賴區間: [{results['ci_low']:.3f}, {results['ci_high']:.3f}]
|
| 378 |
+
- 效果大小: {results['interpretation']['effect_size']}
|
| 379 |
+
|
| 380 |
+
**結論**:{results['feature_label']} 較低的寶可夢反而更容易獲勝(這很少見!)。
|
| 381 |
+
"""
|
| 382 |
+
else:
|
| 383 |
+
interpretation = f"""
|
| 384 |
+
勝算比為 **1.0**,表示:
|
| 385 |
+
- 勝方和敗方在 **{results['feature_label']}** 上沒有差異
|
| 386 |
+
- 此特徵對勝負沒有影響
|
| 387 |
+
"""
|
| 388 |
+
|
| 389 |
+
st.markdown(interpretation)
|
| 390 |
+
|
| 391 |
+
# Tab: 詳細報告
|
| 392 |
+
with result_tabs[3]:
|
| 393 |
+
st.markdown("### 📋 完整分析報告")
|
| 394 |
+
|
| 395 |
+
# 生成文字報告
|
| 396 |
+
text_report = export_results_to_text(results)
|
| 397 |
+
|
| 398 |
+
st.text_area(
|
| 399 |
+
"報告內容",
|
| 400 |
+
text_report,
|
| 401 |
+
height=400
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
# 下載按鈕
|
| 405 |
+
st.download_button(
|
| 406 |
+
label="📥 下載完整報告 (.txt)",
|
| 407 |
+
data=text_report,
|
| 408 |
+
file_name=f"mcnemar_report_{results['feature_name']}_{results['timestamp'][:10]}.txt",
|
| 409 |
+
mime="text/plain"
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# Tab 2: AI 助手
|
| 413 |
+
with tab2:
|
| 414 |
+
st.header("💬 AI 分析助手")
|
| 415 |
+
|
| 416 |
+
if not st.session_state.get('api_key'):
|
| 417 |
+
st.warning("⚠️ 請在左側輸入您的 OpenAI API Key 以使用 AI 助手")
|
| 418 |
+
elif st.session_state.analysis_results is None:
|
| 419 |
+
st.info("ℹ️ 請先在「McNemar 分析」頁面執行分析")
|
| 420 |
+
else:
|
| 421 |
+
# 初始化 LLM 助手
|
| 422 |
+
if 'llm_assistant' not in st.session_state:
|
| 423 |
+
st.session_state.llm_assistant = McNemarLLMAssistant(
|
| 424 |
+
api_key=st.session_state.api_key,
|
| 425 |
+
session_id=st.session_state.session_id
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
# 聊天容器
|
| 429 |
+
chat_container = st.container()
|
| 430 |
+
|
| 431 |
+
with chat_container:
|
| 432 |
+
for message in st.session_state.chat_history:
|
| 433 |
+
with st.chat_message(message["role"]):
|
| 434 |
+
st.markdown(message["content"])
|
| 435 |
+
|
| 436 |
+
# 使用者輸入
|
| 437 |
+
if prompt := st.chat_input("詢問關於分析結果的任何問題..."):
|
| 438 |
+
# 添加使用者訊息
|
| 439 |
+
st.session_state.chat_history.append({
|
| 440 |
+
"role": "user",
|
| 441 |
+
"content": prompt
|
| 442 |
+
})
|
| 443 |
+
|
| 444 |
+
with st.chat_message("user"):
|
| 445 |
+
st.markdown(prompt)
|
| 446 |
+
|
| 447 |
+
# AI 回應
|
| 448 |
+
with st.chat_message("assistant"):
|
| 449 |
+
with st.spinner("思考中..."):
|
| 450 |
+
try:
|
| 451 |
+
response = st.session_state.llm_assistant.get_response(
|
| 452 |
+
user_message=prompt,
|
| 453 |
+
analysis_results=st.session_state.analysis_results
|
| 454 |
+
)
|
| 455 |
+
st.markdown(response)
|
| 456 |
+
except Exception as e:
|
| 457 |
+
error_msg = f"❌ 錯誤: {str(e)}\n\n請檢查 API key 或重新表達問題。"
|
| 458 |
+
st.error(error_msg)
|
| 459 |
+
response = error_msg
|
| 460 |
+
|
| 461 |
+
# 添加助手回應
|
| 462 |
+
st.session_state.chat_history.append({
|
| 463 |
+
"role": "assistant",
|
| 464 |
+
"content": response
|
| 465 |
+
})
|
| 466 |
+
|
| 467 |
+
st.markdown("---")
|
| 468 |
+
|
| 469 |
+
# 快速問題按鈕
|
| 470 |
+
st.subheader("💡 快速問題")
|
| 471 |
+
|
| 472 |
+
quick_questions = [
|
| 473 |
+
"📊 給我這次分析的總結",
|
| 474 |
+
"🎯 解釋 p 值的意義",
|
| 475 |
+
"🔍 解釋勝算比",
|
| 476 |
+
"⚔️ 這對對戰策略有什麼啟示?",
|
| 477 |
+
"❓ 什麼是 McNemar 檢定?"
|
| 478 |
+
]
|
| 479 |
+
|
| 480 |
+
cols = st.columns(len(quick_questions))
|
| 481 |
+
for idx, (col, question) in enumerate(zip(cols, quick_questions)):
|
| 482 |
+
if col.button(question, key=f"quick_{idx}"):
|
| 483 |
+
# 根據問題選擇對應的方法
|
| 484 |
+
if "總結" in question:
|
| 485 |
+
response = st.session_state.llm_assistant.generate_summary(
|
| 486 |
+
st.session_state.analysis_results
|
| 487 |
+
)
|
| 488 |
+
elif "p 值" in question:
|
| 489 |
+
response = st.session_state.llm_assistant.explain_metric(
|
| 490 |
+
'p_value',
|
| 491 |
+
st.session_state.analysis_results
|
| 492 |
+
)
|
| 493 |
+
elif "勝算比" in question:
|
| 494 |
+
response = st.session_state.llm_assistant.explain_metric(
|
| 495 |
+
'odds_ratio',
|
| 496 |
+
st.session_state.analysis_results
|
| 497 |
+
)
|
| 498 |
+
elif "策略" in question:
|
| 499 |
+
response = st.session_state.llm_assistant.battle_strategy_advice(
|
| 500 |
+
st.session_state.analysis_results
|
| 501 |
+
)
|
| 502 |
+
elif "McNemar" in question:
|
| 503 |
+
response = st.session_state.llm_assistant.explain_mcnemar_test()
|
| 504 |
+
else:
|
| 505 |
+
response = st.session_state.llm_assistant.get_response(
|
| 506 |
+
question,
|
| 507 |
+
st.session_state.analysis_results
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
st.session_state.chat_history.append({
|
| 511 |
+
"role": "user",
|
| 512 |
+
"content": question
|
| 513 |
+
})
|
| 514 |
+
|
| 515 |
+
st.session_state.chat_history.append({
|
| 516 |
+
"role": "assistant",
|
| 517 |
+
"content": response
|
| 518 |
+
})
|
| 519 |
+
|
| 520 |
+
st.rerun()
|
| 521 |
+
|
| 522 |
+
# 重置對話按鈕
|
| 523 |
+
st.markdown("---")
|
| 524 |
+
if st.button("🔄 重置對話"):
|
| 525 |
+
st.session_state.llm_assistant.reset_conversation()
|
| 526 |
+
st.session_state.chat_history = []
|
| 527 |
+
st.success("✅ 對話已重置")
|
| 528 |
+
st.rerun()
|
| 529 |
+
|
| 530 |
+
# Footer
|
| 531 |
+
st.markdown("---")
|
| 532 |
+
st.markdown(
|
| 533 |
+
f"""
|
| 534 |
+
<div style='text-align: center'>
|
| 535 |
+
<p>⚔️ McNemar Test Analysis System for Pokémon Battles | Built with Streamlit & OpenAI</p>
|
| 536 |
+
<p>Session ID: {st.session_state.session_id[:8]} | Powered by GPT-4o-mini</p>
|
| 537 |
+
</div>
|
| 538 |
+
""",
|
| 539 |
+
unsafe_allow_html=True
|
| 540 |
+
)
|
mcnemar_core.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import re
|
| 4 |
+
from statsmodels.stats.contingency_tables import mcnemar
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import threading
|
| 7 |
+
|
| 8 |
+
class McNemarAnalyzer:
|
| 9 |
+
"""
|
| 10 |
+
McNemar 檢定分析器
|
| 11 |
+
支援多用戶同時使用,每個 session 獨立處理
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# 類別級的鎖,用於執行緒安全
|
| 15 |
+
_lock = threading.Lock()
|
| 16 |
+
|
| 17 |
+
# 儲存各 session 的分析結果
|
| 18 |
+
_session_results = {}
|
| 19 |
+
|
| 20 |
+
# 特徵標籤對應
|
| 21 |
+
FEATURE_LABELS = {
|
| 22 |
+
'HP': 'HP(血量)',
|
| 23 |
+
'Attack': '攻擊',
|
| 24 |
+
'Defense': '防禦',
|
| 25 |
+
'SpAtk': '特攻',
|
| 26 |
+
'SpDef': '特防',
|
| 27 |
+
'Speed': '速度',
|
| 28 |
+
'height': '身高',
|
| 29 |
+
'weight': '體重',
|
| 30 |
+
'base_experience': '基礎經驗值',
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# 數值描述文字
|
| 34 |
+
DEFAULT_VALUE_TEXT = {1: '較高', 0: '較低'}
|
| 35 |
+
FEATURE_VALUE_TEXT = {
|
| 36 |
+
'HP': DEFAULT_VALUE_TEXT,
|
| 37 |
+
'Attack': DEFAULT_VALUE_TEXT,
|
| 38 |
+
'Defense': DEFAULT_VALUE_TEXT,
|
| 39 |
+
'SpAtk': DEFAULT_VALUE_TEXT,
|
| 40 |
+
'SpDef': DEFAULT_VALUE_TEXT,
|
| 41 |
+
'Speed': {1: '較快', 0: '較慢'},
|
| 42 |
+
'height': {1: '較高', 0: '較矮'},
|
| 43 |
+
'weight': {1: '較重', 0: '較輕'},
|
| 44 |
+
'base_experience': DEFAULT_VALUE_TEXT,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
def __init__(self, session_id):
|
| 48 |
+
"""
|
| 49 |
+
初始化分析器
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
session_id: 唯一的 session 識別碼
|
| 53 |
+
"""
|
| 54 |
+
self.session_id = session_id
|
| 55 |
+
self.df = None
|
| 56 |
+
|
| 57 |
+
def load_data(self, csv_path_or_df):
|
| 58 |
+
"""
|
| 59 |
+
載入資料
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
csv_path_or_df: CSV 檔案路徑或 DataFrame
|
| 63 |
+
"""
|
| 64 |
+
if isinstance(csv_path_or_df, str):
|
| 65 |
+
self.df = pd.read_csv(csv_path_or_df)
|
| 66 |
+
else:
|
| 67 |
+
self.df = csv_path_or_df.copy()
|
| 68 |
+
|
| 69 |
+
def get_available_features(self):
|
| 70 |
+
"""
|
| 71 |
+
取得可用的特徵列表
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
list: 特徵名稱列表
|
| 75 |
+
"""
|
| 76 |
+
if self.df is None:
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
# 找出所有 cs_ 開頭的欄位
|
| 80 |
+
cs_cols = [col for col in self.df.columns if col.startswith('cs_')]
|
| 81 |
+
# 提取特徵名稱(移除 cs_ 前綴)
|
| 82 |
+
features = [col.replace('cs_', '') for col in cs_cols]
|
| 83 |
+
|
| 84 |
+
return features
|
| 85 |
+
|
| 86 |
+
def run_analysis(self, feature_name):
|
| 87 |
+
"""
|
| 88 |
+
執行 McNemar 檢定分析
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
feature_name: 特徵名稱(例如 'HP', 'Attack')
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
dict: 包含所有分析結果的字典
|
| 95 |
+
"""
|
| 96 |
+
with self._lock:
|
| 97 |
+
try:
|
| 98 |
+
if self.df is None:
|
| 99 |
+
raise ValueError("請先載入資料")
|
| 100 |
+
|
| 101 |
+
# 1. 準備資料
|
| 102 |
+
feature_a = f"cs_{feature_name}" # Winner
|
| 103 |
+
feature_b = f"cn_{feature_name}" # Loser
|
| 104 |
+
|
| 105 |
+
if feature_a not in self.df.columns or feature_b not in self.df.columns:
|
| 106 |
+
raise ValueError(f"找不到特徵 {feature_name} 的欄位")
|
| 107 |
+
|
| 108 |
+
var_a = self.df[feature_a]
|
| 109 |
+
var_b = self.df[feature_b]
|
| 110 |
+
|
| 111 |
+
# 2. 建立列聯表(1 before 0)
|
| 112 |
+
var_a = pd.Categorical(var_a, categories=[1, 0], ordered=True)
|
| 113 |
+
var_b = pd.Categorical(var_b, categories=[1, 0], ordered=True)
|
| 114 |
+
|
| 115 |
+
ct = pd.crosstab(var_a, var_b)
|
| 116 |
+
ctm = ct.copy()
|
| 117 |
+
|
| 118 |
+
# 3. 執行 McNemar 檢定
|
| 119 |
+
result = mcnemar(ctm, exact=True, correction=True)
|
| 120 |
+
|
| 121 |
+
# 4. 計算勝算比 (Odds Ratio)
|
| 122 |
+
b = int(ctm.at[1, 0]) # 勝方高 & 敗方低
|
| 123 |
+
c = int(ctm.at[0, 1]) # 勝方低 & 敗方高
|
| 124 |
+
|
| 125 |
+
# Haldane-Anscombe correction(防止除以零)
|
| 126 |
+
bh = b + 0.5 if b == 0 or c == 0 else float(b)
|
| 127 |
+
ch = c + 0.5 if b == 0 or c == 0 else float(c)
|
| 128 |
+
|
| 129 |
+
or_ratio = bh / ch
|
| 130 |
+
ln_or = np.log(or_ratio)
|
| 131 |
+
se = np.sqrt(1.0 / bh + 1.0 / ch)
|
| 132 |
+
z = 1.96
|
| 133 |
+
ci_low = float(np.exp(ln_or - z * se))
|
| 134 |
+
ci_high = float(np.exp(ln_or + z * se))
|
| 135 |
+
n_discordant = b + c
|
| 136 |
+
|
| 137 |
+
# 5. 準備標籤
|
| 138 |
+
feature_label = self.FEATURE_LABELS.get(feature_name, feature_name)
|
| 139 |
+
label_pos, label_neg = self._get_value_labels(feature_name)
|
| 140 |
+
|
| 141 |
+
# 6. 準備列聯表(含標籤)
|
| 142 |
+
ct_labeled = self._create_labeled_table(ct, feature_name)
|
| 143 |
+
|
| 144 |
+
# 7. 整理結果
|
| 145 |
+
results = {
|
| 146 |
+
'feature_name': feature_name,
|
| 147 |
+
'feature_label': feature_label,
|
| 148 |
+
'contingency_table': ctm.to_dict(),
|
| 149 |
+
'contingency_table_labeled': ct_labeled,
|
| 150 |
+
'mcnemar_statistic': float(result.statistic),
|
| 151 |
+
'p_value': float(result.pvalue),
|
| 152 |
+
'odds_ratio': round(or_ratio, 3),
|
| 153 |
+
'ci_low': round(ci_low, 3),
|
| 154 |
+
'ci_high': round(ci_high, 3),
|
| 155 |
+
'discordant_b': b,
|
| 156 |
+
'discordant_c': c,
|
| 157 |
+
'discordant_n': n_discordant,
|
| 158 |
+
'label_pos': label_pos,
|
| 159 |
+
'label_neg': label_neg,
|
| 160 |
+
'interpretation': self._interpret_results(result.pvalue, or_ratio),
|
| 161 |
+
'timestamp': datetime.now().isoformat()
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
# 儲存到 session results
|
| 165 |
+
self._session_results[self.session_id] = results
|
| 166 |
+
|
| 167 |
+
return results
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
raise Exception(f"分析失敗: {str(e)}")
|
| 171 |
+
|
| 172 |
+
def _create_labeled_table(self, ct, feature_name):
|
| 173 |
+
"""建立帶標籤的列聯表"""
|
| 174 |
+
feature_label = self.FEATURE_LABELS.get(feature_name, feature_name)
|
| 175 |
+
mapping = self.FEATURE_VALUE_TEXT.get(feature_name, self.DEFAULT_VALUE_TEXT)
|
| 176 |
+
|
| 177 |
+
# 創建標籤
|
| 178 |
+
row_labels = [f"勝方 {mapping.get(1, '較高')}", f"勝方 {mapping.get(0, '較低')}"]
|
| 179 |
+
col_labels = [f"敗方 {mapping.get(1, '較高')}", f"敗方 {mapping.get(0, '較低')}"]
|
| 180 |
+
|
| 181 |
+
# 重建 DataFrame
|
| 182 |
+
labeled_table = pd.DataFrame(
|
| 183 |
+
ct.values,
|
| 184 |
+
index=row_labels,
|
| 185 |
+
columns=col_labels
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# 添加總計
|
| 189 |
+
labeled_table['總數'] = labeled_table.sum(axis=1)
|
| 190 |
+
labeled_table.loc['總數'] = labeled_table.sum()
|
| 191 |
+
|
| 192 |
+
return labeled_table
|
| 193 |
+
|
| 194 |
+
def _get_value_labels(self, feature_name):
|
| 195 |
+
"""取得特徵的數值標籤"""
|
| 196 |
+
name = self.FEATURE_LABELS.get(feature_name, feature_name)
|
| 197 |
+
m = self.FEATURE_VALUE_TEXT.get(feature_name, self.DEFAULT_VALUE_TEXT)
|
| 198 |
+
pos = f"{name} {m[1]}" # 1
|
| 199 |
+
neg = f"{name} {m[0]}" # 0
|
| 200 |
+
return pos, neg
|
| 201 |
+
|
| 202 |
+
def _interpret_results(self, p_value, odds_ratio):
|
| 203 |
+
"""解釋分析結果"""
|
| 204 |
+
# 顯著性判斷
|
| 205 |
+
if p_value < 0.001:
|
| 206 |
+
significance = "極顯著 (p < 0.001)"
|
| 207 |
+
elif p_value < 0.01:
|
| 208 |
+
significance = "非常顯著 (p < 0.01)"
|
| 209 |
+
elif p_value < 0.05:
|
| 210 |
+
significance = "顯著 (p < 0.05)"
|
| 211 |
+
else:
|
| 212 |
+
significance = "不顯著 (p ≥ 0.05)"
|
| 213 |
+
|
| 214 |
+
# 效果大小判斷
|
| 215 |
+
if odds_ratio > 2:
|
| 216 |
+
effect_size = "大效果 (OR > 2)"
|
| 217 |
+
elif odds_ratio > 1.5:
|
| 218 |
+
effect_size = "中等效果 (OR > 1.5)"
|
| 219 |
+
elif odds_ratio > 1:
|
| 220 |
+
effect_size = "小效果 (OR > 1)"
|
| 221 |
+
elif odds_ratio == 1:
|
| 222 |
+
effect_size = "無差異 (OR = 1)"
|
| 223 |
+
else:
|
| 224 |
+
effect_size = f"反向效果 (OR < 1)"
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
'significance': significance,
|
| 228 |
+
'effect_size': effect_size,
|
| 229 |
+
'is_significant': p_value < 0.05
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
@classmethod
|
| 233 |
+
def get_session_results(cls, session_id):
|
| 234 |
+
"""獲取特定 session 的結果"""
|
| 235 |
+
return cls._session_results.get(session_id)
|
| 236 |
+
|
| 237 |
+
@classmethod
|
| 238 |
+
def clear_session_results(cls, session_id):
|
| 239 |
+
"""清除特定 session 的結果"""
|
| 240 |
+
if session_id in cls._session_results:
|
| 241 |
+
del cls._session_results[session_id]
|
mcnemar_llm_assistant.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openai import OpenAI
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
class McNemarLLMAssistant:
|
| 5 |
+
"""
|
| 6 |
+
McNemar 檢定 LLM 問答助手
|
| 7 |
+
協助用戶理解 McNemar 檢定分析結果
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
def __init__(self, api_key, session_id):
|
| 11 |
+
"""
|
| 12 |
+
初始化 LLM 助手
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
api_key: OpenAI API key
|
| 16 |
+
session_id: 唯一的 session 識別碼
|
| 17 |
+
"""
|
| 18 |
+
self.client = OpenAI(api_key=api_key)
|
| 19 |
+
self.session_id = session_id
|
| 20 |
+
self.conversation_history = []
|
| 21 |
+
|
| 22 |
+
# 系統提示詞
|
| 23 |
+
self.system_prompt = """You are an expert statistician specializing in McNemar's test and paired categorical data analysis, particularly in the context of Pokémon battle statistics.
|
| 24 |
+
|
| 25 |
+
Your role is to help users understand their McNemar's test results for Pokémon battles, where we compare whether winning and losing Pokémon differ on specific features (HP, Attack, Speed, etc.).
|
| 26 |
+
|
| 27 |
+
You should:
|
| 28 |
+
1. Explain McNemar's test concepts in simple, accessible terms
|
| 29 |
+
2. Interpret statistical significance (p-values) clearly
|
| 30 |
+
3. Explain odds ratios and confidence intervals in context
|
| 31 |
+
4. Help users understand what discordant pairs mean
|
| 32 |
+
5. Discuss the practical significance of results for Pokémon battles
|
| 33 |
+
6. Provide insights about which features matter most for winning
|
| 34 |
+
7. Suggest battle strategies based on the statistical findings
|
| 35 |
+
8. Clarify limitations and assumptions of McNemar's test
|
| 36 |
+
|
| 37 |
+
Key concepts to explain when relevant:
|
| 38 |
+
- **McNemar's test**: Tests if proportions differ between paired binary data
|
| 39 |
+
- **p-value**: Probability of seeing these results by chance (< 0.05 is significant)
|
| 40 |
+
- **Odds Ratio**: How much more likely winners are to have higher values than losers
|
| 41 |
+
- **Discordant pairs**: Cases where winner and loser differ on the feature
|
| 42 |
+
- **Concordant pairs**: Cases where both have same value (not used in test)
|
| 43 |
+
|
| 44 |
+
When discussing Pokémon battles:
|
| 45 |
+
- Connect statistical findings to battle mechanics
|
| 46 |
+
- Explain why certain stats matter more (e.g., Speed determines who attacks first)
|
| 47 |
+
- Discuss type advantages and battle strategies
|
| 48 |
+
- Use Pokémon-specific terminology naturally
|
| 49 |
+
|
| 50 |
+
Always be clear, educational, and engaging. Use examples when helpful.
|
| 51 |
+
Format responses with proper markdown for better readability."""
|
| 52 |
+
|
| 53 |
+
def get_response(self, user_message, analysis_results=None):
|
| 54 |
+
"""
|
| 55 |
+
獲取 AI 回應
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
user_message: 用戶訊息
|
| 59 |
+
analysis_results: 分析結果字典(可選)
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
str: AI 回應
|
| 63 |
+
"""
|
| 64 |
+
# 準備上下文資訊
|
| 65 |
+
context = ""
|
| 66 |
+
if analysis_results:
|
| 67 |
+
context = self._prepare_context(analysis_results)
|
| 68 |
+
|
| 69 |
+
# 添加用戶訊息到歷史
|
| 70 |
+
self.conversation_history.append({
|
| 71 |
+
"role": "user",
|
| 72 |
+
"content": user_message
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
# 構建訊息列表
|
| 76 |
+
messages = [
|
| 77 |
+
{"role": "system", "content": self.system_prompt}
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
if context:
|
| 81 |
+
messages.append({"role": "system", "content": f"Current Analysis Context:\n{context}"})
|
| 82 |
+
|
| 83 |
+
# 加入對話歷史
|
| 84 |
+
messages.extend(self.conversation_history)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
# 調用 OpenAI API
|
| 88 |
+
response = self.client.chat.completions.create(
|
| 89 |
+
model="gpt-4o-mini",
|
| 90 |
+
messages=messages,
|
| 91 |
+
temperature=0.7,
|
| 92 |
+
max_tokens=1500
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
assistant_message = response.choices[0].message.content
|
| 96 |
+
|
| 97 |
+
# 添加助手回應到歷史
|
| 98 |
+
self.conversation_history.append({
|
| 99 |
+
"role": "assistant",
|
| 100 |
+
"content": assistant_message
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
return assistant_message
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
return f"❌ Error: {str(e)}\n\nPlease check your API key and try again."
|
| 107 |
+
|
| 108 |
+
def _prepare_context(self, results):
|
| 109 |
+
"""準備分析結果的上下文資訊"""
|
| 110 |
+
|
| 111 |
+
if not results:
|
| 112 |
+
return "No analysis results available yet."
|
| 113 |
+
|
| 114 |
+
context = f"""
|
| 115 |
+
## Current McNemar Test Analysis
|
| 116 |
+
|
| 117 |
+
### Feature Analyzed
|
| 118 |
+
- Feature: {results['feature_label']} ({results['feature_name']})
|
| 119 |
+
- Positive label (1): {results['label_pos']}
|
| 120 |
+
- Negative label (0): {results['label_neg']}
|
| 121 |
+
|
| 122 |
+
### Contingency Table
|
| 123 |
+
```
|
| 124 |
+
Loser Low Loser High Total
|
| 125 |
+
Winner High {results['contingency_table'].get(1, {}).get(1, 0):<15} {results['contingency_table'].get(1, {}).get(0, 0):<15} {results['contingency_table'].get(1, {}).get(1, 0) + results['contingency_table'].get(1, {}).get(0, 0)}
|
| 126 |
+
Winner Low {results['contingency_table'].get(0, {}).get(1, 0):<15} {results['contingency_table'].get(0, {}).get(0, 0):<15} {results['contingency_table'].get(0, {}).get(1, 0) + results['contingency_table'].get(0, {}).get(0, 0)}
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### Statistical Test Results
|
| 130 |
+
- **McNemar Statistic**: {results['mcnemar_statistic']:.4f}
|
| 131 |
+
- **p-value**: {results['p_value']:.4f}
|
| 132 |
+
- **Significance**: {results['interpretation']['significance']}
|
| 133 |
+
- **Is Significant?**: {'Yes' if results['interpretation']['is_significant'] else 'No'}
|
| 134 |
+
|
| 135 |
+
### Odds Ratio Analysis
|
| 136 |
+
- **Odds Ratio**: {results['odds_ratio']:.3f}
|
| 137 |
+
- **95% Confidence Interval**: [{results['ci_low']:.3f}, {results['ci_high']:.3f}]
|
| 138 |
+
- **Effect Size**: {results['interpretation']['effect_size']}
|
| 139 |
+
|
| 140 |
+
### Discordant Pairs (key for McNemar test)
|
| 141 |
+
- **Winner High & Loser Low (b)**: {results['discordant_b']} pairs
|
| 142 |
+
- **Winner Low & Loser High (c)**: {results['discordant_c']} pairs
|
| 143 |
+
- **Total Discordant Pairs**: {results['discordant_n']} pairs
|
| 144 |
+
|
| 145 |
+
### Interpretation
|
| 146 |
+
{
|
| 147 |
+
f"The results show that winners and losers DIFFER SIGNIFICANTLY on {results['feature_label']}."
|
| 148 |
+
if results['interpretation']['is_significant']
|
| 149 |
+
else f"The results show NO SIGNIFICANT DIFFERENCE between winners and losers on {results['feature_label']}."
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
{
|
| 153 |
+
f"Winners are {results['odds_ratio']:.2f} times more likely to have higher {results['feature_label']} than losers."
|
| 154 |
+
if results['odds_ratio'] > 1
|
| 155 |
+
else f"Losers are {1/results['odds_ratio']:.2f} times more likely to have higher {results['feature_label']} than winners."
|
| 156 |
+
}
|
| 157 |
+
"""
|
| 158 |
+
return context
|
| 159 |
+
|
| 160 |
+
def generate_summary(self, analysis_results):
|
| 161 |
+
"""自動生成分析結果總結"""
|
| 162 |
+
|
| 163 |
+
summary_prompt = """Based on the McNemar test results provided, please generate a comprehensive summary that includes:
|
| 164 |
+
|
| 165 |
+
1. **What was tested**: Briefly explain what feature was analyzed and what the test measures
|
| 166 |
+
2. **Statistical Findings**:
|
| 167 |
+
- Is the result statistically significant?
|
| 168 |
+
- What does the p-value tell us?
|
| 169 |
+
- What does the odds ratio mean in practical terms?
|
| 170 |
+
3. **Battle Implications**: What does this mean for Pokémon battles?
|
| 171 |
+
4. **Key Insights**: The most important takeaway from these results
|
| 172 |
+
5. **Recommendations**: How trainers could use this information
|
| 173 |
+
|
| 174 |
+
Format the summary in clear markdown with appropriate sections."""
|
| 175 |
+
|
| 176 |
+
return self.get_response(summary_prompt, analysis_results)
|
| 177 |
+
|
| 178 |
+
def explain_metric(self, metric_name, analysis_results):
|
| 179 |
+
"""解釋特定指標"""
|
| 180 |
+
|
| 181 |
+
metric_explanations = {
|
| 182 |
+
'mcnemar_statistic': 'McNemar Statistic',
|
| 183 |
+
'p_value': 'p-value',
|
| 184 |
+
'odds_ratio': 'Odds Ratio',
|
| 185 |
+
'confidence_interval': '95% Confidence Interval',
|
| 186 |
+
'discordant_pairs': 'Discordant Pairs'
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
metric_display = metric_explanations.get(metric_name, metric_name)
|
| 190 |
+
|
| 191 |
+
explain_prompt = f"""Please explain the following metric in the context of this McNemar test analysis:
|
| 192 |
+
|
| 193 |
+
Metric: {metric_display}
|
| 194 |
+
|
| 195 |
+
Include:
|
| 196 |
+
1. What this metric measures in general
|
| 197 |
+
2. The value obtained in this analysis
|
| 198 |
+
3. How to interpret this value for Pokémon battles
|
| 199 |
+
4. What it tells us about the importance of this feature
|
| 200 |
+
5. Any limitations or caveats to consider"""
|
| 201 |
+
|
| 202 |
+
return self.get_response(explain_prompt, analysis_results)
|
| 203 |
+
|
| 204 |
+
def compare_features(self):
|
| 205 |
+
"""建議比較不同特徵"""
|
| 206 |
+
|
| 207 |
+
compare_prompt = """I'd like to understand how different Pokémon features (HP, Attack, Defense, Speed, etc.) compare in terms of their importance for winning battles.
|
| 208 |
+
|
| 209 |
+
Could you:
|
| 210 |
+
1. Explain which features are typically most important in Pokémon battles
|
| 211 |
+
2. Discuss how McNemar's test helps us identify important features
|
| 212 |
+
3. Suggest which features I should analyze next
|
| 213 |
+
4. Explain how features might interact (e.g., Speed + Attack)"""
|
| 214 |
+
|
| 215 |
+
return self.get_response(compare_prompt, None)
|
| 216 |
+
|
| 217 |
+
def explain_mcnemar_test(self):
|
| 218 |
+
"""解釋 McNemar 檢定的基本概念"""
|
| 219 |
+
|
| 220 |
+
explain_prompt = """Please explain McNemar's test in simple terms, specifically in the context of Pokémon battle analysis.
|
| 221 |
+
|
| 222 |
+
Cover:
|
| 223 |
+
1. What McNemar's test is and when to use it
|
| 224 |
+
2. Why it's appropriate for comparing winner vs. loser features
|
| 225 |
+
3. What "paired data" means in this context
|
| 226 |
+
4. The difference between discordant and concordant pairs
|
| 227 |
+
5. How to interpret the results
|
| 228 |
+
|
| 229 |
+
Use Pokémon examples to make it concrete and easy to understand."""
|
| 230 |
+
|
| 231 |
+
return self.get_response(explain_prompt, None)
|
| 232 |
+
|
| 233 |
+
def battle_strategy_advice(self, analysis_results):
|
| 234 |
+
"""提供對戰策略建議"""
|
| 235 |
+
|
| 236 |
+
strategy_prompt = f"""Based on the McNemar test results for {analysis_results['feature_label']}, please provide practical battle strategy advice for Pokémon trainers.
|
| 237 |
+
|
| 238 |
+
Consider:
|
| 239 |
+
1. Should trainers prioritize this feature when building teams?
|
| 240 |
+
2. How important is this feature compared to others?
|
| 241 |
+
3. Are there specific Pokémon types or strategies that benefit most?
|
| 242 |
+
4. What are the implications for competitive play?
|
| 243 |
+
5. Any exceptions or special cases to be aware of?
|
| 244 |
+
|
| 245 |
+
Be specific and actionable."""
|
| 246 |
+
|
| 247 |
+
return self.get_response(strategy_prompt, analysis_results)
|
| 248 |
+
|
| 249 |
+
def reset_conversation(self):
|
| 250 |
+
"""重置對話歷史"""
|
| 251 |
+
self.conversation_history = []
|
mcnemar_utils.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import plotly.graph_objects as go
|
| 2 |
+
import plotly.express as px
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
|
| 6 |
+
def plot_contingency_table_heatmap(ct_labeled, feature_label, title="列聯表熱力圖"):
|
| 7 |
+
"""
|
| 8 |
+
繪製列聯表熱力圖
|
| 9 |
+
|
| 10 |
+
Args:
|
| 11 |
+
ct_labeled: 帶標籤的列聯表 DataFrame
|
| 12 |
+
feature_label: 特徵標籤
|
| 13 |
+
title: 圖表標題
|
| 14 |
+
|
| 15 |
+
Returns:
|
| 16 |
+
plotly figure
|
| 17 |
+
"""
|
| 18 |
+
# 移除總數列和行
|
| 19 |
+
ct_display = ct_labeled.iloc[:-1, :-1].copy()
|
| 20 |
+
|
| 21 |
+
# 創建註解文字
|
| 22 |
+
annotations = []
|
| 23 |
+
for i, row in enumerate(ct_display.index):
|
| 24 |
+
for j, col in enumerate(ct_display.columns):
|
| 25 |
+
annotations.append(
|
| 26 |
+
dict(
|
| 27 |
+
x=j,
|
| 28 |
+
y=i,
|
| 29 |
+
text=str(ct_display.iloc[i, j]),
|
| 30 |
+
font=dict(size=16, color='white' if ct_display.iloc[i, j] > ct_display.values.max()/2 else 'black'),
|
| 31 |
+
showarrow=False
|
| 32 |
+
)
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
fig = go.Figure(data=go.Heatmap(
|
| 36 |
+
z=ct_display.values,
|
| 37 |
+
x=ct_display.columns,
|
| 38 |
+
y=ct_display.index,
|
| 39 |
+
colorscale='Blues',
|
| 40 |
+
showscale=True,
|
| 41 |
+
hoverongaps=False,
|
| 42 |
+
hovertemplate='%{y}<br>%{x}<br>配對數: %{z}<extra></extra>'
|
| 43 |
+
))
|
| 44 |
+
|
| 45 |
+
fig.update_layout(
|
| 46 |
+
title=f'{title}<br><sub>{feature_label}</sub>',
|
| 47 |
+
xaxis_title='敗方 (Loser)',
|
| 48 |
+
yaxis_title='勝方 (Winner)',
|
| 49 |
+
width=600,
|
| 50 |
+
height=500,
|
| 51 |
+
template='plotly_white',
|
| 52 |
+
annotations=annotations
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return fig
|
| 56 |
+
|
| 57 |
+
def plot_odds_ratio_forest(or_value, ci_low, ci_high, feature_label):
|
| 58 |
+
"""
|
| 59 |
+
繪製勝算比森林圖
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
or_value: 勝算比
|
| 63 |
+
ci_low: 95% 信賴區間下界
|
| 64 |
+
ci_high: 95% 信賴區間上界
|
| 65 |
+
feature_label: 特徵標籤
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
plotly figure
|
| 69 |
+
"""
|
| 70 |
+
fig = go.Figure()
|
| 71 |
+
|
| 72 |
+
# 參考線 (OR = 1)
|
| 73 |
+
fig.add_shape(
|
| 74 |
+
type="line",
|
| 75 |
+
x0=1, x1=1,
|
| 76 |
+
y0=-0.5, y1=0.5,
|
| 77 |
+
line=dict(color="red", width=2, dash="dash"),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# 信賴區間
|
| 81 |
+
fig.add_trace(go.Scatter(
|
| 82 |
+
x=[ci_low, ci_high],
|
| 83 |
+
y=[0, 0],
|
| 84 |
+
mode='lines',
|
| 85 |
+
line=dict(color='#2d6ca2', width=3),
|
| 86 |
+
showlegend=False,
|
| 87 |
+
hovertemplate='95% CI: [%{x:.3f}]<extra></extra>'
|
| 88 |
+
))
|
| 89 |
+
|
| 90 |
+
# 點估計
|
| 91 |
+
fig.add_trace(go.Scatter(
|
| 92 |
+
x=[or_value],
|
| 93 |
+
y=[0],
|
| 94 |
+
mode='markers',
|
| 95 |
+
marker=dict(
|
| 96 |
+
size=15,
|
| 97 |
+
color='#d62728',
|
| 98 |
+
line=dict(color='white', width=2)
|
| 99 |
+
),
|
| 100 |
+
showlegend=False,
|
| 101 |
+
hovertemplate=f'OR: {or_value:.3f}<extra></extra>'
|
| 102 |
+
))
|
| 103 |
+
|
| 104 |
+
# 添加數值標註
|
| 105 |
+
fig.add_annotation(
|
| 106 |
+
x=or_value,
|
| 107 |
+
y=0.15,
|
| 108 |
+
text=f"OR = {or_value:.3f}<br>95% CI [{ci_low:.3f}, {ci_high:.3f}]",
|
| 109 |
+
showarrow=False,
|
| 110 |
+
font=dict(size=12, color='#1b4f72'),
|
| 111 |
+
bgcolor='rgba(255,255,255,0.8)',
|
| 112 |
+
bordercolor='#2d6ca2',
|
| 113 |
+
borderwidth=1,
|
| 114 |
+
borderpad=4
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
fig.update_layout(
|
| 118 |
+
title=f'勝算比 (Odds Ratio)<br><sub>{feature_label}</sub>',
|
| 119 |
+
xaxis_title='Odds Ratio',
|
| 120 |
+
yaxis=dict(
|
| 121 |
+
showticklabels=False,
|
| 122 |
+
showgrid=False,
|
| 123 |
+
zeroline=False
|
| 124 |
+
),
|
| 125 |
+
width=700,
|
| 126 |
+
height=300,
|
| 127 |
+
template='plotly_white',
|
| 128 |
+
xaxis=dict(type='log', showgrid=True),
|
| 129 |
+
hovermode='closest'
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
return fig
|
| 133 |
+
|
| 134 |
+
def plot_discordant_pairs(b, c, label_pos, label_neg):
|
| 135 |
+
"""
|
| 136 |
+
繪製不一致配對比較圖
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
b: cs=1 & cn=0 的配對數
|
| 140 |
+
c: cs=0 & cn=1 的配對數
|
| 141 |
+
label_pos: 正向標籤
|
| 142 |
+
label_neg: 負向標籤
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
plotly figure
|
| 146 |
+
"""
|
| 147 |
+
fig = go.Figure()
|
| 148 |
+
|
| 149 |
+
categories = [
|
| 150 |
+
f'勝方 {label_pos.split()[1]}<br>敗方 {label_neg.split()[1]}',
|
| 151 |
+
f'勝方 {label_neg.split()[1]}<br>敗方 {label_pos.split()[1]}'
|
| 152 |
+
]
|
| 153 |
+
values = [b, c]
|
| 154 |
+
colors = ['#2d6ca2', '#d62728']
|
| 155 |
+
|
| 156 |
+
fig.add_trace(go.Bar(
|
| 157 |
+
x=categories,
|
| 158 |
+
y=values,
|
| 159 |
+
marker=dict(
|
| 160 |
+
color=colors,
|
| 161 |
+
line=dict(color='white', width=2)
|
| 162 |
+
),
|
| 163 |
+
text=values,
|
| 164 |
+
textposition='outside',
|
| 165 |
+
textfont=dict(size=16, color='black'),
|
| 166 |
+
hovertemplate='%{x}<br>配對數: %{y}<extra></extra>'
|
| 167 |
+
))
|
| 168 |
+
|
| 169 |
+
fig.update_layout(
|
| 170 |
+
title='不一致配對分布',
|
| 171 |
+
xaxis_title='配對類型',
|
| 172 |
+
yaxis_title='配對數量',
|
| 173 |
+
width=600,
|
| 174 |
+
height=400,
|
| 175 |
+
template='plotly_white',
|
| 176 |
+
showlegend=False
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
return fig
|
| 180 |
+
|
| 181 |
+
def plot_p_value_significance(p_value):
|
| 182 |
+
"""
|
| 183 |
+
繪製 p 值顯著性指示圖
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
p_value: p 值
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
plotly figure
|
| 190 |
+
"""
|
| 191 |
+
fig = go.Figure()
|
| 192 |
+
|
| 193 |
+
# 設定顯著性閾值
|
| 194 |
+
thresholds = [0.001, 0.01, 0.05, 1.0]
|
| 195 |
+
labels = ['p < 0.001<br>(極顯著)', 'p < 0.01<br>(非常顯著)',
|
| 196 |
+
'p < 0.05<br>(顯著)', 'p ≥ 0.05<br>(不顯著)']
|
| 197 |
+
colors = ['#1a5f1a', '#2d8b2d', '#5cb85c', '#d9534f']
|
| 198 |
+
|
| 199 |
+
# 找出 p 值所在區間
|
| 200 |
+
current_idx = 0
|
| 201 |
+
for i, thresh in enumerate(thresholds):
|
| 202 |
+
if p_value < thresh:
|
| 203 |
+
current_idx = i
|
| 204 |
+
break
|
| 205 |
+
|
| 206 |
+
# 繪製區間條
|
| 207 |
+
for i in range(len(thresholds)):
|
| 208 |
+
opacity = 1.0 if i == current_idx else 0.3
|
| 209 |
+
fig.add_trace(go.Bar(
|
| 210 |
+
x=[labels[i]],
|
| 211 |
+
y=[1],
|
| 212 |
+
marker=dict(color=colors[i], opacity=opacity),
|
| 213 |
+
showlegend=False,
|
| 214 |
+
hovertemplate=f'{labels[i]}<extra></extra>'
|
| 215 |
+
))
|
| 216 |
+
|
| 217 |
+
# 添加 p 值標註
|
| 218 |
+
fig.add_annotation(
|
| 219 |
+
x=labels[current_idx],
|
| 220 |
+
y=1.1,
|
| 221 |
+
text=f"p = {p_value:.4f}",
|
| 222 |
+
showarrow=True,
|
| 223 |
+
arrowhead=2,
|
| 224 |
+
arrowsize=1,
|
| 225 |
+
arrowwidth=2,
|
| 226 |
+
arrowcolor='black',
|
| 227 |
+
font=dict(size=14, color='black', weight='bold'),
|
| 228 |
+
bgcolor='yellow',
|
| 229 |
+
bordercolor='black',
|
| 230 |
+
borderwidth=2,
|
| 231 |
+
borderpad=4
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
fig.update_layout(
|
| 235 |
+
title='顯著性水準',
|
| 236 |
+
xaxis_title='',
|
| 237 |
+
yaxis_title='',
|
| 238 |
+
yaxis=dict(showticklabels=False, showgrid=False),
|
| 239 |
+
width=700,
|
| 240 |
+
height=300,
|
| 241 |
+
template='plotly_white',
|
| 242 |
+
showlegend=False
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
return fig
|
| 246 |
+
|
| 247 |
+
def create_results_summary_table(results):
|
| 248 |
+
"""
|
| 249 |
+
創建結果摘要表格
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
results: 分析結果字典
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
pandas DataFrame
|
| 256 |
+
"""
|
| 257 |
+
summary_data = {
|
| 258 |
+
'項目': [
|
| 259 |
+
'特徵',
|
| 260 |
+
'McNemar 統計量',
|
| 261 |
+
'p 值',
|
| 262 |
+
'顯著性',
|
| 263 |
+
'勝算比 (OR)',
|
| 264 |
+
'95% 信賴區間',
|
| 265 |
+
'不一致配對數',
|
| 266 |
+
'效果大小'
|
| 267 |
+
],
|
| 268 |
+
'數值': [
|
| 269 |
+
results['feature_label'],
|
| 270 |
+
f"{results['mcnemar_statistic']:.4f}",
|
| 271 |
+
f"{results['p_value']:.4f}",
|
| 272 |
+
results['interpretation']['significance'],
|
| 273 |
+
f"{results['odds_ratio']:.3f}",
|
| 274 |
+
f"[{results['ci_low']:.3f}, {results['ci_high']:.3f}]",
|
| 275 |
+
f"{results['discordant_n']} (b={results['discordant_b']}, c={results['discordant_c']})",
|
| 276 |
+
results['interpretation']['effect_size']
|
| 277 |
+
]
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
return pd.DataFrame(summary_data)
|
| 281 |
+
|
| 282 |
+
def export_results_to_text(results):
|
| 283 |
+
"""
|
| 284 |
+
匯出結果為純文字格式
|
| 285 |
+
|
| 286 |
+
Args:
|
| 287 |
+
results: 分析結果字典
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
str: 格式化的文字報告
|
| 291 |
+
"""
|
| 292 |
+
report = f"""
|
| 293 |
+
==============================================
|
| 294 |
+
McNemar 檢定分析報告
|
| 295 |
+
==============================================
|
| 296 |
+
|
| 297 |
+
分析特徵: {results['feature_label']} ({results['feature_name']})
|
| 298 |
+
分析時間: {results['timestamp']}
|
| 299 |
+
|
| 300 |
+
----------------------------------------------
|
| 301 |
+
1. 列聯表
|
| 302 |
+
----------------------------------------------
|
| 303 |
+
{results['contingency_table_labeled'].to_string()}
|
| 304 |
+
|
| 305 |
+
----------------------------------------------
|
| 306 |
+
2. McNemar 檢定結果
|
| 307 |
+
----------------------------------------------
|
| 308 |
+
McNemar 統計量: {results['mcnemar_statistic']:.4f}
|
| 309 |
+
p 值: {results['p_value']:.4f}
|
| 310 |
+
顯著性: {results['interpretation']['significance']}
|
| 311 |
+
|
| 312 |
+
----------------------------------------------
|
| 313 |
+
3. 勝算比分析
|
| 314 |
+
----------------------------------------------
|
| 315 |
+
勝算比 (OR): {results['odds_ratio']:.3f}
|
| 316 |
+
95% 信賴區間: [{results['ci_low']:.3f}, {results['ci_high']:.3f}]
|
| 317 |
+
效果大小: {results['interpretation']['effect_size']}
|
| 318 |
+
|
| 319 |
+
----------------------------------------------
|
| 320 |
+
4. 不一致配對
|
| 321 |
+
----------------------------------------------
|
| 322 |
+
勝方{results['label_pos'].split()[1]}且敗方{results['label_neg'].split()[1]} (b): {results['discordant_b']}
|
| 323 |
+
勝方{results['label_neg'].split()[1]}且敗方{results['label_pos'].split()[1]} (c): {results['discordant_c']}
|
| 324 |
+
總不一致配對數: {results['discordant_n']}
|
| 325 |
+
|
| 326 |
+
----------------------------------------------
|
| 327 |
+
5. 解釋
|
| 328 |
+
----------------------------------------------
|
| 329 |
+
{'結果顯示勝方和敗方在此特徵上有顯著差異。' if results['interpretation']['is_significant'] else '結果顯示勝方和敗方在此特徵上無顯著差異。'}
|
| 330 |
+
勝算比為 {results['odds_ratio']:.3f},表示{
|
| 331 |
+
'勝方在此特徵上較高的機率是敗方的 ' + str(round(results['odds_ratio'], 2)) + ' 倍。'
|
| 332 |
+
if results['odds_ratio'] > 1
|
| 333 |
+
else '敗方在此特徵上較高的機率是勝方的 ' + str(round(1/results['odds_ratio'], 2)) + ' 倍。'
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
==============================================
|
| 337 |
+
"""
|
| 338 |
+
return report
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit==1.31.0
|
| 2 |
+
pandas==2.1.4
|
| 3 |
+
numpy==1.26.3
|
| 4 |
+
plotly==5.18.0
|
| 5 |
+
statsmodels==0.14.1
|
| 6 |
+
openai>=1.30.0
|