针对不同类型,高风险类拒打概率增加,低风险类增加提问可能¶
18 Class-Aware Safety Agent¶
这一份 notebook 的目标是做第一版 按类别分流的安全 agent。
前面的类别分析已经说明:
- 有些类别可能从提问中受益
- 有些类别会被提问明显带偏
- 如果所有类别都用同一套提问/拒答策略,整体效果会被平均掉
所以这一版我们不再“一刀切”,而是先按当前实验现象把 7 类分成 3 组:
potential_benefit:nv,melhigh_risk:bkl,df,akiecneutral:bcc,vasc
然后为不同组设置不同的策略强度。
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')
DATA_DIR = PROJECT_ROOT / 'data' / 'HAM10000'
SPLIT_DIR = DATA_DIR / 'splits'
SUPPORT_DIR = PROJECT_ROOT / 'supports'
BASELINE_RESULTS_PATH = SUPPORT_DIR / 'baseline_results.csv'
MODEL_REGISTRY_PATH = SUPPORT_DIR / '2026-08-03_194704_all_saved_metadata_models.csv'
IMAGE_ONLY_CASE_PATH = SUPPORT_DIR / 'image_only_case_level_predictions.csv'
VALIDATED_AGENT_PATH = SUPPORT_DIR / '2026-08-03_185733_validated_agent_comparison.csv'
print(BASELINE_RESULTS_PATH)
print(MODEL_REGISTRY_PATH)
print(IMAGE_ONLY_CASE_PATH)
print(VALIDATED_AGENT_PATH)
/Users/applesues01/Documents/Medical_Agent/supports/baseline_results.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-03_194704_all_saved_metadata_models.csv /Users/applesues01/Documents/Medical_Agent/supports/image_only_case_level_predictions.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-03_185733_validated_agent_comparison.csv
1. 读取数据和当前模型结果¶
In [2]:
test_df = pd.read_csv(SPLIT_DIR / 'test.csv')
baseline_df = pd.read_csv(BASELINE_RESULTS_PATH)
model_registry_df = pd.read_csv(MODEL_REGISTRY_PATH)
image_only_case_df = pd.read_csv(IMAGE_ONLY_CASE_PATH)
validated_agent_df = pd.read_csv(VALIDATED_AGENT_PATH)
image_only_row = baseline_df[baseline_df['Method'] == 'Image Only'].iloc[0]
policy_model_scores = {
'image_only': {'macro_f1': float(image_only_row['Macro-F1'])},
}
report_model_scores = {
'image_only': {
'accuracy': float(image_only_row['Accuracy']),
'balanced_accuracy': float(image_only_row['Balanced Accuracy']),
'macro_f1': float(image_only_row['Macro-F1']),
}
}
for _, row in model_registry_df.iterrows():
key = str(row['Method'])
policy_model_scores[key] = {'macro_f1': float(row['Best Val Macro-F1'])}
report_model_scores[key] = {
'accuracy': float(row['Test Accuracy']),
'balanced_accuracy': float(row['Test Balanced Accuracy']),
'macro_f1': float(row['Test Macro-F1']),
}
case_df = test_df.merge(image_only_case_df, on=['image_id'], how='left')
len(case_df)
Out[2]:
1481
2. 定义类别分组¶
In [3]:
CLASS_GROUPS = {
'potential_benefit': ['nv', 'mel'],
'high_risk': ['bkl', 'df', 'akiec'],
'neutral': ['bcc', 'vasc'],
}
def get_class_group(pred_cls):
for group_name, classes in CLASS_GROUPS.items():
if pred_cls in classes:
return group_name
return 'neutral'
CLASS_GROUPS
Out[3]:
{'potential_benefit': ['nv', 'mel'],
'high_risk': ['bkl', 'df', 'akiec'],
'neutral': ['bcc', 'vasc']}
3. 提取 image-only 的病例级结构¶
这里继续使用 image-only 的预测分布来判断当前病例更像哪一组。
In [4]:
prob_cols = [c for c in case_df.columns if c.startswith('prob_')]
class_names = [c.replace('prob_', '') for c in prob_cols]
def extract_case_structure(row):
probs = np.array([row[c] for c in prob_cols], dtype=float)
order = np.argsort(-probs)
top1_idx = int(order[0])
top2_idx = int(order[1])
top1_cls = class_names[top1_idx]
top2_cls = class_names[top2_idx]
top1_prob = float(probs[top1_idx])
top2_prob = float(probs[top2_idx])
margin = top1_prob - top2_prob
entropy = float(-(probs * np.log(probs + 1e-12)).sum())
return pd.Series({
'top1_cls': top1_cls,
'top2_cls': top2_cls,
'top1_prob': top1_prob,
'top2_prob': top2_prob,
'margin': margin,
'entropy': entropy,
'predicted_group': get_class_group(top1_cls),
})
case_df = pd.concat([case_df, case_df.apply(extract_case_structure, axis=1)], axis=1)
case_df[['image_id', 'dx', 'top1_cls', 'top2_cls', 'predicted_group', 'margin', 'entropy']].head()
Out[4]:
| image_id | dx | top1_cls | top2_cls | predicted_group | margin | entropy | |
|---|---|---|---|---|---|---|---|
| 0 | ISIC_0025837 | bkl | bkl | mel | high_risk | 0.953966 | 0.150215 |
| 1 | ISIC_0025209 | bkl | bkl | akiec | high_risk | 0.186130 | 1.487569 |
| 2 | ISIC_0029161 | bkl | bkl | nv | high_risk | 0.665526 | 0.669199 |
| 3 | ISIC_0026273 | bkl | bkl | mel | high_risk | 0.707970 | 0.723783 |
| 4 | ISIC_0025819 | bkl | bkl | nv | high_risk | 0.969386 | 0.123900 |
In [5]:
GROUP_POLICY = {
'potential_benefit': ['age', 'location', 'sex'],
'high_risk': ['age'],
'neutral': ['age', 'sex', 'location'],
}
GROUP_POLICY
Out[5]:
{'potential_benefit': ['age', 'location', 'sex'],
'high_risk': ['age'],
'neutral': ['age', 'sex', 'location']}
5. 状态函数¶
In [6]:
def build_initial_state(row):
return {
'image_id': row['image_id'],
'true_label': row['dx'],
'known_metadata': {},
'asked_questions': [],
'done': False,
'top1_cls': row['top1_cls'],
'top2_cls': row['top2_cls'],
'margin': float(row['margin']),
'entropy': float(row['entropy']),
'predicted_group': row['predicted_group'],
}
def ask_question(state, row, question):
new_state = dict(state)
new_state['known_metadata'] = dict(state['known_metadata'])
new_state['asked_questions'] = list(state['asked_questions'])
if question == 'age':
new_state['known_metadata']['age'] = row['age']
elif question == 'sex':
new_state['known_metadata']['sex'] = row['sex']
elif question == 'location':
new_state['known_metadata']['location'] = row['localization']
new_state['asked_questions'].append(question)
return new_state
def get_model_key_from_known_set(known_set):
if known_set == set():
return 'image_only'
if known_set == {'age'}:
return 'image_age'
if known_set == {'sex'}:
return 'image_sex'
if known_set == {'location'}:
return 'image_location'
if known_set == {'age', 'sex'}:
return 'image_age_sex'
if known_set == {'age', 'location'}:
return 'image_age_location'
if known_set == {'sex', 'location'}:
return 'image_sex_location'
if known_set == {'age', 'sex', 'location'}:
return 'image_all_metadata'
raise ValueError(f'Unknown known_set: {known_set}')
def get_model_key_from_state(state):
return get_model_key_from_known_set(set(state['known_metadata'].keys()))
def score_state(state):
model_key = get_model_key_from_state(state)
return policy_model_scores[model_key]['macro_f1']
6. 定义 class-aware policy¶
In [7]:
def class_aware_policy(state, max_questions=2):
if len(state['asked_questions']) >= max_questions:
return 'diagnose'
group_name = state['predicted_group']
preferred_order = GROUP_POLICY[group_name]
for question in preferred_order:
if question not in state['asked_questions']:
return question
return 'diagnose'
7. 先看几个病例¶
In [8]:
def run_class_aware_episode(row, max_questions=2):
state = build_initial_state(row)
trajectory = []
while not state['done']:
action = class_aware_policy(state, max_questions=max_questions)
trajectory.append({
'step': len(trajectory),
'action': action,
'predicted_group': state['predicted_group'],
'model_key_before_action': get_model_key_from_state(state),
'score_before_action': score_state(state),
'known_metadata_before_action': dict(state['known_metadata']),
})
if action == 'diagnose':
state['done'] = True
state['final_action'] = 'diagnose'
state['final_model_key'] = get_model_key_from_state(state)
state['final_score'] = score_state(state)
break
state = ask_question(state, row, action)
return trajectory, state
In [9]:
trajectory, final_state = run_class_aware_episode(case_df.iloc[0], max_questions=2)
trajectory, final_state
Out[9]:
([{'step': 0,
'action': 'age',
'predicted_group': 'high_risk',
'model_key_before_action': 'image_only',
'score_before_action': 0.6083234281767498,
'known_metadata_before_action': {}},
{'step': 1,
'action': 'diagnose',
'predicted_group': 'high_risk',
'model_key_before_action': 'image_age',
'score_before_action': 0.5926280556025649,
'known_metadata_before_action': {'age': np.float64(70.0)}}],
{'image_id': 'ISIC_0025837',
'true_label': 'bkl',
'known_metadata': {'age': np.float64(70.0)},
'asked_questions': ['age'],
'done': True,
'top1_cls': 'bkl',
'top2_cls': 'mel',
'margin': 0.9539660867303611,
'entropy': 0.15021531890606907,
'predicted_group': 'high_risk',
'final_action': 'diagnose',
'final_model_key': 'image_age',
'final_score': 0.5926280556025649})
8. 全测试集评估¶
In [10]:
def evaluate_class_aware_agent(max_questions=2):
records = []
for idx in range(len(case_df)):
row = case_df.iloc[idx]
trajectory, final_state = run_class_aware_episode(row, max_questions=max_questions)
records.append({
'image_id': row['image_id'],
'true_label': row['dx'],
'top1_cls': row['top1_cls'],
'predicted_group': row['predicted_group'],
'asked_questions': list(final_state['asked_questions']),
'num_questions': len(final_state['asked_questions']),
'final_model_key': final_state['final_model_key'],
'final_policy_score': final_state['final_score'],
})
cases_df = pd.DataFrame(records)
summary_df = cases_df.groupby('final_model_key').size().reset_index(name='count')
summary_df['ratio'] = summary_df['count'] / len(cases_df)
summary_df['test_accuracy'] = summary_df['final_model_key'].map(lambda k: report_model_scores[k]['accuracy'])
summary_df['test_balanced_accuracy'] = summary_df['final_model_key'].map(lambda k: report_model_scores[k]['balanced_accuracy'])
summary_df['test_macro_f1'] = summary_df['final_model_key'].map(lambda k: report_model_scores[k]['macro_f1'])
result = {
'agent_name': 'class_aware_safety_agent',
'max_questions': max_questions,
'avg_questions': float(cases_df['num_questions'].mean()),
'expected_accuracy': float((summary_df['ratio'] * summary_df['test_accuracy']).sum()),
'expected_balanced_accuracy': float((summary_df['ratio'] * summary_df['test_balanced_accuracy']).sum()),
'expected_macro_f1': float((summary_df['ratio'] * summary_df['test_macro_f1']).sum()),
}
return cases_df, summary_df, result
In [11]:
class_aware_cases_df, class_aware_summary_df, class_aware_result = evaluate_class_aware_agent(max_questions=2)
class_aware_result
Out[11]:
{'agent_name': 'class_aware_safety_agent',
'max_questions': 2,
'avg_questions': 1.811613774476705,
'expected_accuracy': 0.7782225543355609,
'expected_balanced_accuracy': 0.579323177037876,
'expected_macro_f1': 0.5825781552298765}
9. 和已有 baseline 对比¶
In [12]:
comparison_df = pd.concat([
validated_agent_df,
pd.DataFrame([class_aware_result])
], ignore_index=True)
comparison_df
Out[12]:
| agent_name | max_questions | avg_questions | expected_accuracy | expected_balanced_accuracy | expected_macro_f1 | threshold | |
|---|---|---|---|---|---|---|---|
| 0 | fixed_order | 2 | 2.000000 | 0.793383 | 0.582281 | 0.581666 | NaN |
| 1 | uncertainty | 2 | 2.000000 | 0.793383 | 0.582281 | 0.581666 | 0.605 |
| 2 | lookahead | 2 | 2.000000 | 0.792708 | 0.598458 | 0.596497 | NaN |
| 3 | class_aware_safety_agent | 2 | 1.811614 | 0.778223 | 0.579323 | 0.582578 | NaN |
10. 保存结果¶
In [13]:
timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')
cases_path = SUPPORT_DIR / f'{timestamp}_class_aware_agent_cases.csv'
summary_path = SUPPORT_DIR / f'{timestamp}_class_aware_agent_summary.csv'
result_path = SUPPORT_DIR / f'{timestamp}_class_aware_agent_result.json'
comparison_path = SUPPORT_DIR / f'{timestamp}_class_aware_agent_comparison.csv'
class_aware_cases_df.to_csv(cases_path, index=False)
class_aware_summary_df.to_csv(summary_path, index=False)
comparison_df.to_csv(comparison_path, index=False)
with open(result_path, 'w', encoding='utf-8') as f:
json.dump(class_aware_result, f, ensure_ascii=False, indent=2)
print(cases_path)
print(summary_path)
print(result_path)
print(comparison_path)
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_194420_class_aware_agent_cases.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_194420_class_aware_agent_summary.csv /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_194420_class_aware_agent_result.json /Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_194420_class_aware_agent_comparison.csv
论文里怎么讲会比较好
这版结果最适合被写成:
A preliminary class-aware policy reduced the average number of questions, but did not yield a substantial improvement in overall Macro-F1, suggesting that coarse class grouping alone is insufficient for effective adaptive questioning.
中文就是: 初步的类别感知策略降低了平均提问次数,但未能带来显著的整体 Macro-F1 提升,这说明仅依赖粗粒度类别分组还不足以支持有效的自适应提问。
这个表述是正面的,因为它不是“失败”,而是明确告诉我们: 单靠粗分组不够,下一步要做更细的类别条件化决策,或者把类别感知和安全门结合起来。