简介:这是一份面向自然语言处理研究者与大模型开发者的中文评估套件论文资源。内容围绕C-EVAL展开,该套件按中学、高中、大学和专业四个难度层级,覆盖52个学科,用于检验基础模型在中国语境下的知识与推理能力,并包含难度更高的C-EVAL HARD子集用于专项评估。实验数据表明,多数现有大模型在该测试下准确率有限,仅有GPT-4平均超过60%,因此对模型能力诊断、短板分析和后续优化具有直接参考价值。压缩包内共1个文件,为PDF格式的完整论文原文,包体大小4.89MB,便于全文查阅实验设计、评测结果与模型对比数据。该资源已有75人浏览学习。读者可获得论文中关于数据集构建、难度分层、评测方法及结果分析的系统性信息,适用于评估工作设计、模型横向对比及相关科研选题等场景。
1. 先别急着跑分:C-EVAL 到底评估什么,以及它为什么值得你做一次
在其他公开榜单上接近满分的中文大模型,遇到 C-EVAL 忽然现出原形,这种事这两年我没少见。中文多级多学科评估套件 C-EVAL 做的事很直接:用 52 个学科、13948 道中文选择题,从初中到研究生四个难度层级,逼着模型展示它在中国语境下的知识与推理能力。它不是给模型一个“中文好不好”的笼统评价,而是给出一张可逐科拆解的失分地图。
这套评估对三类人尤其有用:做模型选型的工程团队,想在下游任务前知道候选模型的中文底子;做迭代回归的开发同学,每次训练完需要一个稳定、可复现的分数看涨跌;以及给客户做技术验收的人,C-EVAL 的学科粒度能让你快速定位“这个模型到底哪不行”。它测的是知识与推理,不是对话体验,也不测生成质量,这一点在用它下结论前必须想清楚。
2. C-EVAL 的评估体系:52 个学科、四个难度层级,以及 dev、valid、test 该怎么用
2.1 学科分组与难度分级:一张表看懂 C-EVAL 的覆盖范围
C-EVAL 把 52 个学科分成四个学科组,每组覆盖的领域差异很大。下面是按公开资料整理出的代表学科与难度分布:
| 学科组 | 代表学科 | 难度范围 |
|---|---|---|
| STEM | 数学、物理、化学、生物、计算机科学、地球科学 | 初中到研究生 |
| 社会科学 | 经济学、法学、社会学、教育学、管理学 | 高中到研究生 |
| 人文科学 | 中国文学、外国文学、历史、哲学、艺术学 | 初中到研究生 |
| 其他 | 环境科学、心理学、体育学、传播学等 | 初中到研究生 |
注意“多级”不是摆设。初中题考察基本事实记忆,高中题开始涉及概念辨析,大学题需要跨知识点推理,研究生题则大量出现专业术语和复合推理。同一个模型可能在初中组接近满分,却在研究生组掉到及格线以下,这本身就是很有价值的信号:说明模型的知识覆盖停留在浅层。
C-EVAL 还单独划出一个高难度子集 C-EVAL-HARD,专门挑那些需要多步推理、对专业背景要求更高的题目。后面我会单独讲怎么用这个子集做模型区分,这里先记住:标准集的分数容易饱和,HARD 子集才是拉开差距的地方。
2.2 四选一为什么比开放问答更适合做中文知识与推理评估
C-EVAL 全部采用选择题,形式上主要是四选一,部分题目是二选一。这个设计不是偷懒,而是刻意选择。开放生成式问答看着更贴近真实使用,但评分时要处理同义改写、答案长度、部分正确这些麻烦,评估结果极易受生成参数影响。选择题把评估变成确定性匹配:给定题干和四个选项,模型只需要输出 A/B/C/D,自动化打分零成本,复现性也远好于生成式评估。
从实现层面看,选择题本质上是语言模型在做多分类。你不需要让模型“写”出答案,只需要比较模型对四个选项的置信度——具体做法我下一章会展开。这种判别式评估方式对模型能力更敏感,因为它测的是模型从给定候选中选出正确项的能力,而不是生成的流畅度。当然它也有边界:选择题测的是再认,不是完全自由的回忆和应用,一个模型能在 C-EVAL 拿高分不代表它在业务问题上就好用,但它是一个低成本、高稳定性的初筛器。
2.3 dev、valid、test 三套数据:哪些能碰,哪些必须隔离
C-EVAL 按学科提供三套数据,很多人第一步就栽在数据切分上。每学科下通常有 dev 集,包含几条带正确答案的示例,官方推荐用来构造 few-shot 提示;valid 集带正确答案,可以在本地直接算分数;test 集不带正确答案,需要把模型预测结果提交到官方平台才能得到公共分数。
工程上我的建议是:本地开发只碰 dev 和 valid,test 只在模型定版后做最终提交。dev 集每科只给少量示例,刚好用来拼 few-shot 上下文;valid 集是你平时唯一的本地评分基准。不要为了提高分数反复在 valid 上改 prompt,否则 valid 就成了你的训练集,提交 test 时大概率会翻车。数据用途可以按下表管理:
| 数据 | 是否本地评分 | 可以做什么 | 不可以做什么 |
|---|---|---|---|
| dev | 否 | 构造 few-shot 示例 | 作为调优目标反复修改 |
| valid | 是 | 本地验证、回归对比 | 把 valid 当 test 宣称最终成绩 |
| test | 否 | 最终提交,拿公共分数 | 拿 test 输入做本地“准答案”调参 |
3. 本地拉起评估:从加载模型到输出学科分数的脚本实现
3.1 环境与加载:先把模型和 tokenizer 的坑填平
评估环境不需要太多依赖,torch、transformers、tqdm 三件套基本够了。模型加载有个容易踩的坑:不少开源中文基座模型需要设置 trust_remote_code=True,否则加载权重时直接报错。另外,建议统一使用 bfloat16 而不是 fp16,在长文本上 fp16 更容易出现 loss 为 NaN 的情况。
import torch from transformers import AutoTokenizer, AutoModelForCausalLM model_name = "你的模型路径或名称" # 换成待评估模型的本地路径或仓库名 tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( model_name, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="auto", ) model.eval() if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # 很多中文模型没有 pad_token这段代码有两个关键设置。torch_dtype=torch.bfloat16 是为了数值稳定性,如果你在 V100 这类不支持 bf16 的卡上跑,改成 fp16 也可以,但要记录在结果里,因为不同精度跑出来的分数会有细微差别。model.eval() 必须加上,否则 Dropout 还在生效,同一份权重每次跑分数都会波动。补 pad_token 是因为后面批量构造样本时 tokenizer 需要统一的 padding 符号。
3.2 用选项负对数似然实现稳定的四选一分类
跑 C-EVAL 最稳的做法不是让模型生成“答案是A”,而是把每个选项分别拼成一个完整句子,计算整句的负对数似然,选负对数似然最小的选项作为模型预测。这样做的好处是完全没有采样随机性,结果天然可复现,也不需要调 temperature 和 max_new_tokens。
def pick_answer(question, choices, tokenizer, model, device): cand_input_ids, cand_label_ids = [], [] option_labels = ["A", "B", "C", "D"] for idx, choice in enumerate(choices): text = f"{question}\nA. {choices[0]}\nB. {choices[1]}\nC. {choices[2]}\nD. {choices[3]}\n答案:{option_labels[idx]}" enc = tokenizer(text, return_tensors="pt", add_special_tokens=False) cand_input_ids.append(enc["input_ids"].to(device)) cand_label_ids.append(enc["input_ids"].to(device)) losses = [] with torch.no_grad(): for input_ids, label_ids in zip(cand_input_ids, cand_label_ids): output = model(input_ids=input_ids, labels=label_ids) normalized_loss = output.loss * input_ids.size(1) # 除以长度,消除选项长度偏差 losses.append(normalized_loss.item()) return option_labels[int(torch.argmin(torch.tensor(losses)).item())]核心逻辑是:对每个选项,把“题干 + 四个选项 + 答案:选项X”拼成一个完整文本,把整段文本同时作为输入和标签送入模型。模型输出的 cross-entropy loss 衡量的是这段话在模型眼里有多“自然”,正确答案组成的段落通常更自然,loss 更低。乘上 input_ids.size(1) 是为了把平均 loss 还原成整段的总 loss。如果不乘,长的选项天然有优势,因为平均 loss 会被稀释,这是个很容易被忽略的坑。
3.3 批量跑 52 个学科并输出 JSON 结果
单题预测做好之后,就可以按学科组织评估主流程。下面这段代码把 dev 示例拼成 few-shot 上下文,再遍历 valid 集逐题预测,最后按学科计算准确率并落盘。
import json import random from tqdm import tqdm def build_few_shot_context(examples): context = "" for ex in examples: context += f"{ex['question']}\n" for label, choice in zip(["A", "B", "C", "D"], ex["choices"]): context += f"{label}. {choice}\n" context += f"答案:{ex['answer']}\n\n" return context def evaluate_subject(data, tokenizer, model, device, few_shot_num=5, seed=42): random.seed(seed) dev_data = data["dev"] valid_data = data["valid"] shots = random.sample(dev_data, min(few_shot_num, len(dev_data))) prefix = build_few_shot_context(shots) correct, total = 0, 0 records = [] for item in tqdm(valid_data): full_prompt = prefix + item["question"] pred = pick_answer(full_prompt, item["choices"], tokenizer, model, device) records.append({ "id": item["id"], "pred": pred, "answer": item["answer"], }) correct += (pred == item["answer"]) total += 1 return {"acc": round(correct / max(total, 1), 4), "records": records}build_few_shot_context 把示例按题目加答案的顺序拼成连续文本,作为预测新题前的上下文。random.seed(42) 必须固定,否则两次运行抽到的示例不一样,分数不可比。few_shot_num 建议固定为 5,这是 C-EVAL 官方标配,也是分数最稳定的区间。records 里保留每一题的 id、pred 和 answer,方便事后排查是哪些学科、哪些题目拉低了分数。运行入口可以简化成:
python eval_ceval.py --model /path/to/model --data-dir ./ceval_data --output ./result.json --few-shot 5跑完输出的 result.json 建议至少包含模型名称、运行日期、few-shot 数量、总准确率、四个学科组的准确率和 52 个学科的逐科准确率。有了这份 JSON,后面的回归对比和学科画像才有数据基础。
4. Few-shot 与关键参数:决定 C-EVAL 分数上限的 5 个设置
4.1 示例选择:固定种子,还是按学科精选
C-EVAL 每学科自带 dev 示例,这些示例是经过人工验证的正确答案,直接用于 few-shot 是最省事的做法。但从 valid 里额外抽题拼进上下文也能提升少数专业学科的表现,代价是引入了随机性。我的默认策略是:全部从 dev 取,随机抽满 5 条并固定 seed=42。
不同学科的 few-shot 效果差异很大。简单学科如初中地理,1-shot 和 5-shot 差距不大;但法学、高等数学这类学科,示例多一点,模型更容易对齐专业格式和推理风格。建议至少跑四个配置:0-shot、1-shot、3-shot、5-shot。如果时间紧,直接跑 5-shot 作主结果,因为 0-shot 在很多模型上会明显掉分,不能代表模型真实水平。
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 固定 dev 抽样 | dev 中随机抽 5 条,seed 固定 | 默认标准,跨模型可比 |
| 全量示例 | 每科所有 dev 示例都拼进去 | 文本长度较短时提升稳定性 |
| 按学科精选 | 手工挑最有代表性的示例 | 做最佳成绩报告时使用 |
| 0-shot | 不给示例 | 测模型零样本泛化,易低估 |
4.2 四个必调的推理参数:temperature、truncation、padding、batch size
如果你按第 3 章的 loss 法做评估,temperature 和 top_p 实际上不参与计算,因为模型没有做采样,只计算概率分布。但如果你选择生成式方案,temperature 必须设为 0,并且 do_sample=False,否则每次跑分都会随机浮动。这个区别值得在评估记录里写明。
truncation 是隐藏的分数杀手。C-EVAL 题目本身不长,但拼上 5 条 few-shot 示例后,研究生级别的长题干很容易超过模型的 max_length。默认的 truncation=False 在超长时会直接报错,设置 truncation=True 后又可能把题目后半截裁掉。我的处理方式是 max_length 设为模型支持的最大值,并且记录有多少条样本发生了截断。如果截断比例超过 5%,优先减少 few-shot 数量。
padding 设置在批量推理时必须处理。中文 tokenizer 大部分没有预设 pad_token,直接 batch 会报错,需要在加载后设置 tokenizer.pad_token = tokenizer.eos_token。另一个细节是 padding_side 设为 left,因为 C-EVAL 是续写任务,padding 加在右边会影响结果,加在左边则只影响初始位置的注意力。batch size 我一般用 8 到 16,主要看显存;loss 法不需要保存中间 logits,显存占用反而比生成式更低。
4.3 怎么判断分数变化是模型进步,而不是评估噪声
C-EVAL 的 valid 集每科题目数量有限,单科准确率波动一两个点是正常的。你不能因为某次从 70.2 涨到 70.8 就断言模型变强了,要先排除评估噪声。最实用的做法是控制变量:固定相同的脚本版本、相同的 seed、相同的示例顺序、相同的模型 dtype,在新旧两个权重上各跑两遍,观察波动幅度。
如果旧权重两次跑出来分别是 70.1 和 70.5,那么 0.4 个点的涨跌属于噪声区间。总分变化超过 0.5 个点才值得关注,单科变化超过 2 个点才值得排查原因。想更严格一点,可以对 52 个学科做 bootstrap:把 valid 的题目按学科重采样 1000 次,看看新旧分数的置信区间是否重叠。这个分析不复杂,但绝大多数团队不会做,会做的在汇报时说服力强很多。
5. C-EVAL 评估的 5 个常见坑:现象、原因与解决
5.1 本地高分,提交 test 后分数大跌
现象:在 valid 上反复调整 prompt,调到 76 分,提交 test 后却只有 70 分。
原因:valid 集被当成了超参数训练集。每一次改 prompt、换 few-shot 策略都会让模型间接“记住” valid 的样子,本地分数虚高。C-EVAL 的 test 题面分布和 valid 不完全一致,过拟合必然导致提交翻车。
解决:把 prompt 模板、few-shot 数量和示例 seed 在项目开始时一次性冻结。本地评分只认可冻结方案下的输出,不要拿 max 值代表模型水平。最终提交前用已冻结方案跑一遍 valid,再提交 test。
5.2 同一份权重,两次跑分对不上
现象:同样代码、同样模型,今天跑 73.2,明天跑 71.8,没动任何东西。
原因:最常见的是评估脚本里没有固定随机种子,few-shot 示例每次重新随机抽取,导致每道题的上下文都变了。另一个原因是没有调用 model.eval(),Dropout 层在推理时仍然生效。如果用了生成式方案,temperature 没设 0 也会引入采样波动。
解决:在入口处固定random.seed(42)并调用torch.manual_seed(42),模型加载后立即调用model.eval()。把示例的选取结果导出成 JSON 保存,第二次运行时直接用同一批示例,彻底消除抽样差异。
5.3 换台机器,分数掉了一两个点
现象:同一份权重,在测试机 A 上跑 74.5,换到测试机 B 上只剩 72.8。
原因:两台的 GPU 型号和软件栈不一致。A 卡支持 bf16,B 卡只能用 fp16,数值精度不同导致概率分布略有偏移;或者两个环境里 transformers 版本不同,注意力实现有差异,flash-attention 开没开也会影响结果。
解决:把评估环境固定成模板,比如统一的 docker 镜像,锁定 torch、transformers 版本。跑分前检查 dtype 和 attention 实现,记录到结果文件的 meta 字段里。跨机器对比分数前,先在同一环境里跑一组基准权重校验一致性。
5.4 超长题目被截断,正确答案被裁掉
现象:研究生组的个别学科分数特别低,看记录发现模型预测几乎集中在一个选项上。
原因:题干加上 few-shot 示例后总长度超过 max_length,truncation 从右侧裁掉了一段,正好把题干后半部分和选项切掉,模型在残缺文本上只能瞎猜。C-EVAL 中部分法律和数学题目题干很长,这个问题在 5-shot 时最容易触发。
解决:评估代码里加一个长度统计,输出超过 max_length 的样本数和比例。对超长样本使用 left truncation,保证保留题干和选项的尾部,而不是头部。如果截断比例超过 5%,把 few-shot 降到 3 或换成更短的示例。
5.5 把 C-EVAL 数据混进训练集,再拿来宣传成绩
现象:某模型在 C-EVAL 上宣称达到 80 分,但业务场景表现平平,仔细查发现 valid 或 test 数据出现在训练语料里。
原因:数据泄漏。C-EVAL 的题目文本会出现在公开数据集或爬虫抓取的网页中,模型训练时已经见过原题,评估分数自然虚高。这个问题危害最大,因为它会误导选型决策。
解决:训练语料清洗时加入 C-EVAL 题目去重,用 n-gram 重叠检测过滤相似文本。评估时对每个学科额外报告“疑似记忆题目的单独准确率”,如果发现模型在长尾题目和常见题上分数差异过大,就要警惕泄漏风险。
6. 从总分到学科画像:用 C-EVAL-HARD 与分层报告做模型回归
标准 C-EVAL 总分容易在模型迭代后期陷入“涨不动”的瓶颈,这时候 C-EVAL-HARD 是更好的突破口。我拿两个候选基座模型做对比时遇到过这样的情况:总分只差 0.8,看起来势均力敌,但 HARD 子集上差距拉到 6 个点。追问原因后发现,领先的模型在高等数学和法律推理题上的多步推理能力更强,这个差异被简单题的平均分掩盖了。所以我的回归报告永远同时保留两套分数:全量总分和 HARD 子集分数。
更细的维度是学科画像。把 52 个学科的准确率画成雷达图或柱状图,一眼就能看出模型的知识结构偏向。某个模型如果 STEM 组平均 78,人文组只有 58,说明训练数据里中文理工科语料充足、人文社科语料缺乏;反过来亦然。我在做完学科画像后,通常会给团队输出一个“短板清单”,用于指导定向数据补充和领域微调。例如法学短板明显时,就补充司法考试和中方法学教材类语料,再拿 C-EVAL 法律学科的 valid 集做回归验证。
最终的落地建议是把它接进迭代 CI。每次模型训练完成后,自动跑一遍固定协议脚本,输出下面这张对比表:
| 报告项 | 数据来源 | 判定标准 |
|---|---|---|
| 总准确率 | 52 学科 valid 平均 | 相对基线变化超过 0.5 视为有效 |
| 学科组分数 | 四学科组分别计算 | 查看短板是否被修复 |
| HARD 子集准确率 | HARD valid 子集 | 优先关注,推理能力指标 |
| 单科准确率 | 逐科统计 | 单科波动超 2 点需排查噪声 |
我自己第一次跑 C-EVAL 时也翻过车:把 valid 当 test 反复调参,提交后比本地低了好几分。那之后我养成一个习惯——评估协议先行,prompt、种子、示例顺序全部提前冻结,分数只在固定协议下比较,任何新想法都开分支验证,不污染主结果。希望帮到你,这套流程跑通后,C-EVAL 就不再是榜单上的一个数字,而是模型迭代过程中真正能依赖的度量工具。
本文还有配套的精品资源,点击获取