11 Safe Dynamic Agent¶

这份 notebook 的目标不是继续抠一点点准确率,而是解决我们现在最核心的问题:

动态 agent 会把一些错误答案推到极高置信度,因此当前系统不安全。

这份实验先做 安全门 / 拒答机制(abstention),核心思路是:

  1. 读取已经跑完的 dynamic agent 病例级结果
  2. 设计几个简单但可解释的安全规则
  3. 评估覆盖率、选择性准确率、选择性 Macro-F1、危险错误率
  4. 把结果按时间戳保存下来

这一版先做 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. 定义安全评估函数¶

这里我们引入一个新的输出动作:

  • diagnose
  • abstain

评估时我们不只看准确率,还看:

  • coverage:有多少病例被真正诊断
  • selective_accuracy:只在被诊断的病例上,准确率是多少
  • selective_balanced_accuracy
  • selective_macro_f1
  • unsafe_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

4. 先跑几个直观的安全规则¶

这里先不做复杂模型,先做三种可解释规则:

Rule A: baseline dynamic¶

全部都诊断,不拒答。

Rule B: no sex-only escalation¶

如果只问了 sex,就拒答。因为我们已经看到很多危险病例都来自这里。

Rule C: conservative agreement gate¶

只有在下面条件同时满足时才诊断:

  • 最终置信度足够高
  • 置信度增长不过分夸张
  • 不是 sex-only

这不是最终答案,但非常适合做第一版安全实验。

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

7. 取当前最优候选,检查它到底拒掉了哪些病例¶

这一步非常重要,因为安全研究不能只看一个总分。我们要看:

  • 它拒掉的是不是危险病例
  • 它会不会把很多本来正确的病例也拒掉
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

8. 保存结果¶

这一格会把:

  • policy grid 搜索结果
  • practical 筛选结果
  • 最优候选摘要
  • 最优候选的诊断病例
  • 最优候选的拒答病例

全部按时间戳保存下来,方便后面回顾和写论文。

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. 你接下来应该怎么看这些结果¶

你重点看三件事:

  1. unsafe_rate_among_diagnosed 有没有明显下降
  2. coverage 会不会掉得太狠
  3. 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

自古忠孝难两全¶