16 Case-Specific Risk-Aware Agent¶

这一份 notebook 开始做真正的 病例级问题选择。

前面 15 号的问题在于:

  • 虽然名字叫 adaptive
  • 但打分仍然只依赖全局统计和状态级分数
  • 所以在同一个状态下,不同病例仍然会走同一条路

这一版要解决的就是这个问题:

让不同病例根据自己的当前预测分布,选择不同的问题。

核心思路:

  1. 先读取 image-only 的病例级概率分布
  2. 对每个病例,提取当前最可能的候选类别结构
  3. 用“候选类别不确定性 + 问题风险惩罚”来给 age / sex / location 打分
  4. 选对当前病例最合适的问题,而不是全局统一排序
In [3]:
from pathlib import Path
from datetime import datetime
import json

import numpy as np
import pandas as pd

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'
QUESTION_SCORE_PATH = SUPPORT_DIR / '2026-08-04_163052_risk_aware_score_table.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(IMAGE_ONLY_CASE_PATH)
print(QUESTION_SCORE_PATH)
print(MODEL_REGISTRY_PATH)
/Users/applesues01/Documents/Medical_Agent/supports/image_only_case_level_predictions.csv
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_163052_risk_aware_score_table.csv
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-03_194704_all_saved_metadata_models.csv

1. 读取数据和模型分数¶

In [4]:
train_df = pd.read_csv(SPLIT_DIR / 'train.csv')
val_df = pd.read_csv(SPLIT_DIR / 'val.csv')
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)
question_score_df = pd.read_csv(QUESTION_SCORE_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']),
    }

len(test_df), len(image_only_case_df)
Out[4]:
(1481, 1481)
In [5]:
global_question_score = {
    row['question']: float(row['risk_aware_score'])
    for _, row in question_score_df.iterrows()
}

global_question_score
Out[5]:
{'age': -0.4508196721311475,
 'sex': -0.6773255813953488,
 'location': -0.8148148148148148}

2. 合并病例级 image-only 预测分布¶

这一步是关键。我们后面的提问决策,第一次真正依赖具体病例自己的概率分布。

In [6]:
case_df = test_df.merge(image_only_case_df, on=['image_id'], how='left')
prob_cols = [c for c in case_df.columns if c.startswith('prob_')]
class_names = [c.replace('prob_', '') for c in prob_cols]

case_df[['image_id', 'dx', 'pred_label', 'max_prob'] + prob_cols[:3]].head()
Out[6]:
image_id dx pred_label max_prob prob_akiec prob_bcc prob_bkl
0 ISIC_0025837 bkl bkl 0.972961 0.004813 0.001014 0.972961
1 ISIC_0025209 bkl bkl 0.407122 0.220992 0.070574 0.407122
2 ISIC_0029161 bkl bkl 0.793358 0.000883 0.001113 0.793358
3 ISIC_0026273 bkl bkl 0.802558 0.000078 0.005748 0.802558
4 ISIC_0025819 bkl bkl 0.978878 0.000174 0.000190 0.978878

3. 提取病例级不确定性结构¶

我们先从 image-only 概率分布里提取几个最直观的结构:

  • top1 类别
  • top2 类别
  • top1-top2 margin
  • 熵(entropy)

这些量会帮助我们判断:当前病例最像哪些病,以及它到底有多纠结。

In [7]:
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,
    })

case_structure_df = case_df.apply(extract_case_structure, axis=1)
case_df = pd.concat([case_df, case_structure_df], axis=1)
case_df[['image_id', 'dx', 'top1_cls', 'top2_cls', 'top1_prob', 'top2_prob', 'margin', 'entropy']].head()
Out[7]:
image_id dx top1_cls top2_cls top1_prob top2_prob margin entropy
0 ISIC_0025837 bkl bkl mel 0.972961 0.018995 0.953966 0.150215
1 ISIC_0025209 bkl bkl akiec 0.407122 0.220992 0.186130 1.487569
2 ISIC_0029161 bkl bkl nv 0.793358 0.127832 0.665526 0.669199
3 ISIC_0026273 bkl bkl mel 0.802558 0.094588 0.707970 0.723783
4 ISIC_0025819 bkl bkl nv 0.978878 0.009492 0.969386 0.123900

4. 定义病例级问题打分¶

这一版先做一个简单、可解释的病例级打分:

局部收益¶

如果当前状态下问某个问题,未来模型的 validation Macro-F1 能提升多少。

病例级不确定性放大¶

如果当前病例本来就很不确定(比如 top1-top2 margin 很小),那么更值得提问。

风险惩罚¶

如果某个问题在全局统计上更危险,就减分。

一个简单形式:

score = local_gain + lambda * uncertainty_bonus + alpha * risk_prior

In [8]:
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 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'],
        'top1_prob': float(row['top1_prob']),
        'top2_prob': float(row['top2_prob']),
        'margin': float(row['margin']),
        'entropy': float(row['entropy']),
    }


def ask_question(state, row, question):
    assert question in ['age', 'sex', 'location']
    new_state = {
        'image_id': state['image_id'],
        'true_label': state['true_label'],
        'known_metadata': dict(state['known_metadata']),
        'asked_questions': list(state['asked_questions']),
        'done': state['done'],
        'top1_cls': state['top1_cls'],
        'top2_cls': state['top2_cls'],
        'top1_prob': state['top1_prob'],
        'top2_prob': state['top2_prob'],
        'margin': state['margin'],
        'entropy': state['entropy'],
    }

    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_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']
In [9]:
def case_specific_question_score(state, question, alpha=1.0, lambda_uncertainty=0.2):
    current_known = set(state['known_metadata'].keys())
    if question in current_known:
        return -1e9

    current_key = get_model_key_from_known_set(current_known)
    future_key = get_model_key_from_known_set(current_known | {question})

    local_gain = policy_model_scores[future_key]['macro_f1'] - policy_model_scores[current_key]['macro_f1']
    risk_prior = global_question_score[question]

    uncertainty_bonus = (1.0 - state['margin']) + 0.1 * state['entropy']

    return local_gain + lambda_uncertainty * uncertainty_bonus + alpha * risk_prior


def case_specific_risk_aware_policy(state, max_questions=2, alpha=1.0, lambda_uncertainty=0.2):
    if len(state['asked_questions']) >= max_questions:
        return 'diagnose'

    candidates = [q for q in ['age', 'sex', 'location'] if q not in state['asked_questions']]
    if not candidates:
        return 'diagnose'

    best_question = None
    best_score = -1e18

    for q in candidates:
        s = case_specific_question_score(state, q, alpha=alpha, lambda_uncertainty=lambda_uncertainty)
        if s > best_score:
            best_score = s
            best_question = q

    if best_question is None:
        return 'diagnose'
    return best_question

5. 先看几个单病例轨迹¶

In [10]:
def run_case_specific_episode(row, max_questions=2, alpha=1.0, lambda_uncertainty=0.2):
    state = build_initial_state(row)
    trajectory = []

    while not state['done']:
        action = case_specific_risk_aware_policy(
            state,
            max_questions=max_questions,
            alpha=alpha,
            lambda_uncertainty=lambda_uncertainty,
        )

        trajectory.append({
            'step': len(trajectory),
            'action': action,
            'model_key_before_action': get_model_key_from_state(state),
            'score_before_action': score_state(state),
            'known_metadata_before_action': dict(state['known_metadata']),
            'top1_cls': state['top1_cls'],
            'top2_cls': state['top2_cls'],
            'margin': state['margin'],
        })

        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 [11]:
sample_row = case_df.iloc[0]
trajectory, final_state = run_case_specific_episode(sample_row, max_questions=2, alpha=1.0, lambda_uncertainty=0.2)
trajectory, final_state
Out[11]:
([{'step': 0,
   'action': 'age',
   'model_key_before_action': 'image_only',
   'score_before_action': 0.6083234281767498,
   'known_metadata_before_action': {},
   'top1_cls': 'bkl',
   'top2_cls': 'mel',
   'margin': 0.9539660867303611},
  {'step': 1,
   'action': 'sex',
   'model_key_before_action': 'image_age',
   'score_before_action': 0.5926280556025649,
   'known_metadata_before_action': {'age': np.float64(70.0)},
   'top1_cls': 'bkl',
   'top2_cls': 'mel',
   'margin': 0.9539660867303611},
  {'step': 2,
   'action': 'diagnose',
   'model_key_before_action': 'image_age_sex',
   'score_before_action': 0.5964635518225668,
   'known_metadata_before_action': {'age': np.float64(70.0), 'sex': 'female'},
   'top1_cls': 'bkl',
   'top2_cls': 'mel',
   'margin': 0.9539660867303611}],
 {'image_id': 'ISIC_0025837',
  'true_label': 'bkl',
  'known_metadata': {'age': np.float64(70.0), 'sex': 'female'},
  'asked_questions': ['age', 'sex'],
  'done': True,
  'top1_cls': 'bkl',
  'top2_cls': 'mel',
  'top1_prob': 0.972960650920868,
  'top2_prob': 0.0189945641905069,
  'margin': 0.9539660867303611,
  'entropy': 0.15021531890606907,
  'final_action': 'diagnose',
  'final_model_key': 'image_age_sex',
  'final_score': 0.5964635518225668})

6. 全测试集跑第一版病例级 risk-aware adaptive agent¶

In [12]:
def evaluate_case_specific_agent(alpha=1.0, lambda_uncertainty=0.2, max_questions=2):
    records = []
    for idx in range(len(case_df)):
        row = case_df.iloc[idx]
        trajectory, final_state = run_case_specific_episode(
            row,
            max_questions=max_questions,
            alpha=alpha,
            lambda_uncertainty=lambda_uncertainty,
        )
        records.append({
            'image_id': row['image_id'],
            'true_label': row['dx'],
            'top1_cls': row['top1_cls'],
            'top2_cls': row['top2_cls'],
            'margin': row['margin'],
            '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'],
            'known_metadata': dict(final_state['known_metadata']),
        })

    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': 'case_specific_risk_aware',
        'alpha': alpha,
        'lambda_uncertainty': lambda_uncertainty,
        '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 [13]:
case_specific_cases_df, case_specific_summary_df, case_specific_result = evaluate_case_specific_agent(
    alpha=1.0,
    lambda_uncertainty=0.2,
    max_questions=2,
)
case_specific_result
Out[13]:
{'agent_name': 'case_specific_risk_aware',
 'alpha': 1.0,
 'lambda_uncertainty': 0.2,
 'max_questions': 2,
 'avg_questions': 2.0,
 'expected_accuracy': 0.7886563133018231,
 'expected_balanced_accuracy': 0.5856809903171467,
 'expected_macro_f1': 0.5837050128198616}

7. 和之前 baseline agent 对比¶

In [14]:
comparison_df = pd.concat([
    validated_agent_df,
    pd.DataFrame([case_specific_result])
], ignore_index=True)
comparison_df
Out[14]:
agent_name max_questions avg_questions expected_accuracy expected_balanced_accuracy expected_macro_f1 threshold alpha lambda_uncertainty
0 fixed_order 2 2.0 0.793383 0.582281 0.581666 NaN NaN NaN
1 uncertainty 2 2.0 0.793383 0.582281 0.581666 0.605 NaN NaN
2 lookahead 2 2.0 0.792708 0.598458 0.596497 NaN NaN NaN
3 case_specific_risk_aware 2 2.0 0.788656 0.585681 0.583705 NaN 1.0 0.2

8. 保存结果¶

In [15]:
timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')

cases_path = SUPPORT_DIR / f'{timestamp}_case_specific_risk_aware_cases.csv'
summary_path = SUPPORT_DIR / f'{timestamp}_case_specific_risk_aware_summary.csv'
result_path = SUPPORT_DIR / f'{timestamp}_case_specific_risk_aware_result.json'
comparison_path = SUPPORT_DIR / f'{timestamp}_case_specific_risk_aware_comparison.csv'

case_specific_cases_df.to_csv(cases_path, index=False)
case_specific_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(case_specific_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_192036_case_specific_risk_aware_cases.csv
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_192036_case_specific_risk_aware_summary.csv
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_192036_case_specific_risk_aware_result.json
/Users/applesues01/Documents/Medical_Agent/supports/2026-08-04_192036_case_specific_risk_aware_comparison.csv

9. 如果这一版还是不行,说明什么¶

如果这一版仍然超不过 lookahead,那就说明:

  • 仅靠 image-only 概率结构 + 全局风险分数还不够
  • 下一步就必须真正做更像信息增益的策略
  • 也就是:问题的价值要和当前候选类别分布更直接地绑定起来