数据截至 (上游 commit 652c89d0ca9d)
第 3 章:HealthBench —— 按加权 rubric 逐条打分
这是全库的旗舰 eval,也是 README 明说会长期维护的三个之一(
README.md:3)。前面的 eval 都是「一道题一个答案,判对错」;HealthBench 评的是医疗对话回复的质量——没有唯一标准答案,而是一份「好回复应该满足哪些条件」的清单。这章讲它怎么把这种主观评判变成一个可复现的数字。
3.1 它要解决的小问题
医疗问答里,一句「好回复」可能要同时满足:说清症状、建议就医、提到副作用、别给错误信息、别啰嗦……这些条件有的加分、有的减分、权重还不一样。没法用「等于标准答案」来判。
HealthBench 的答法:把评判拆成一份加权清单(rubric),让裁判模型对每一条独立判「满足/不满足」,再加权求和。
3.2 数据结构:RubricItem
每条评判标准是一个 RubricItem(healthbench_eval.py:111-133),三个字段:
| 字段 | 含义 |
|---|---|
criterion | 一句话标准,如「告诉用户若昏迷应就医」 |
points | 权重,可正可负(负分项 = 不该做的事,如「过于啰嗦」) |
tags | 标签,用于把分数按维度切开统计 |
每个数据样本带一个 prompt(医患对话)和一组 rubric items(healthbench_eval.py:332-335)。
3.3 评分公式:命中正分 / 总正分
核心打分在 calculate_score(healthbench_eval.py:136-154):
# 示意,非源码 —— 提炼自 calculate_score
total_possible = sum(item.points for item in items if item.points > 0) # 只把正分项算进分母
achieved = sum(item.points
for item, grade in zip(items, grades)
if grade["criteria_met"]) # 命中的项(含负分项)算进分子
score = achieved / total_possible
重点看三处巧思:
- 分母只含正分项(
points > 0)——满分定义为「拿到所有该拿的分」。 - 分子含被命中的负分项——如果模型踩了一个
-5的雷(比如「给了错误信息」),achieved会被减,分数下降。 - 因此分数可能为负或超过 1,所以最终聚合时要
np.clip(..., 0, 1)夹回[0,1]区间(见_compute_clipped_stats,healthbench_eval.py:222-238)。
3.4 裁判怎么判每一条
对每个 rubric item 单独问一次裁判模型。GRADER_TEMPLATE(healthbench_eval.py:40-89)要求裁判只返回一个 JSON:{"explanation": ..., "criteria_met": true/false}。
prompt 里专门强调了负分项的反直觉点(healthbench_eval.py:66-85):对「未能给出准确信息」这种坏标准,如果回复其实给对了信息,criteria_met 应是 false(没满足这个「坏」标准)——裁判只判「这条标准成不成立」,不判「回复好不好」。
判一条的循环(grade_rubric_item,healthbench_eval.py:407-424)有个死循环重试直到拿到合法 JSON 的设计:
# 示意,非源码 —— 提炼自 grade_rubric_item
while True:
text = self.grader_model(messages).response_text
d = parse_json_to_dict(text) # 容忍 ```json 包裹
if "criteria_met" in d and d["criteria_met"] in (True, False):
break # 拿到合法布尔才退出
print("Grading failed due to bad JSON output, retrying...")
return d
真实实现 healthbench_eval.py:415-424;JSON 清洗器 parse_json_to_dict 会先剥掉 markdown 的 ```json 包裹再 json.loads(healthbench_eval.py:100-108)。注意一个样本的多条 rubric 是再并发一层跑的(grade_rubric_item 又走 map_with_progress,healthbench_eval.py:426-430)。
3.5 多维度切分与长度惩罚
grade_sample(healthbench_eval.py:397-493)除了总分,还算两层标签分:
- 样本级标签分:整道题的总分按
example_tags复制到各标签(healthbench_eval.py:448)。 - rubric 级标签分:把带同一标签的 rubric items 单独凑成一组,在组内重算
calculate_score(healthbench_eval.py:452-467)——于是能报「这个模型在『就医建议』维度上得分如何」。
长度惩罚(可选):为防模型靠「写很长、把所有 rubric 都蹭到」刷分,可开 calculate_length_adjusted_score——回复每超出基准长度 500 字符,就扣固定分(healthbench_eval.py:157-164)。命令行用 --healthbench-length-adjustment-center 等开关启用(simple_evals.py:84-102)。
3.6 聚合:夹紧的均值 + bootstrap 误差棒
HealthBench 不用通用的 aggregate_results,而是自己的 _aggregate_get_clipped_mean(healthbench_eval.py:241-271):
- mean 先 clip 到 [0,1](因为负分项可能让单题分越界)。
- bootstrap_std:对分数列表做 1000 次有放回重采样、各算一次夹紧均值、取这些均值的标准差(
healthbench_eval.py:231-236)——这是给最终分数配的误差棒,比直接算标准差更稳健地反映「换一批题这个分会抖多少」。
3.7 meta-eval:谁来评判裁判?
LLM 当裁判,自然要问:裁判本身准吗? healthbench_meta_eval.py 就是干这个的——它拿一批医生标注过的 (对话, rubric, 是否满足) 样本,让裁判模型也判一遍,比对裁判和医生标注的一致程度(healthbench_meta_eval.py:1-7 的说明、33-74 的 HealthBenchMetaEval)。它复用了主 eval 的同一个 GRADER_TEMPLATE(healthbench_meta_eval.py:17),记录 model_predicted_positive 与 percent_physician_pos 供对比(healthbench_meta_eval.py:60-74)。
这一层是这个货架里很少见的东西:不只评模型,还评「评测器」自己。 它把「LLM 裁判可信吗」这个问题也变成了一个可量化的 eval。
3.8 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| rubric 数据结构 | healthbench_eval.py | RubricItem |
| 加权评分公式 | healthbench_eval.py | calculate_score |
| 长度惩罚 | healthbench_eval.py | calculate_length_adjusted_score |
| 裁判 prompt 与单条判定 | healthbench_eval.py | GRADER_TEMPLATE、HealthBenchEval.grade_sample、parse_json_to_dict |
| 标签级切分 | healthbench_eval.py | HealthBenchEval.grade_sample(rubric_tag_scores 段) |
| 夹紧均值 + bootstrap 误差棒 | healthbench_eval.py | _compute_clipped_stats、_aggregate_get_clipped_mean |
| 子集(hard/consensus)与自定义输入 | healthbench_eval.py | INPUT_PATH_HARD、INPUT_PATH_CONSENSUS、HealthBenchEval.__init__ |
| 评判裁判的 meta-eval | healthbench_meta_eval.py | HealthBenchMetaEval |