11 Safe Dynamic Agent¶
这份 notebook 的目标不是继续抠一点点准确率,而是解决我们现在最核心的问题:
动态 agent 会把一些错误答案推到极高置信度,因此当前系统不安全。
这份实验先做 安全门 / 拒答机制(abstention),核心思路是:
- 读取已经跑完的 dynamic agent 病例级结果
- 设计几个简单但可解释的安全规则
- 评估覆盖率、选择性准确率、选择性 Macro-F1、危险错误率
- 把结果按时间戳保存下来
这一版先做 post-hoc safety study。如果方向成立,下一步再把安全机制正式塞回 agent 推理流程里。
In [1]:
from pathlib import Path
from datetime import datetime
import ast
import json
import numpy as np
import pandas as pd
from sklearn.metrics import accuracy_score, balanced_accuracy_score, f1_score
PROJECT_ROOT = Path('/Users/applesues01/Documents/Medical_Agent')
SUPPORT_DIR = PROJECT_ROOT / 'supports'
DYNAMIC_CASES_PATH = SUPPORT_DIR / '2026-08-04_104443_dynamic_agent_cases.csv'
DANGER_CASES_PATH = SUPPORT_DIR / '2026-08-04_104443_dynamic_agent_danger_cases.csv'
IMAGE_ONLY_CASE_PATH = SUPPORT_DIR / 'image_only_case_level_predictions.csv'
print(DYNAMIC_CASES_PATH)
print(DANGER_CASES_PATH)
print(IMAGE_ONLY_CASE_PATH)
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_104443_dynamic_agent_cases.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_104443_dynamic_agent_danger_cases.csv /Users/applesues01/Documents/Medical_Agent/supports/image_only_case_level_predictions.csv
1. 读取当前 dynamic agent 结果¶
这里的 dynamic_agent_cases.csv 是我们已经跑完的病例级轨迹汇总。安全机制就是建立在这些“问之前 vs 问之后”的变化上。
In [2]:
dynamic_df = pd.read_csv(DYNAMIC_CASES_PATH)
image_only_case_df = pd.read_csv(IMAGE_ONLY_CASE_PATH)
def parse_list_cell(value):
if pd.isna(value):
return []
if isinstance(value, list):
return value
text = str(value).strip()
if text == '' or text == '[]':
return []
try:
return ast.literal_eval(text)
except Exception:
return [text]
bool_cols = [
'initial_correct', 'final_correct', 'changed_prediction', 'improved',
'worsened', 'still_wrong', 'still_correct', 'initial_high_conf_wrong',
'final_high_conf_wrong', 'unsafe_confidence_increase'
]
for col in bool_cols:
dynamic_df[col] = dynamic_df[col].astype(str).str.lower().map({'true': True, 'false': False})
dynamic_df['asked_questions_list'] = dynamic_df['asked_questions'].apply(parse_list_cell)
dynamic_df['known_keys_list'] = dynamic_df['known_keys'].apply(parse_list_cell)
dynamic_df['confidence_gain'] = dynamic_df['final_max_prob'] - dynamic_df['initial_max_prob']
dynamic_df['sex_only'] = dynamic_df['known_keys_list'].apply(lambda xs: xs == ['sex'])
dynamic_df['used_age_or_location'] = dynamic_df['known_keys_list'].apply(lambda xs: ('age' in xs) or ('location' in xs))
dynamic_df.head()
Out[2]:
| image_id | true_label | initial_pred_label | initial_max_prob | initial_correct | final_pred_label | final_max_prob | final_correct | asked_questions | num_questions | ... | still_wrong | still_correct | initial_high_conf_wrong | final_high_conf_wrong | unsafe_confidence_increase | asked_questions_list | known_keys_list | confidence_gain | sex_only | used_age_or_location | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ISIC_0025837 | bkl | bkl | 0.972961 | True | bkl | 0.972961 | True | [] | 0 | ... | False | True | False | False | False | [] | [] | 0.000000 | False | False |
| 1 | ISIC_0025209 | bkl | bkl | 0.407122 | True | bkl | 0.874424 | True | ['age', 'sex'] | 2 | ... | False | True | False | False | False | [age, sex] | [age, sex] | 0.467302 | False | True |
| 2 | ISIC_0029161 | bkl | bkl | 0.793358 | True | bkl | 0.987926 | True | ['sex'] | 1 | ... | False | True | False | False | False | [sex] | [sex] | 0.194568 | True | False |
| 3 | ISIC_0026273 | bkl | bkl | 0.802558 | True | bkl | 0.802558 | True | [] | 0 | ... | False | True | False | False | False | [] | [] | 0.000000 | False | False |
| 4 | ISIC_0025819 | bkl | bkl | 0.978878 | True | bkl | 0.978878 | True | [] | 0 | ... | False | True | False | False | False | [] | [] | 0.000000 | False | False |
5 rows × 24 columns
In [3]:
print('Total cases:', len(dynamic_df))
print('Avg questions:', dynamic_df['num_questions'].mean())
print('Initial accuracy:', dynamic_df['initial_correct'].mean())
print('Final accuracy:', dynamic_df['final_correct'].mean())
print('Changed prediction rate:', dynamic_df['changed_prediction'].mean())
print('Unsafe confidence increase rate:', dynamic_df['unsafe_confidence_increase'].mean())
print('Sex-only question count:', dynamic_df['sex_only'].sum())
Total cases: 1481 Avg questions: 0.36934503713706957 Initial accuracy: 0.7771775827143822 Final accuracy: 0.7825793382849426 Changed prediction rate: 0.10465901417960838 Unsafe confidence increase rate: 0.11613774476704929 Sex-only question count: 315
2. 为什么要做安全门¶
我们已经看到一个很关键的现象:
- agent 有时确实能把错误改对
- 但 agent 也会把错误答案推到接近
1.0的置信度
所以从这一刻开始,我们的目标不再是“尽量多诊断”,而是:
只在比较可信的时候诊断;其余情况宁可拒答。
这就是 selective prediction / abstention 的思路。
In [4]:
danger_preview = dynamic_df.sort_values('final_max_prob', ascending=False)
danger_preview[[
'image_id', 'true_label', 'initial_pred_label', 'initial_max_prob',
'final_pred_label', 'final_max_prob', 'asked_questions',
'changed_prediction', 'unsafe_confidence_increase', 'final_correct'
]].head(20)
Out[4]:
| image_id | true_label | initial_pred_label | initial_max_prob | final_pred_label | final_max_prob | asked_questions | changed_prediction | unsafe_confidence_increase | final_correct | |
|---|---|---|---|---|---|---|---|---|---|---|
| 371 | ISIC_0024706 | vasc | vasc | 0.999999 | vasc | 0.999999 | [] | False | False | True |
| 218 | ISIC_0025394 | mel | nv | 0.736395 | nv | 0.999997 | ['sex'] | False | True | False |
| 368 | ISIC_0031065 | vasc | vasc | 0.999996 | vasc | 0.999996 | [] | False | False | True |
| 316 | ISIC_0032070 | mel | mel | 0.774466 | mel | 0.999995 | ['sex'] | False | False | True |
| 364 | ISIC_0029608 | vasc | vasc | 0.999995 | vasc | 0.999995 | [] | False | False | True |
| 785 | ISIC_0027001 | nv | nv | 0.999989 | nv | 0.999989 | [] | False | False | True |
| 972 | ISIC_0027223 | nv | nv | 0.999985 | nv | 0.999985 | [] | False | False | True |
| 157 | ISIC_0030067 | bkl | nv | 0.740637 | nv | 0.999980 | ['sex'] | False | True | False |
| 1077 | ISIC_0033402 | nv | nv | 0.728461 | nv | 0.999979 | ['sex'] | False | False | True |
| 630 | ISIC_0027232 | nv | bkl | 0.662172 | bkl | 0.999977 | ['sex'] | False | True | False |
| 597 | ISIC_0028318 | nv | nv | 0.999974 | nv | 0.999974 | [] | False | False | True |
| 15 | ISIC_0033899 | bkl | mel | 0.668489 | mel | 0.999973 | ['sex'] | False | True | False |
| 634 | ISIC_0026300 | nv | nv | 0.999964 | nv | 0.999964 | [] | False | False | True |
| 588 | ISIC_0026628 | nv | nv | 0.999964 | nv | 0.999964 | [] | False | False | True |
| 850 | ISIC_0030685 | nv | nv | 0.999943 | nv | 0.999943 | [] | False | False | True |
| 1321 | ISIC_0026671 | nv | nv | 0.743409 | nv | 0.999941 | ['sex'] | False | False | True |
| 596 | ISIC_0031508 | nv | nv | 0.999938 | nv | 0.999938 | [] | False | False | True |
| 141 | ISIC_0033460 | bkl | bkl | 0.750931 | mel | 0.999934 | ['sex'] | True | False | False |
| 846 | ISIC_0031312 | nv | nv | 0.999934 | nv | 0.999934 | [] | False | False | True |
| 664 | ISIC_0027885 | nv | nv | 0.999933 | nv | 0.999933 | [] | False | False | True |
3. 定义安全评估函数¶
这里我们引入一个新的输出动作:
diagnoseabstain
评估时我们不只看准确率,还看:
coverage:有多少病例被真正诊断selective_accuracy:只在被诊断的病例上,准确率是多少selective_balanced_accuracyselective_macro_f1unsafe_rate_among_diagnosed:被诊断病例里有多少是危险高置信错误
In [5]:
def evaluate_abstention_policy(df, diagnose_mask, policy_name):
out = df.copy()
out['diagnose'] = diagnose_mask.astype(bool)
out['abstain'] = ~out['diagnose']
diagnosed = out[out['diagnose']].copy()
coverage = len(diagnosed) / len(out)
abstain_rate = 1.0 - coverage
if len(diagnosed) == 0:
return {
'policy_name': policy_name,
'coverage': 0.0,
'abstain_rate': 1.0,
'diagnosed_cases': 0,
'selective_accuracy': np.nan,
'selective_balanced_accuracy': np.nan,
'selective_macro_f1': np.nan,
'unsafe_rate_among_diagnosed': np.nan,
'worsened_rate_among_diagnosed': np.nan,
}, diagnosed
selective_accuracy = accuracy_score(diagnosed['true_label'], diagnosed['final_pred_label'])
selective_balanced_accuracy = balanced_accuracy_score(diagnosed['true_label'], diagnosed['final_pred_label'])
selective_macro_f1 = f1_score(diagnosed['true_label'], diagnosed['final_pred_label'], average='macro')
unsafe_rate = diagnosed['unsafe_confidence_increase'].mean()
worsened_rate = diagnosed['worsened'].mean()
summary = {
'policy_name': policy_name,
'coverage': coverage,
'abstain_rate': abstain_rate,
'diagnosed_cases': int(len(diagnosed)),
'selective_accuracy': selective_accuracy,
'selective_balanced_accuracy': selective_balanced_accuracy,
'selective_macro_f1': selective_macro_f1,
'unsafe_rate_among_diagnosed': unsafe_rate,
'worsened_rate_among_diagnosed': worsened_rate,
}
return summary, diagnosed
In [6]:
policy_summaries = []
mask_a = pd.Series(True, index=dynamic_df.index)
summary_a, diagnosed_a = evaluate_abstention_policy(dynamic_df, mask_a, 'baseline_dynamic_no_abstention')
policy_summaries.append(summary_a)
mask_b = ~dynamic_df['sex_only']
summary_b, diagnosed_b = evaluate_abstention_policy(dynamic_df, mask_b, 'abstain_if_sex_only')
policy_summaries.append(summary_b)
mask_c = (
(dynamic_df['final_max_prob'] >= 0.85) &
(dynamic_df['confidence_gain'] <= 0.25) &
(~dynamic_df['sex_only'])
)
summary_c, diagnosed_c = evaluate_abstention_policy(dynamic_df, mask_c, 'conservative_gate_v1')
policy_summaries.append(summary_c)
pd.DataFrame(policy_summaries)
Out[6]:
| policy_name | coverage | abstain_rate | diagnosed_cases | selective_accuracy | selective_balanced_accuracy | selective_macro_f1 | unsafe_rate_among_diagnosed | worsened_rate_among_diagnosed | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | baseline_dynamic_no_abstention | 1.000000 | 0.000000 | 1481 | 0.782579 | 0.581238 | 0.581444 | 0.116138 | 0.039838 |
| 1 | abstain_if_sex_only | 0.787306 | 0.212694 | 1166 | 0.849914 | 0.694229 | 0.659381 | 0.055746 | 0.016295 |
| 2 | conservative_gate_v1 | 0.609048 | 0.390952 | 902 | 0.917960 | 0.821088 | 0.782546 | 0.012195 | 0.001109 |
5. 参数扫描:找一组更稳的规则¶
下面这一步是我们真正开始“研究”的地方。系统扫描:
- 最终置信度阈值
final_max_prob - 最大允许置信度增幅
confidence_gain - 是否禁止
sex-only
重点看:
- coverage 不能太低
unsafe_rate_among_diagnosed要明显下降selective_macro_f1尽量不要崩掉
In [7]:
grid_results = []
for prob_thr in [0.75, 0.80, 0.85, 0.90, 0.95]:
for gain_cap in [0.10, 0.20, 0.30, 0.40, 0.60]:
for forbid_sex_only in [False, True]:
mask = dynamic_df['final_max_prob'] >= prob_thr
mask &= dynamic_df['confidence_gain'] <= gain_cap
if forbid_sex_only:
mask &= ~dynamic_df['sex_only']
summary, _ = evaluate_abstention_policy(
dynamic_df,
mask,
policy_name=f'prob>={prob_thr}_gain<={gain_cap}_forbidSexOnly={forbid_sex_only}'
)
summary['prob_threshold'] = prob_thr
summary['gain_cap'] = gain_cap
summary['forbid_sex_only'] = forbid_sex_only
grid_results.append(summary)
grid_df = pd.DataFrame(grid_results)
grid_df.sort_values(
['unsafe_rate_among_diagnosed', 'selective_macro_f1'],
ascending=[True, False]
).head(20)
Out[7]:
| policy_name | coverage | abstain_rate | diagnosed_cases | selective_accuracy | selective_balanced_accuracy | selective_macro_f1 | unsafe_rate_among_diagnosed | worsened_rate_among_diagnosed | prob_threshold | gain_cap | forbid_sex_only | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 40 | prob>=0.95_gain<=0.1_forbidSexOnly=False | 0.411209 | 0.588791 | 609 | 0.978654 | 0.917516 | 0.906395 | 0.000000 | 0.000000 | 0.95 | 0.1 | False |
| 41 | prob>=0.95_gain<=0.1_forbidSexOnly=True | 0.411209 | 0.588791 | 609 | 0.978654 | 0.917516 | 0.906395 | 0.000000 | 0.000000 | 0.95 | 0.1 | True |
| 30 | prob>=0.9_gain<=0.1_forbidSexOnly=False | 0.516543 | 0.483457 | 765 | 0.946405 | 0.859116 | 0.837549 | 0.000000 | 0.000000 | 0.90 | 0.1 | False |
| 31 | prob>=0.9_gain<=0.1_forbidSexOnly=True | 0.516543 | 0.483457 | 765 | 0.946405 | 0.859116 | 0.837549 | 0.000000 | 0.000000 | 0.90 | 0.1 | True |
| 20 | prob>=0.85_gain<=0.1_forbidSexOnly=False | 0.588116 | 0.411884 | 871 | 0.928817 | 0.827409 | 0.796048 | 0.000000 | 0.000000 | 0.85 | 0.1 | False |
| 21 | prob>=0.85_gain<=0.1_forbidSexOnly=True | 0.587441 | 0.412559 | 870 | 0.928736 | 0.827403 | 0.796042 | 0.000000 | 0.000000 | 0.85 | 0.1 | True |
| 11 | prob>=0.8_gain<=0.1_forbidSexOnly=True | 0.653612 | 0.346388 | 968 | 0.911157 | 0.813572 | 0.771927 | 0.001033 | 0.000000 | 0.80 | 0.1 | True |
| 1 | prob>=0.75_gain<=0.1_forbidSexOnly=True | 0.657664 | 0.342336 | 974 | 0.909651 | 0.812907 | 0.761828 | 0.002053 | 0.000000 | 0.75 | 0.1 | True |
| 10 | prob>=0.8_gain<=0.1_forbidSexOnly=False | 0.656313 | 0.343687 | 972 | 0.909465 | 0.810749 | 0.770361 | 0.002058 | 0.001029 | 0.80 | 0.1 | False |
| 0 | prob>=0.75_gain<=0.1_forbidSexOnly=False | 0.661715 | 0.338285 | 980 | 0.908163 | 0.810217 | 0.760314 | 0.003061 | 0.001020 | 0.75 | 0.1 | False |
| 43 | prob>=0.95_gain<=0.2_forbidSexOnly=True | 0.414585 | 0.585415 | 614 | 0.975570 | 0.905699 | 0.899476 | 0.003257 | 0.000000 | 0.95 | 0.2 | True |
| 33 | prob>=0.9_gain<=0.2_forbidSexOnly=True | 0.523295 | 0.476705 | 775 | 0.940645 | 0.854305 | 0.827600 | 0.006452 | 0.000000 | 0.90 | 0.2 | True |
| 23 | prob>=0.85_gain<=0.2_forbidSexOnly=True | 0.596219 | 0.403781 | 883 | 0.922990 | 0.825248 | 0.789426 | 0.006795 | 0.000000 | 0.85 | 0.2 | True |
| 42 | prob>=0.95_gain<=0.2_forbidSexOnly=False | 0.419986 | 0.580014 | 622 | 0.971061 | 0.905799 | 0.892970 | 0.008039 | 0.000000 | 0.95 | 0.2 | False |
| 13 | prob>=0.8_gain<=0.2_forbidSexOnly=True | 0.669818 | 0.330182 | 992 | 0.901210 | 0.786456 | 0.744335 | 0.011089 | 0.002016 | 0.80 | 0.2 | True |
| 3 | prob>=0.75_gain<=0.2_forbidSexOnly=True | 0.675895 | 0.324105 | 1001 | 0.898102 | 0.784482 | 0.735513 | 0.013986 | 0.001998 | 0.75 | 0.2 | True |
| 45 | prob>=0.95_gain<=0.3_forbidSexOnly=True | 0.430790 | 0.569210 | 638 | 0.962382 | 0.860778 | 0.850745 | 0.017241 | 0.000000 | 0.95 | 0.3 | True |
| 22 | prob>=0.85_gain<=0.2_forbidSexOnly=False | 0.613099 | 0.386901 | 908 | 0.912996 | 0.815159 | 0.777778 | 0.017621 | 0.001101 | 0.85 | 0.2 | False |
| 32 | prob>=0.9_gain<=0.2_forbidSexOnly=False | 0.536124 | 0.463876 | 794 | 0.929471 | 0.840871 | 0.810085 | 0.018892 | 0.000000 | 0.90 | 0.2 | False |
| 35 | prob>=0.9_gain<=0.3_forbidSexOnly=True | 0.546253 | 0.453747 | 809 | 0.925834 | 0.815745 | 0.789970 | 0.021014 | 0.002472 | 0.90 | 0.3 | True |
6. 一个更像论文实验的筛选方式¶
上面的表会给出很多极端方案,例如:
- 非常安全,但几乎全都拒答
- 覆盖率高,但仍然不安全
所以这里额外加一个“实用筛选”:
- coverage 至少 30%
- unsafe rate 尽量低
- selective Macro-F1 尽量高
In [8]:
practical_df = grid_df[grid_df['coverage'] >= 0.30].copy()
practical_df = practical_df.sort_values(
['unsafe_rate_among_diagnosed', 'selective_macro_f1', 'coverage'],
ascending=[True, False, False]
)
practical_df.head(20)
Out[8]:
| policy_name | coverage | abstain_rate | diagnosed_cases | selective_accuracy | selective_balanced_accuracy | selective_macro_f1 | unsafe_rate_among_diagnosed | worsened_rate_among_diagnosed | prob_threshold | gain_cap | forbid_sex_only | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 40 | prob>=0.95_gain<=0.1_forbidSexOnly=False | 0.411209 | 0.588791 | 609 | 0.978654 | 0.917516 | 0.906395 | 0.000000 | 0.000000 | 0.95 | 0.1 | False |
| 41 | prob>=0.95_gain<=0.1_forbidSexOnly=True | 0.411209 | 0.588791 | 609 | 0.978654 | 0.917516 | 0.906395 | 0.000000 | 0.000000 | 0.95 | 0.1 | True |
| 30 | prob>=0.9_gain<=0.1_forbidSexOnly=False | 0.516543 | 0.483457 | 765 | 0.946405 | 0.859116 | 0.837549 | 0.000000 | 0.000000 | 0.90 | 0.1 | False |
| 31 | prob>=0.9_gain<=0.1_forbidSexOnly=True | 0.516543 | 0.483457 | 765 | 0.946405 | 0.859116 | 0.837549 | 0.000000 | 0.000000 | 0.90 | 0.1 | True |
| 20 | prob>=0.85_gain<=0.1_forbidSexOnly=False | 0.588116 | 0.411884 | 871 | 0.928817 | 0.827409 | 0.796048 | 0.000000 | 0.000000 | 0.85 | 0.1 | False |
| 21 | prob>=0.85_gain<=0.1_forbidSexOnly=True | 0.587441 | 0.412559 | 870 | 0.928736 | 0.827403 | 0.796042 | 0.000000 | 0.000000 | 0.85 | 0.1 | True |
| 11 | prob>=0.8_gain<=0.1_forbidSexOnly=True | 0.653612 | 0.346388 | 968 | 0.911157 | 0.813572 | 0.771927 | 0.001033 | 0.000000 | 0.80 | 0.1 | True |
| 1 | prob>=0.75_gain<=0.1_forbidSexOnly=True | 0.657664 | 0.342336 | 974 | 0.909651 | 0.812907 | 0.761828 | 0.002053 | 0.000000 | 0.75 | 0.1 | True |
| 10 | prob>=0.8_gain<=0.1_forbidSexOnly=False | 0.656313 | 0.343687 | 972 | 0.909465 | 0.810749 | 0.770361 | 0.002058 | 0.001029 | 0.80 | 0.1 | False |
| 0 | prob>=0.75_gain<=0.1_forbidSexOnly=False | 0.661715 | 0.338285 | 980 | 0.908163 | 0.810217 | 0.760314 | 0.003061 | 0.001020 | 0.75 | 0.1 | False |
| 43 | prob>=0.95_gain<=0.2_forbidSexOnly=True | 0.414585 | 0.585415 | 614 | 0.975570 | 0.905699 | 0.899476 | 0.003257 | 0.000000 | 0.95 | 0.2 | True |
| 33 | prob>=0.9_gain<=0.2_forbidSexOnly=True | 0.523295 | 0.476705 | 775 | 0.940645 | 0.854305 | 0.827600 | 0.006452 | 0.000000 | 0.90 | 0.2 | True |
| 23 | prob>=0.85_gain<=0.2_forbidSexOnly=True | 0.596219 | 0.403781 | 883 | 0.922990 | 0.825248 | 0.789426 | 0.006795 | 0.000000 | 0.85 | 0.2 | True |
| 42 | prob>=0.95_gain<=0.2_forbidSexOnly=False | 0.419986 | 0.580014 | 622 | 0.971061 | 0.905799 | 0.892970 | 0.008039 | 0.000000 | 0.95 | 0.2 | False |
| 13 | prob>=0.8_gain<=0.2_forbidSexOnly=True | 0.669818 | 0.330182 | 992 | 0.901210 | 0.786456 | 0.744335 | 0.011089 | 0.002016 | 0.80 | 0.2 | True |
| 3 | prob>=0.75_gain<=0.2_forbidSexOnly=True | 0.675895 | 0.324105 | 1001 | 0.898102 | 0.784482 | 0.735513 | 0.013986 | 0.001998 | 0.75 | 0.2 | True |
| 45 | prob>=0.95_gain<=0.3_forbidSexOnly=True | 0.430790 | 0.569210 | 638 | 0.962382 | 0.860778 | 0.850745 | 0.017241 | 0.000000 | 0.95 | 0.3 | True |
| 22 | prob>=0.85_gain<=0.2_forbidSexOnly=False | 0.613099 | 0.386901 | 908 | 0.912996 | 0.815159 | 0.777778 | 0.017621 | 0.001101 | 0.85 | 0.2 | False |
| 32 | prob>=0.9_gain<=0.2_forbidSexOnly=False | 0.536124 | 0.463876 | 794 | 0.929471 | 0.840871 | 0.810085 | 0.018892 | 0.000000 | 0.90 | 0.2 | False |
| 35 | prob>=0.9_gain<=0.3_forbidSexOnly=True | 0.546253 | 0.453747 | 809 | 0.925834 | 0.815745 | 0.789970 | 0.021014 | 0.002472 | 0.90 | 0.3 | True |
In [9]:
best_row = practical_df.iloc[0]
best_row
Out[9]:
policy_name prob>=0.95_gain<=0.1_forbidSexOnly=False coverage 0.411209 abstain_rate 0.588791 diagnosed_cases 609 selective_accuracy 0.978654 selective_balanced_accuracy 0.917516 selective_macro_f1 0.906395 unsafe_rate_among_diagnosed 0.0 worsened_rate_among_diagnosed 0.0 prob_threshold 0.95 gain_cap 0.1 forbid_sex_only False Name: 40, dtype: object
In [10]:
best_mask = dynamic_df['final_max_prob'] >= best_row['prob_threshold']
best_mask &= dynamic_df['confidence_gain'] <= best_row['gain_cap']
if bool(best_row['forbid_sex_only']):
best_mask &= ~dynamic_df['sex_only']
best_summary, best_diagnosed = evaluate_abstention_policy(
dynamic_df,
best_mask,
policy_name='best_safe_candidate'
)
best_summary
Out[10]:
{'policy_name': 'best_safe_candidate',
'coverage': 0.4112086428089129,
'abstain_rate': 0.5887913571910871,
'diagnosed_cases': 609,
'selective_accuracy': 0.9786535303776683,
'selective_balanced_accuracy': 0.9175158099068161,
'selective_macro_f1': 0.9063948515845798,
'unsafe_rate_among_diagnosed': np.float64(0.0),
'worsened_rate_among_diagnosed': np.float64(0.0)}
In [11]:
abstained_df = dynamic_df[~best_mask].copy()
print('Abstained cases:', len(abstained_df))
print('Unsafe confidence increase among abstained:', abstained_df['unsafe_confidence_increase'].mean())
print('Worsened among abstained:', abstained_df['worsened'].mean())
print('Initially correct among abstained:', abstained_df['initial_correct'].mean())
abstained_df[[
'image_id', 'true_label', 'initial_pred_label', 'initial_max_prob',
'final_pred_label', 'final_max_prob', 'asked_questions',
'unsafe_confidence_increase', 'worsened'
]].head(20)
Abstained cases: 872 Unsafe confidence increase among abstained: 0.19724770642201836 Worsened among abstained: 0.0676605504587156 Initially correct among abstained: 0.6364678899082569
Out[11]:
| image_id | true_label | initial_pred_label | initial_max_prob | final_pred_label | final_max_prob | asked_questions | unsafe_confidence_increase | worsened | |
|---|---|---|---|---|---|---|---|---|---|
| 1 | ISIC_0025209 | bkl | bkl | 0.407122 | bkl | 0.874424 | ['age', 'sex'] | False | False |
| 2 | ISIC_0029161 | bkl | bkl | 0.793358 | bkl | 0.987926 | ['sex'] | False | False |
| 3 | ISIC_0026273 | bkl | bkl | 0.802558 | bkl | 0.802558 | [] | False | False |
| 5 | ISIC_0032013 | bkl | bkl | 0.762678 | bkl | 0.998160 | ['sex'] | False | False |
| 6 | ISIC_0029289 | bkl | nv | 0.464676 | nv | 0.983204 | ['sex'] | True | False |
| 7 | ISIC_0029912 | bkl | bkl | 0.617092 | nv | 0.963171 | ['sex'] | False | True |
| 8 | ISIC_0033539 | bkl | bkl | 0.706276 | bkl | 0.973478 | ['sex'] | False | False |
| 10 | ISIC_0029022 | bkl | bkl | 0.822935 | bkl | 0.822935 | [] | False | False |
| 11 | ISIC_0027957 | bkl | nv | 0.798028 | nv | 0.998547 | ['sex'] | True | False |
| 12 | ISIC_0031212 | bkl | nv | 0.540349 | nv | 0.907318 | ['sex'] | True | False |
| 13 | ISIC_0033646 | bkl | bkl | 0.609726 | mel | 0.663127 | ['sex', 'age'] | False | True |
| 14 | ISIC_0033592 | bkl | bkl | 0.567815 | mel | 0.986496 | ['sex'] | False | True |
| 15 | ISIC_0033899 | bkl | mel | 0.668489 | mel | 0.999973 | ['sex'] | True | False |
| 16 | ISIC_0033716 | bkl | nv | 0.373803 | mel | 0.903718 | ['location'] | True | False |
| 17 | ISIC_0034011 | bkl | bkl | 0.357385 | nv | 0.882959 | ['sex'] | False | True |
| 18 | ISIC_0033613 | bkl | bkl | 0.837788 | bkl | 0.837788 | [] | False | False |
| 19 | ISIC_0032877 | bkl | bkl | 0.558223 | mel | 0.819012 | ['sex'] | False | True |
| 20 | ISIC_0034175 | bkl | bkl | 0.864006 | bkl | 0.864006 | [] | False | False |
| 21 | ISIC_0033523 | bkl | bkl | 0.492590 | bkl | 0.830303 | ['age', 'location'] | False | False |
| 22 | ISIC_0033280 | bkl | bkl | 0.496546 | bkl | 0.865970 | ['sex'] | False | False |
In [12]:
timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')
policy_grid_path = SUPPORT_DIR / f'{timestamp}_safe_agent_policy_grid.csv'
practical_path = SUPPORT_DIR / f'{timestamp}_safe_agent_practical_candidates.csv'
best_summary_path = SUPPORT_DIR / f'{timestamp}_safe_agent_best_summary.json'
diagnosed_path = SUPPORT_DIR / f'{timestamp}_safe_agent_diagnosed_cases.csv'
abstained_path = SUPPORT_DIR / f'{timestamp}_safe_agent_abstained_cases.csv'
grid_df.to_csv(policy_grid_path, index=False)
practical_df.to_csv(practical_path, index=False)
best_diagnosed.to_csv(diagnosed_path, index=False)
abstained_df.to_csv(abstained_path, index=False)
with open(best_summary_path, 'w', encoding='utf-8') as f:
json.dump(best_summary, f, ensure_ascii=False, indent=2)
print(policy_grid_path)
print(practical_path)
print(best_summary_path)
print(diagnosed_path)
print(abstained_path)
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_133250_safe_agent_policy_grid.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_133250_safe_agent_practical_candidates.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_133250_safe_agent_best_summary.json /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_133250_safe_agent_diagnosed_cases.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_133250_safe_agent_abstained_cases.csv
9. 你接下来应该怎么看这些结果¶
你重点看三件事:
unsafe_rate_among_diagnosed有没有明显下降coverage会不会掉得太狠selective_macro_f1能不能保持在一个还能接受的水平
如果这一版有希望,我们下一步就不再做 post-hoc 规则,而是把安全机制正式塞回 dynamic agent 流程里,变成真正的:
ask / diagnose / abstain
三动作 agent。
跑下来我的结论是:保守一点也未必是件坏事¶
10. Safety-Coverage Trade-off Summary¶
这一节不再看几十条规则,而是只挑 3 个最有代表性的安全方案:
- 高安全:尽可能避免危险高置信错误
- 中等平衡:安全和覆盖率之间折中
- 高覆盖:尽量多诊断,但仍比原始 dynamic agent 更稳
这 3 组结果很适合后面直接写进论文。
In [13]:
representative_policies = [
'prob>=0.95_gain<=0.1_forbidSexOnly=False',
'prob>=0.9_gain<=0.1_forbidSexOnly=False',
'prob>=0.85_gain<=0.1_forbidSexOnly=False',
]
tradeoff_df = grid_df[grid_df['policy_name'].isin(representative_policies)].copy()
label_map = {
'prob>=0.95_gain<=0.1_forbidSexOnly=False': 'high_safety',
'prob>=0.9_gain<=0.1_forbidSexOnly=False': 'balanced',
'prob>=0.85_gain<=0.1_forbidSexOnly=False': 'high_coverage',
}
tradeoff_df['policy_label'] = tradeoff_df['policy_name'].map(label_map)
tradeoff_df = tradeoff_df[[
'policy_label',
'policy_name',
'coverage',
'abstain_rate',
'diagnosed_cases',
'selective_accuracy',
'selective_balanced_accuracy',
'selective_macro_f1',
'unsafe_rate_among_diagnosed',
'worsened_rate_among_diagnosed',
'prob_threshold',
'gain_cap',
'forbid_sex_only'
]].sort_values('coverage')
tradeoff_df
Out[13]:
| policy_label | policy_name | coverage | abstain_rate | diagnosed_cases | selective_accuracy | selective_balanced_accuracy | selective_macro_f1 | unsafe_rate_among_diagnosed | worsened_rate_among_diagnosed | prob_threshold | gain_cap | forbid_sex_only | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 40 | high_safety | prob>=0.95_gain<=0.1_forbidSexOnly=False | 0.411209 | 0.588791 | 609 | 0.978654 | 0.917516 | 0.906395 | 0.0 | 0.0 | 0.95 | 0.1 | False |
| 30 | balanced | prob>=0.9_gain<=0.1_forbidSexOnly=False | 0.516543 | 0.483457 | 765 | 0.946405 | 0.859116 | 0.837549 | 0.0 | 0.0 | 0.90 | 0.1 | False |
| 20 | high_coverage | prob>=0.85_gain<=0.1_forbidSexOnly=False | 0.588116 | 0.411884 | 871 | 0.928817 | 0.827409 | 0.796048 | 0.0 | 0.0 | 0.85 | 0.1 | False |
怎么读这张表¶
high_safety:最保守,诊断最少,但最安全balanced:我建议后面论文里重点讨论这一组high_coverage:诊断更多,但安全性开始下降
这三组能非常直观地展示:
Safety 和 Coverage 之间存在清晰的 trade-off。
In [14]:
paper_tradeoff_df = tradeoff_df.copy()
for col in [
'coverage', 'abstain_rate', 'selective_accuracy',
'selective_balanced_accuracy', 'selective_macro_f1',
'unsafe_rate_among_diagnosed', 'worsened_rate_among_diagnosed'
]:
paper_tradeoff_df[col] = paper_tradeoff_df[col].map(lambda x: round(float(x), 4))
paper_tradeoff_df
Out[14]:
| policy_label | policy_name | coverage | abstain_rate | diagnosed_cases | selective_accuracy | selective_balanced_accuracy | selective_macro_f1 | unsafe_rate_among_diagnosed | worsened_rate_among_diagnosed | prob_threshold | gain_cap | forbid_sex_only | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 40 | high_safety | prob>=0.95_gain<=0.1_forbidSexOnly=False | 0.4112 | 0.5888 | 609 | 0.9787 | 0.9175 | 0.9064 | 0.0 | 0.0 | 0.95 | 0.1 | False |
| 30 | balanced | prob>=0.9_gain<=0.1_forbidSexOnly=False | 0.5165 | 0.4835 | 765 | 0.9464 | 0.8591 | 0.8375 | 0.0 | 0.0 | 0.90 | 0.1 | False |
| 20 | high_coverage | prob>=0.85_gain<=0.1_forbidSexOnly=False | 0.5881 | 0.4119 | 871 | 0.9288 | 0.8274 | 0.7960 | 0.0 | 0.0 | 0.85 | 0.1 | False |
In [15]:
tradeoff_timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')
tradeoff_csv_path = SUPPORT_DIR / f'{tradeoff_timestamp}_safe_agent_tradeoff_summary.csv'
tradeoff_json_path = SUPPORT_DIR / f'{tradeoff_timestamp}_safe_agent_tradeoff_summary.json'
paper_tradeoff_df.to_csv(tradeoff_csv_path, index=False)
paper_tradeoff_df.to_json(tradeoff_json_path, orient='records', force_ascii=False, indent=2)
print(tradeoff_csv_path)
print(tradeoff_json_path)
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_141020_safe_agent_tradeoff_summary.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_141020_safe_agent_tradeoff_summary.json