简介:C-EVAL是一套面向中文语境的多级多学科大模型评估套件,专供自然语言处理研究者与技术从业者分析大型语言模型在中文环境中的高级知识与推理能力。该资源为PDF格式的完整论文,共1个文件,压缩包大小约4.89MB,目前已吸引76人下载学习。内容详细介绍四个难度层级(中学、高中、大学、专业)与52个学科划分,涵盖人文到科学工程,并单独定义了C-EVAL HARD子集以考察困难推理问题。同时整理了对GPT-4及其他主流模型的评测对比,明确指出仅GPT-4平均准确率超过60%,为后续模型优化提供了清晰基线。阅读者可从中获取评估体系构建思路、任务设计逻辑、实验结果分析及C-EVAL公开评测代码的引用说明,有助于设计或选用更贴合中文场景的模型评测方案。
1. C-EVAL是什么:先弄清一个评估套件在帮你测什么
有开发者在英文多任务基准上刷到高分的开源模型,切到中文知识问答后,反而被一个参数量小很多的中文特化模型按在地上摩擦。这种反差不是个例,根因往往出在评测维度上——英文基准考的是英文语境下的知识,而中文多级多学科评估套件C-EVAL,考的是中国语境下的高级知识与推理能力。它用中学、大学、研究生及职业考试四个层级的上万道中文题目,直接量化一个大模型“在中国语境下懂多少、会不会推理”。这篇文章写给手里已经有一个模型、想知道它在中文场景里到底强不强、短板在哪、下一步怎么调的开发者。
2. C-EVAL的评估设计:先看懂数据再动手跑分
跑分之前,先花二十分钟把“考什么”搞明白。C-EVAL不是一个普通的多选题集合,它的题目组织带着明显的中文教育体系特征。这一章把它拆到三块:级别的难度梯度、学科的知识跨度,以及中文语境带来的独特评估价值。
2.1 四个级别与52个学科:难度梯度从哪来
C-EVAL的题库按教育阶段分成四级:中学、大学、研究生、职业考试。这四级本身就是一个天然的难度梯度。中学题主要覆盖基础知识,比如初中数学的简单计算、语文的文学常识;大学题开始考察学科方法论,比如线性代数、宏观经济学里的概念辨析;研究生题进一步强调综合运用,很多题目需要跨章节甚至跨学科推导;职业考试题则把知识放进实际职业场景中,比如法律案例判断、医学诊断推理、工程方案选择。
这种分层对模型诊断很有用。一个模型可能在中学级别拿高分,到研究生级别明显下滑,说明它的知识覆盖偏向基础层。另一个模型可能在中学和大学都一般,但职业考试分数较高,说明它对场景化、实操性知识的记忆更牢。只看总分看不到这些差异,所以动手之前先把每个级别的样本量摸清。不少自定义评估集在切分时存在样本量不均衡,比如某学科只有几十条,统计出来的分数波动会非常大,参考价值很低。
import json from collections import Counter def inspect_split(path): level_counter = Counter() subject_counter = Counter() with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue item = json.loads(line) level_counter[item.get("level", "unknown")] += 1 subject_counter[item.get("subject", "unknown")] += 1 print("级别分布:", dict(level_counter)) print("学科数量:", len(subject_counter)) print("学科分布TOP10:", subject_counter.most_common(10)) inspect_split("ceval_test.jsonl")这段脚本假设数据是JSONL逐行存储,每行是一个题目对象,字段里带level和subject。跑完之后核对级别数量和学科数量,如果发现“研究生”这一级只有几十条样本,后面这一级的分数就没有统计意义。字段名以你实际拿到的数据集为准,有的版本叫category或phase,需要从数据说明里确认后再改。
学科跨度是第二个设计重点。公开资料里C-EVAL覆盖52个学科,大类上可以归到理工、人文、社会科学和职业类目。不同学科考的认知能力很不一样:数学、物理偏多步计算和符号推理;法学、医学偏条文记忆与案例匹配;历史、文学偏事实记忆与文化理解。模型在这些学科上的得分结构,能直接反映训练语料的偏向——如果所有人文类学科都低,基本可以判断训练数据里中文文科内容偏少,而不是模型推理能力不足。
2.2 高级知识与推理能力到底测什么:题目结构拆解
C-EVAL的题目以选择题为主,包含单选题和不定项选择题。多选的存在意味着评估不能只看模型是否猜中了第一选项,还要看它是否具备“选出全部正确项”的集合判断能力。这也是标题里“高级知识与推理能力”的具体体现:多选题天然要求模型先全面评估每个选项,再决定取舍,而不是靠概率采样碰运气。
从认知层次上,我习惯把题目分成两类:知识记忆型和推理应用型。知识记忆型题目,模型只要在训练阶段见过相关知识就能答对,典型的是朝代、作者、条约内容、公式定义;推理应用型题目,即使模型知道相关概念,也必须经过多步推断、排除干扰项或实际计算才能得到答案,典型的是数学应用题、需要综合多个法律条文才能判断的案例题。
在跑分之前,先按题型和大致认知层次做一个分层统计。最直接的办法是同时读取题目和选项,计算题干长度、选项个数,并结合关键词做一个粗筛。这样做的目的不是把题目精确定义成记忆或推理,而是确认测试集里确实存在一定比例的推理型题目,让后续bad case归因不至于低估难度。
import json, re from collections import Counter def rough_label(item): question = item.get("question", "") if any(k in question for k in ["计算", "推理", "分析", "推导", "论证"]): return "推理型" return "记忆型" def inspect_types(path): type_counter = Counter() option_counter = Counter() with open(path, "r", encoding="utf-8") as f: for line in f: item = json.loads(line.strip()) type_counter[rough_label(item)] += 1 option_count = len(re.findall(r"[((]\s*[ABCD]\s*[))]", item.get("options", ""))) option_counter[option_count] += 1 print("认知类型粗筛:", dict(type_counter)) print("选项数量统计:", {k: v for k, v in sorted(option_counter.items())}) inspect_types("ceval_test.jsonl")这个脚本只是一个摸底工具,不要把它当准确标注。比如题干里有“计算”二字的题未必就是推理题,有的只是直接考计算法则。真正可靠的做法是跑完一轮之后,抽几十个答错的题,人工看一遍,确定错误的真实原因是知识盲区、计算失误还是选项理解偏差。这个过程会在第6章展开。
2.3 为什么不用英文基准直接翻译:中文语境的特殊性
有人会问:为什么不直接拿英文多学科选择题基准的翻译版来用?这里有两个硬伤。
第一个硬伤是翻译带来的语义歧义。英文法律术语直译成中文后,与中文法律用语的惯常表达不一致,选项之间的区分度会降低。比如英文合同条款里的某个概念直译成中文后,模型没有见过足够的中国法律语料,会把该概念理解成普通日常含义,导致该选的不选。这类问题不是模型笨,而是“翻译题”本身没法完全等价。
第二个硬伤是知识框架的错位。英文基准的学科内容以西方教育体系为蓝本,包含大量西方历史文化、政治制度、文学作品的内容。对面向中文用户的模型来说,这些内容既不是训练语料重点,也不是实际使用场景。测出来的低分要么是“模型确实不懂”要么是“语料天然缺失”,对下一步优化没有指向性。
C-EVAL把知识框架换成了中文考试体系:语文的文言文与成语、中国历史脉络、法律职业情境题、行政能力测试题。这类题目所依赖的文化前提和推理习惯,只能通过中文语料习得。C-EVAL跑出的分数,和模型在中文知识问答、中文客服、中文内容理解上的表现相关性通常更高。
跑分时还有一个原则要提前定好:C-EVAL是评估集,不是训练集。处理数据时会把它单独放在只读目录,不进训练管线;任何想拿它做继续预训练或数据增强的想法都要拦下来。分数好看的前提是测试集从未被污染,一旦污染,后面每一版模型的分值都失去横向对比意义。
3. 把C-EVAL跑起来:本地推理、结果生成与评分
这里从头到尾做一个可复现的最小闭环:读取题目文件,构造提示词,批量推理,再把结果按统一规则评分。环境假设是你已经有一个可以本地加载的大模型权重,无论是开源权重还是自己微调的模型,都不影响流程。
3.1 准备数据与构造提示词:固定模板是分数可复现的前提
跑分最怕的是“每次提示词不一样”,导致分数差异分不清是模型变化还是模板变化。评估时要固定一个提示词模板,所有题目共用。C-EVAL没有规定唯一模板,但公开评测实践里,常见做法是把“角色设定 + 作答要求 + 题目选项 + 答案引导”拼在一段文本里,让模型只输出选项而不是长篇分析。
模板里的一个重要细节是“答案引导”。只给题目选项,模型可能输出一整段解释;在末尾加上“【答案】”或“正确答案是”,模型会更倾向于直接输出选项字母。这个技巧在指令微调过的模型上尤其明显。
SYSTEM_PROMPT = "你是中国教育考试领域的专家,请依据题目选出正确选项。" def build_prompt(item, fewshot_examples=None, use_cot=False): if use_cot: instruction = "请先逐步推理,再在最后一行以“答案:字母”给出结论。" answer_mark = "" else: instruction = "请直接给出正确选项。" answer_mark = "【答案】" prompt = f"{SYSTEM_PROMPT}\n{instruction}\n" if fewshot_examples: for ex in fewshot_examples: prompt += f"【题目】{ex['question']}\n【选项】{ex['options']}\n【答案】{ex['answer']}\n" prompt += f"【题目】{item['question']}\n【选项】{item['options']}\n{answer_mark}" return prompt这段代码实现三种设置的基础:zero-shot直接给题,few-shot在题目前插入示例,CoT则把指令换成“先逐步推理再给答案”。最容易踩的坑是示例里漏了答案行。模型会误以为你要它做对话,而不是做选择题。示例里不仅要有题目和选项,还要有标准答案,模型才能从示例中模仿“输出字母”的行为模式。
3.2 选择推理引擎与生成参数:答案稳定性靠低温度保证
提示词构造好之后,进入批量推理。先跑小规模验证流程,再上全量集。下面是用HuggingFace transformers做单机推理的最小代码。
from transformers import AutoModelForCausalLM, AutoTokenizer import torch model_path = "./your_model_dir" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForCausalLM.from_pretrained( model_path, torch_dtype=torch.float16, device_map="auto" ) def generate_answer(prompt_text, max_new_tokens=32): inputs = tokenizer(prompt_text, return_tensors="pt").to(model.device) with torch.no_grad(): output = model.generate( inputs.input_ids, max_new_tokens=max_new_tokens, temperature=0.05, top_p=0.9, do_sample=True, repetition_penalty=1.1, pad_token_id=tokenizer.eos_token_id ) generated = output[0][len(inputs.input_ids):] return tokenizer.decode(generated, skip_special_tokens=True) sample = json.loads(open("ceval_test.jsonl", "r", encoding="utf-8").readline().strip()) print(generate_answer(build_prompt(sample)))几个参数说明。temperature设0.05而不是0,因为部分实现里为0时的归一化可能触发随机性分支;0.05已经接近贪心解码,选择题不是创意写作,不需要随机性。top_p保持0.9是为了兼容那些有概率依赖的tokenizer设置。max_new_tokens默认32,零样本时足够;如果用CoT,需要单独调成128或256,否则推理过程会被截断。repetition_penalty 1.1能显著减少模型重复输出选项序列。pad_token_id要显式指定为eos_token_id,否则批量推理时会产生不均匀的padding警告。
如果测试集规模大,逐条推理会比较慢。可以把引擎换成vLLM,用离线批处理方式同时跑几百条,吞吐量高一个数量级。提示词构造逻辑不变,只需要把生成参数对齐到vLLM接口:
from vllm import LLM, SamplingParams sampling_params = SamplingParams( temperature=0.05, top_p=0.9, max_tokens=32, repetition_penalty=1.1 ) llm = LLM(model="./your_model_dir", tensor_parallel_size=1, dtype="float16") prompts = [build_prompt(item) for item in test_items] outputs = llm.generate(prompts, sampling_params) predictions = [out.outputs[0].text for out in outputs]这里的关键是把prompt列表一次性传入,vLLM内部会做continuous batching。显存不够时调低gpu_memory_utilization,比如0.85。并发请求数是自动管理的,不需要手动控制,但仍建议先从几十条开始试跑,确认显存峰值,避免被系统OOM杀掉。
3.3 统一抽取答案与评分:把格式噪声挡在指标外
模型输出经常带着格式噪声,比如“答案:B”“B. 分析正确”“(B)”。评分前要先做一次文本抽取。常见做法提供两级提取:优先找“答案:X”这种显式标记,找不到就找最后一个出现在行内的选项字母。之所以找“最后一个”,是因为模型可能会在中间复述题干里的选项字母,而真正的结论一般在末尾。
import re def extract_answer(raw_text): if not raw_text: return None match = re.search(r"[答|正确答案][::]\s*([ABCD])", raw_text) if match: return match.group(1) matches = re.findall(r"([ABCD])[.\、\))]?(\s|$)", raw_text) if matches: return matches[-1][0] return None def evaluate(predictions, ground_truths): single_total, single_correct = 0, 0 multi_total, multi_correct = 0, 0 for pred_text, gt in zip(predictions, ground_truths): letter = extract_answer(pred_text) if letter is None: continue if isinstance(gt, str): single_total += 1 if letter == gt: single_correct += 1 elif isinstance(gt, list): multi_total += 1 if set(letter) == set(gt): multi_correct += 1 print("单选准确率:", single_correct / max(single_total, 1)) print("多选准确率:", multi_correct / max(multi_total, 1))多选的判定要严格一些。如果标准答案是AB,模型只输出A,会被判定为错误,这是C-EVAL官方评分更倾向的做法,因为多选题要全选对才给分。如果还想观察“模型离全对差多少”,可以额外统计多选的命中率和误报率,比如预测集合与标准答案的交并比。不过最终对外报数时,还是按“全部匹配才算对”,否则分数会虚高。
到这里,跑分闭环已经完成:提示词固定、推理稳定、抽取一致、评分严格。接下来要讨论的是比跑分更难的一步——怎么从分数里读出模型的真实短板。
4. 结果怎么读:分数之外还要看什么
跑分脚本输出一个总准确率后,很多人就结束了。总准确率确实能说明模型整体水平,但一个数字回答不了“模型弱在哪个级别、哪个学科、哪种题型”。这一章把读数方法拆成三块:分级分学科看短板、对比Few-shot与CoT、把C-EVAL放进更大的评估矩阵。
4.1 级别、学科与Hard子集三张表:一眼定位短板
第一张表是按级别的准确率表。C-EVAL四个级别本质上代表四个难度档位,如果一个模型在中学档位高、研究生档位低,说明它的知识深度不足,只能答浅层题,一遇到跨章节综合就露馅。第二张表是按学科的准确率表,这张表能告诉你语料偏向:理工类学科差是多数开源模型的通病;但如果人文和社科类也差,就要怀疑是中文语料覆盖不够,而不是能力问题。
第三张表是Hard子集的分数。这里需要特别注意:Hard子集通常是一批难度更高、更接近真实考试里拔高题的样本,它的准确率往往比总体低许多。Hard子集分数低本身不丢人,但“总分离、Hard低”的模型,和“总分不高、Hard也不低”的模型,优化策略完全不同——前者要补推理能力,后者要先补知识底座。我用一个脚本把三张表一次性输出。
def build_report(results): df = pd.DataFrame(results) report = {} report["by_level"] = df.groupby("level")["correct"].mean().round(3).to_dict() report["by_subject"] = df.groupby("subject")["correct"].mean().round(3).sort_values(ascending=False).to_dict() if "hard" in df.columns: report["hard_accuracy"] = round(df[df["hard"] == True]["correct"].mean(), 3) return report report = build_report(eval_results) print(report["by_level"]) print("Hard子集准确率:", report["hard_accuracy"])生成报告时至少要保留四列:level、subject、correct、hard。如果没有hard字段,就跳过Hard子集统计,不要硬凑。分学科表打印出来后,我习惯再按人文、理工、社科、职业做一次聚合,因为52个学科列在一张表里很难看出规律。聚合可以简单靠学科名映射:
def map_category(subject): if subject in ["数学", "物理", "化学", "生物"]: return "理工" if subject in ["语文", "历史", "地理", "文学"]: return "人文" if subject in ["法律", "经济", "政治", "哲学"]: return "社科" return "职业"这种映射不追求完备,只求快速看出大类差异。看到某一类整体偏低之后,再回到学科明细表里去定位具体是哪个学科拉低了平均分。
4.2 Few-shot与CoT的差异:提示词写法如何影响分数
同样一个模型,选对提示词设置,分数能差出十几个点。这不是模型作弊,而是提示词确实改变了模型的信息处理方式。C-EVAL评测时最常用的三种设置是zero-shot、few-shot、CoT。zero-shot最公平,反映的是模型在没有外部指导时的真实知识水平。few-shot通过一到两条示例教模型“输出什么格式”,也顺带提供了一点任务示范。CoT则让模型在作答时把推理过程写出来,等于强制它调用更深层的推理链路。
我在实际跑分中的经验是:few-shot示例必须贴题。给一道数学题配一道同知识点的数学示例,比配一个法律示例有用得多。示例数量不是越多越好,一到两个就够。理由很简单:大模型在上下文里见过太多内容后,注意力会被示例里的无关细节分散,反而干扰判断。另一个经验是:CoT提示词把推理步骤拆得越清楚,分数越稳定。明确要求“先提取条件,再逐一判断选项,最后给出答案”的模板,效果普遍好于只写“请仔细思考再做答”。
def build_cot_prompt(item): cot_instruction = ( "请按以下步骤作答:\n" "1. 阅读题目,列出已知条件。\n" "2. 逐个判断选项,排除与条件冲突的项。\n" "3. 在最后一行以“答案:字母”输出结论。\n" ) return f"{cot_instruction}\n【题目】{item['question']}\n【选项】{item['options']}"使用CoT时评测要用更长的max_new_tokens,建议从128起,并根据实际输出长度动态调整。抽取答案的规则要相应放宽:不能只找第一个字母,要在最后一行找“答案:X”或倒数出现的选项字母。否则模型按要求输出了一整段推理,最后答案被截断在生成尾部,评分脚本却误判为没作答。
4.3 把C-EVAL放进评估矩阵:几类指标怎么配平
C-EVAL测的是“在中文知识维度上的水平”,它不是万能的。如果团队正在做客服机器人、知识问答系统这类需要事实密度的产品,C-EVAL的高权重合理;但如果做的是开放闲聊、小说生成或代码辅助,C-EVAL分数高并不能代表产品体验好。评估矩阵至少要覆盖四类能力,每类用不同工具度量:
| 评估目标 | 典型手段 | C-EVAL的角色 |
|---|---|---|
| 中文知识深度与推理 | 多级多学科选择题 | 主评测 |
| 指令遵循与对话能力 | 多轮对话、指令跟随测试 | 辅助参考 |
| 开放式生成质量 | 摘要、问答人工评分 | 不覆盖 |
| 安全与合规 | 专项安全测试 | 不覆盖 |
这张表的核心观点是:C-EVAL管“知识”和“推理”,不管“对话体验”和“安全合规”。如果只拿一个基准来调模型,模型的分数会涨,但真实业务指标未必跟着涨。我建议把C-EVAL作为每次模型迭代的必跑项目,同时配上一组固定的对话评测集和安全性冒烟测试,三块分数一起看。迭代时如果C-EVAL涨、对话体验跌,说明改动可能偏向“背题”,要回看训练数据权重。
回归到落地场景里,你还要知道C-EVAL分数的横向差距。不同模型在C-EVAL上的分差往往比在英文基准上的分差更小,因为中文知识更依赖语料覆盖,大家都能答对一部分常识题,真正拉开差距的是Hard子集和冷门学科。所以当两个模型总准确率只差一个点时,别急着换模型,先比较它们的分学科分数和Hard子集分数,再决定投入。
5. C-EVAL实测中的常见问题与避坑记录
跑C-EVAL的过程看着简单,实际翻车的点都在细节里。这一章把实践中反复遇到、修复成本最高的几个问题写下来,每条都按“现象→原因→解决”的路径说清楚。
5.1 生成长度设太短,计算题答案被截断
现象:数学、物理这类学科准确率异常低,和语文、历史形成悬殊反差。把预测文本拉出来看,模型已经写了一段计算过程,但输出到一半就断了,或者只输出到“B”就被截断,后面的“. 25”没生成完。
原因:CoT模式下,模型会把推理过程写在最终答案之前。max_new_tokens只设了32,推理过程消耗了长度,答案还没出现就到达上限。这是评测设置问题,不是模型能力问题。很多第一次跑C-EVAL的人都会在这上面吃暗亏,以为是模型数学不行,其实是脚本没给够输出空间。
解决:跑CoT评测前,先抽十条题测一下平均生成长度,把max_new_tokens设为平均长度的1.5倍,留足余量。我一般直接用256,能同时覆盖推理过程和答案输出;零样本时再调回32,不要一刀切。另一个缓解手段是在提示词里用“最后一行以‘答案:字母’输出”,即使截断,也尽量保住答案行。如果发现某道题答案行仍然丢失,再根据日志单独重跑该题。
5.2 答案提取规则不统一,导致同模型同数据两次分数不一致
现象:同一版模型跑了两轮C-EVAL,分数差了二到三个百分点。排查发现两次脚本抽取答案的逻辑不一样,一次用的是“第一个出现的字母”,另一次用的是“最后一个出现的字母”,导致一部分输出被判成不同答案。
原因:模型输出常常混杂解释、复述、结论。如果输出里同时出现“A. xxx”和真正的答案“B”,两种提取逻辑会选中不同的字符。这种坑在多人协作时尤其容易发生,每个人写的正则规则各有偏好,今天你跑一版,明天同事跑一版,数值对不上。
解决:把答案提取做成唯一函数,加上明确优先级:先找“答案:X”“正确选项:X”这类显式标记;没有显式标记就找最后一行出现的选项字母;最后再用“末尾字母”作兜底。写完之后用几个带噪声的样例做单元测试,确认规则稳定后再跑全量。团队协作时把这个函数放在公共评估脚本里,避免各写一套。
5.3 温度过高,低分数抖动掩盖真实水平
现象:用temperature=0.7跑C-EVAL,准确率在三次运行间波动三四个点。尤其是多选题,模型在概率相近的选项间反复横跳,今天选出AB,明天选出AC。
原因:多选题的判断本质是集合预测,高温采样增大了低置信度选项被选中的概率。选择题评测要的是稳定复现能力,不是生成多样性,高温度会让结果失真。生成多样性的设置用在这里,等于给分数叠加了一层随机噪声,前后两次对比根本看不出模型真实进步。
解决:选择题评测把temperature降到0到0.1之间。如果推理框架要求do_sample必须为True,也要保持temperature在0.05附近。每次跑分固定随机种子,并在输出文件名里注明温度和种子值,方便后续对比。要报告采样随机性影响时,用“跑三次取均值,附标准差”的方式。只有当你需要评估模型在开放生成任务上的多样性时,才把温度调高。
5.4 大批量推理时显存溢出,跑了一半挂掉
现象:用transformers直接跑全量C-EVAL,跑到第几千题时CUDA OOM,前面的结果没有保存,进程被杀,只能全部重来。
原因:选项文本拼接后单条输入可能超过2048 token,显存分配不足;此外如果device_map设置不当,多GPU上显存分配不均衡,某个卡先用满也会触发OOM。这属于资源管理问题,和模型能力无关,但代价很高——一次全量评测可能要跑几个小时,中断一次就浪费几个小时。
解决:先跑一个小batch试探显存峰值,比如batch_size=8,如果OOM就降到4或2。用torch.no_grad()包裹推理,关闭不需要的梯度计算。vLLM用户要设置gpu_memory_utilization,比如0.85,给CUDA context留出余量。更稳妥的方案是边推理边把结果写入本地JSONL,每条生成后立刻落盘,一旦崩掉,可以从断点继续跑,而不是全部重来。我自己会在评测脚本里加一个参数--resume,每次启动时检查已有结果文件,跳过已完成的题目。
5.5 评估数据被混入训练,分数虚高后无法再信任
现象:某次版本升级后,C-EVAL分数突然涨了六七个点,但业务问答准确率没有同步增长。怀疑训练数据里混入了测试集。
原因:开发过程中有人为了提升公开榜单成绩,把C-EVAL题目加入了预训练或指令微调数据,模型背下了不少题,分数虚高。之后每跑一版都是虚高,整个评估体系失效。这种问题隐蔽性很强,因为训练数据动辄几十GB,混进一两份评估文件很难被发现。
解决:把C-EVAL数据放到独立目录并设置只读权限。数据管道在清洗时要做一次“评估集去重”,把测试题文本与训练语料做哈希或字符串匹配过滤。团队协作时,在数据准备脚本里默认忽略该目录,不参与训练打包。如果你发现分数突变而业务指标不动,第一件事就是检查是否有评估文件被误加进数据。这个教训我可以说是血泪经验:一旦评估集被污染,前面几个月的迭代记录都失去参考价值,只能重搭评估基线。
误差来源基本上就是设置、提取、污染三类。把这些源头卡死,C-EVAL的分数才值得用来做决策。
6. 进阶用法:用C-EVAL做分学科诊断与bad case归因
基础跑分之后,真正投入产出比高的动作是bad case归因。我习惯把每次评估结果保存成一份带原始输出的JSONL,然后按“抽取失败、答案越界、推理错误”三类去归因。
6.1 三类错误的自动分类
import json, re def classify_bad_cases(results): buckets = {"抽取失败": 0, "答案越界": 0, "推理错误": 0} subject_error = {} for r in results: if r["pred"] is None: buckets["抽取失败"] += 1 continue if r["correct"]: continue answer_mark = re.findall(r"答案[::]\s*([ABCD])", r["raw_output"]) if answer_mark: buckets["答案越界"] += 1 else: buckets["推理错误"] += 1 subject_error[r["subject"]] = subject_error.get(r["subject"], 0) + 1 print(buckets) print(sorted(subject_error.items(), key=lambda x: x[1], reverse=True)[:10]) classify_bad_cases(eval_results)抽取失败说明生成质量或抽取逻辑有问题,需要看输出是否为空或乱码;答案越界说明模型写了答案但格式不规整,多数是提示词问题;推理错误才是真正需要模型优化的部分。这三类错误的处理手段完全不同,不分开就直接进优化,会白费很多训练时间。
6.2 用Hard子集定位知识盲区与推理弱环
Hard子集筛出的题目通常涉及跨章节综合或复杂计算。我会把Hard子集里的错误题单独存一份,人工抽二十条,逐题记下错误类型:是知识没见过、选项文字没读懂,还是推理链条断了。这类人工标注很费时间,但回报很高——它能直接告诉你优化优先级是继续补语料,还是调整SFT数据里的推理类样本。
6.3 把C-EVAL做成持续回归的固定流程
最后,把评估做成脚本,每次模型改动后自动跑一遍:
#!/bin/bash for model in model_v1 model_v2 model_v3; do python run_eval.py --model "$model" --split all --output "report_${model}.json" python gen_report.py --input "report_${model}.json" --output "table_${model}.md" done我自己每周至少跑一次C-EVAL回归,把准确率、Hard子集分数、Top5弱学科写进周报,与训练数据变更一一对照。半年后你会积累出一套“模型在哪个学科上会因为哪种数据变化而波动”的经验库,比任何单一分数都有价值。评估不是跑完一个基准就结束,而是跑出能指导下一次改动的信号。希望帮到你。
本文还有配套的精品资源,点击获取