# CharacterEval 复现核查（第二轮，代码优先）

审查方式：从官方代码出发，逐行追踪"读测试数据 → 生成 → 格式转换 → 奖励模型打分 → 打印各指标均值"的完整路径，规定平台实现在每一步必须怎么做，再到论文中找支持或冲突。本报告只读源文件，未调用任何付费模型或奖励模型端点。

## 0. 来源与固定版本

| 来源 | 版本 / 哈希 | 位置 |
|---|---|---|
| 官方仓库 morecry/CharacterEval | commit `c3d44a6fc1790cc8c4b2fd7c01f0c72930655e0c`（2025-05-28） | `scratchpad/src/ce` |
| 论文 LaTeX（arXiv 2401.01275 v2） | `acl_latex.tex` SHA-256 `f98299737e75bfb7002e47a76a67161246fbf1286c7be43f8ccd5800b50d6c17` | `scratchpad/src/ce_eprint` |
| BaichuanCharRM（HF） | revision `e953da5f57b86db7adca3a2714b4c3720f5b96a2`；本轮在该 revision 下重新拉取了 7 个非权重文件（`scratchpad/src/hf_charrm_p2`），与先前目录 `hf_charrm` 的 SHA-256 逐一相同 | modeling_baichuan.py `64c46c28…a5c934`，tokenization_baichuan.py `f21aa041…cbb061`，config.json `25ae0bd0…a0a489`，tokenizer_config.json `3d136a6d…5fb`，special_tokens_map.json `2f5a9116…cb26` |

**脚本历史（与结果有关）**：`transform_format.py` 在 9f1a6ae（2024-01-12）加入了 `is not None` 过滤和"id 不在 id2metric 就跳过"两项；1be1239 / 5b99dd9 只改了输入文件名。`split("\n")[0]` 从首次提交起就有。

字符码位：代码和数据里的冒号都是全角 `：`（U+FF1A），`！` 是 U+FF01，`。` 是 U+3002。下文所有字面量都用 Python AST 从源文件机械提取，并以 Python repr 形式给出（`\n` 表示换行符）。

---

## 1. 逐步规范

记号：【论文支持】【论文冲突】【论文未说明】，后面附 `acl_latex.tex:行号`。

### S1 读取数据

- 代码：`get_response.py:62-65`。`data/test_data.jsonl` 用 `json.load` 读入，**它实际上是一个 JSON 数组，并不是 JSONL**；`data/character_profiles.json` 是一个对象，按角色名索引。`transform_format.py:5-6` 读入的 `data/id2metric.jsonl` 也是**单个 JSON 对象**（键是字符串形式的 id，值是 `[[metric_en, metric_zh], …]`）。`results/*.jsonl` 也都是 JSON 数组（`indent=4`）。
- 平台要求：这些文件都要当整体 JSON 解析，不能逐行解析。测试集按文件顺序共 4,564 条，字段只有 `id`（int）、`role`、`novel_name`、`context`。
- 论文：测试集 4,564 个样本【论文支持 :381】。

### S2 解析 context（`make_inputs`，`get_response.py:36-43`）

```python
dialogues= context.split('\n')
role = dial.split("：")[0]
dial = "：".join(dial.split("：")[1:])
```

- 只按 `'\n'` 切分（不按 `\r` 切，也不用 splitlines），不做 strip。
- 说话人取第一个全角冒号之前的部分，内容取第一个全角冒号之后的全部（中间的全角冒号保留）。如果某行没有全角冒号，说话人就是整行、内容为空串（本数据中没有这种行）。半角 `:` 不起作用。
- TS 等价写法：`const k = line.indexOf('：'); speaker = k<0 ? line : line.slice(0,k); value = k<0 ? '' : line.slice(k+1)`。不要 trim。
- 论文：只用公式描述了 C_n=[q1,r1,…,qn] 和 ABAB 结构【论文支持 :208, :261】；切分规则【论文未说明】。

### S3 拼装对话消息（`concat_messages`，`get_response.py:10-34`；系统文本见 `:50-52`）

1. 第一条 **user** 消息（不是 system 角色）：`f'''{role_information}\n现在请你扮演一个角色扮演专家。请你根据上述信息扮演{role}进行对话。\n'''`。AST 提取结果为：`[F role_information] + '\n现在请你扮演一个角色扮演专家。请你根据上述信息扮演' + [F role] + '进行对话。\n'`。其中 `role_information` 是 profile **dict**，f-string 的效果等于 `str(dict)`，即 Python repr（细节见 S7）。文本**末尾有一个换行符**。
2. 第一条 **assistant** 消息，是固定文本：
   - 角色先说话时（`conversations[0]['from'] == role`）：`'好的！现在我来扮演' + role + '。' + '我首先发话：' + conversations[0]['value']`。注意这里接的是**去掉 "role：" 前缀之后**的内容。
   - 否则：`'好的！现在我来扮演' + role + '。'`
3. 对每个 i>0 且 `from == role` 的行：追加 user `f"{conversations[i-1]['from']}：" + value[i-1]`，再追加 assistant `f"{conversations[i]['from']}：" + value[i]`。也就是说，历史里 assistant 的发言**带 "角色名：" 前缀**，user 的发言带对方名字前缀。
4. 最后一条 user 消息：`f"{conversations[-1]['from']}：" + conversations[-1]['value']`。
5. 两个断言：角色不能连续两行说话（`:26`）；最后一行不能是角色说的（`:31`）。数据中两项都是 0 例违反。
6. 对方（非角色）连续两行说话时，前一行会被静默丢弃（因为 query 只取 i-1）。数据中 0 例。
- 平台要求：OpenAI 兼容的 messages = `[user(系统文本), assistant(固定确认语), …历史…, user(最后一行)]`，**不发 system 消息**，所有文本逐字保留，包括末尾空格和换行。消息数为 3 到 51 条，严格 user/assistant 交替，以 user 结尾。
- 论文："We consistently employ the same prompt for each model, with minor adjustments made only for closed-source models."（:535）。论文没有给出提示词原文，也没说这些"minor adjustments"是什么【论文未说明 → 按代码】。角色先说话时的处理【论文未说明】。

### S4 生成

- 代码：`get_response.py:7, 55`。模型是 ChatGLM3（`.half()`），调用 `model.chat(tokenizer, query, messages)`，没有传任何采样参数，因此用的是 ChatGLM3 `chat()` 的默认值。据我回忆，ChatGLM3 的默认值是 `do_sample=True, top_p=0.8, temperature=0.8, max_length=8192`，并会对回复做 strip；**这一点不在固定来源内，本轮没有核实**。
- 论文：只说开源模型用 chat 版本、GPT 用 `gpt-4` 和 `gpt-3.5-turbo-1106`（:531-532）。temperature、top_p、max_tokens、重试、种子在 LaTeX 中全文检索都没有出现【论文未说明 → 平台选择，须写明理由】。
- 失败约定：官方脚本本身从不写入 `"ERROR"`。`"ERROR"` 和 `None` 只在 S5 被过滤，是给 API 型生成脚本留的约定。

### S5 格式转换（`transform_format.py:13-22`）

```python
if data['model_output'] is not None and data['model_output'] != "ERROR":
    model_output = data['model_output'].split("\n")[0] # Prevent continuous generation
    ...
    if str(data['id']) in id_metric:
        for x in id_metric[str(data['id'])]:
            data['metric_en']= x[0]; data['metric_zh']= x[1]
            results.append(copy.deepcopy(data))
```

- 按以下顺序处理：先过滤 None 和字面量 `"ERROR"`（大小写敏感、全等匹配）→ 截取第一个 `'\n'` 之前的部分 → 用 `str(id)` 查 id2metric，查不到就静默丢弃 → 每个指标展开成一行（行顺序与 id2metric 中一致）。
- `split("\n")[0]` 的效果：回复以换行开头时结果是**空串，空串照样送去打分**；`\r` 会保留；回复不做 strip。TS 中用 `s.split('\n')[0]` 即可，不能用 trim 或按 `/\r?\n/` 切分。
- 被过滤的样本**不计 1 分，也不进分母**，直接从该指标的均值中消失。
- 论文：稀疏指标矩阵【论文支持 :469】。首行截断、ERROR 过滤、缺失 id 的处理【论文未说明 → 按代码】。

### S6 奖励模型输入（`run_char_rm.py:15-18, 29-32`）

```python
input_text = "<RoleInfo>\n\n" + str(character_profile[example['role']]) + "\n\n<Context>\n\n" + example['context'] + "\n\n<Response>\n\n" + example['model_output'] + "\n\n<Dimension>\n\n" + example["metric_zh"]
input_ids = tokenizer.encode(text=input_text, add_special_tokens=False) + [tokenizer.eos_token_id]
if len(input_ids) > max_seq_length: input_ids = input_ids[-max_seq_length:]   # max_seq_length = 4096
```

- 注意：`<Context>` 后面放的是**原始 context 字符串**，不是 S3 里改写过的消息；`<Response>` 后面放的是 S5 处理后的首行（通常带 "角色名：" 前缀，**不能去掉**）；`<Dimension>` 后面放的是 `metric_zh` 中文名（12 个，原样取自 id2metric）。
- 分词：`add_special_tokens=False`，不加 BOS（tokenizer_config 中 `add_bos_token:false`、`add_eos_token:false` 也是这个结果），末尾手动追加 `eos_token_id`。`</s>` 的 id 是 2（config `eos_token_id:2`）。慢速分词器先按特殊 token 字面量（`<s>`、`</s>`、`<unk>`）切开文本，其余部分交给 `sp_model.encode(text, out_type=str)`，再用 `piece_to_id` 转成 id（`tokenization_baichuan.py:93-99`）。
- 截断：**左截断**，保留最后 4096 个 token，**4096 包含末尾的 eos**。截断时最先被切掉的是开头的 `<RoleInfo>` 和 profile。
- `tokenizer.pad_token = eos` 和 `padding_side = "left"`（`:21-22`）**没有起作用**：每次只送一条样本，不做 padding，也不传 attention_mask。
- 平台要求：评分端点必须用官方 `tokenizer.model` 和上面这套慢速分词路径（requirements 中 `transformers==4.36.2`）。平台用 TS 拼好 input_text 后发给端点；如果端点改用别的分词实现，必须逐 token 比对。
- 论文：只说 CharacterRM 以 Baichuan2-13B-base 为基座、用人工标注训练【论文支持 :471】，输入模板、截断方式【论文未说明 → 按代码】。

### S7 `str(profile)` 的确切形式（Python repr）

- `json.load` 保留文件中的键顺序，repr 的格式是 `{'键': '值', '键2': [...], '键3': {...}}`，分隔符为 `': '` 和 `', '`；中文等可打印字符原样输出，不转义。
- 字符串的引号规则：如果字符串含 `'` 而不含 `"`，就用双引号包；两种都含时，用单引号包并转义成 `\'`；`\\`、`\n`、`\t` 和不可打印字符（如 U+3000、U+00A0、零宽字符）会被转义。
- 本数据实测：值类型 str 1130、list 163、dict 87（有嵌套）；**9 个字符串含 `'`，会被双引号包住**（如 安陵容/人物经历、薰儿/评价[0]、薰儿/评价[1]、史强/人物关系/常伟思、史强/评价[0]、史强/评价[2] 等）；没有反斜杠、没有不可打印字符、没有同时含两种引号的字符串、没有重复键；profile repr 长度为 175–2579 字符。
- 平台要求：**不要在 TS 里手写 repr**。用 Python 离线生成 78 个角色的 `str(profile)` 字符串，作为数据随协议冻结，并记录哈希。S3 的系统文本和 S6 的 RoleInfo 用的是同一个字符串。

### S8 奖励模型前向：`base_model(input_ids=...)[1]` 究竟是什么

依据 `modeling_baichuan.py`（HF e953da5）：

- `BaichuanCharRM.forward`（:553-594）：`hidden_states = self.model(...)[0]`，即经过最终 RMSNorm（:477）的最后一层隐状态 → `hidden_states[:, -1, :]`，也就是**最后一个位置，即追加上去的 eos `</s>`（截断后也仍然是它）** → `self.score`，一个 `nn.Linear(5120, 1, bias=True)`（:542，权重 `score.weight` 和 `score.bias` 在 index.json 中存在） → `.squeeze()` → **`F.sigmoid`**。返回值是 `(loss, logits)`，因此 `[1]` 就是 **sigmoid 概率，范围 (0,1)**。
- 训练损失：`MSELoss(logits, labels/4)`（:589-592），说明训练标签是 0–4 分（即 5 分制减 1）。所以推理时 `score = sigmoid × 4 + 1`，范围 (1, 5)，与论文的五分制一致【论文支持 :471】；sigmoid 和 MSE【论文未说明】。
- 精度：`torch_dtype=torch.bfloat16`（`run_char_rm.py:23`），没有 autocast。score 头和 sigmoid 都在 bf16 下计算，`.item()` 之后才在 Python float64 中做 `*4+1`。因此每个样本的分数落在 bf16 网格上（sigmoid ≥ 0.5 时步长 2⁻⁸，折算后是 0.015625 分）。
- `from_pretrained` 返回的模型处于 eval 模式，走的是显式 matmul + softmax 路径（:184-201）；xformers / SDPA 分支只在 `self.training` 时使用。ALiBi 掩码的缓冲长度是 `model_max_length` 4096。
- 平台要求：端点应当用 bf16、batch=1、不加 padding、eager attention，返回**不取整**的 sigmoid 值或最终分数。如果要做批处理或改用 SDPA / flash attention，必须先在冻结样本上证明结果逐值一致或误差可以忽略。

### S9 汇总（`compute_score.py:1-12`）

- 对每个 `metric_en`，把所有行的 `record[metric_en]` 求算术平均，打印的是**未取整的浮点数**，按指标首次出现的顺序输出。
- **代码中没有维度平均（Avg.），没有总分，也没有人格回测（MBTI）。**
- 论文表 5 的三个维度 Avg.：本轮用论文 15 行数据验算，与"该维度各指标均值的**不加权**算术平均"在 ±0.0005 内全部吻合；如果按样本数加权，结果明显不同（例如 ChatGLM3 的会话能力加权平均为 3.429，而论文写的是 3.399）。论文**没有总分列**（只有雷达图）。【论文未说明公式，数字支持"不加权平均"】
- 指标与论文缩写的对应关系：Fluency→Flu.、Coherence→Coh.、Consistency→Cons.、Exposure→KE、Accuracy→KA、Hallucination→KH、Behavior→PB、Utterance→PU、Humanlikeness→HL、Communication_skills→CS、Diversity→ED、Empathy→Emp.
- 平台要求：每个样本都不取整；只在展示时保留 3 位小数（符合 C5）。

---

## 2. 数据中的边界情况（用 `python3 -I` 实测）

| 项目 | 计数 | 影响与处理 |
|---|---|---|
| 测试样本 / 唯一 id / 重复 id | 4,564 / 4,564 / 0 | 无重复 |
| 测试 id 不在 id2metric 中 | **33**（如 678、952、999…10315；其中 宋江 8 条） | 官方会生成回复但不打分。平台可以跳过这 33 条的生成以省钱，对分数没有影响 |
| 参与打分的 id / (样本, 指标) 行数 | 4,531 / **8,032** | 与 5 个已发布结果文件的行数完全一致 |
| 每个 id 的指标数 | 1 个：2,801；2 个：962；…；11 个：4 | 稀疏矩阵 |
| 各指标行数 | Emp 744、ED 737、CS 734、PU 724、HL 724、PB 720、KA 718、Cons 715、KE 714、Coh 708、**KH 486、Flu 308** | 不同指标的样本量差异很大 |
| 角色先说话 | **2,077**（45.5%） | 首句并入固定 assistant 消息，不带前缀 |
| 单行 context（角色没说过话） | 529 | 请求只有 3 条消息 |
| context 行数 | 1–50，均值 6.87 | |
| 行内有多个全角冒号 | 174 行 | 只按第一个冒号切分 |
| 行内没有全角冒号 / 空行 / 冒号后为空 / `\r` / 制表符 | 0 / 0 / 0 / 0 / 0 | 无 |
| 最后一行是角色说的 / 角色连续两行 / 对方连续两行 | 0 / 0 / 0 | 两个断言都不会触发 |
| 对方说话人名带括号 | 1（id 8158，"小郭（忍着）"，与"郭芙蓉"并存，所以这条对话有两个非角色说话人） | 按代码原样作为前缀 |
| 对方名是角色名的子串 | 2（8550/8551，"强" 对 "史强"） | 按全等比较，"强"算对方 |
| context 末尾有空格 | 3（355、6650、4852） | 最后一条 user 消息保留末尾空格，不要 trim |
| context 中出现 `<s>` `</s>` `<unk>` 或模板标签 | 0 | 被测模型的回复里可能出现，那样会被切成特殊 token（官方行为，照做） |
| profiles 数 / 用到的角色数 | 78 / 77（沙僧未使用） | |
| profile 字符串含 `'` | 9 个 | repr 用双引号包（见 S7） |
| context 最长 | 1,684 字符 | |
| 已发布结果中含 `\n` / None / ERROR / 空串 | 5 个文件均为 0 | **已发布的结果已经做过 `split("\n")[0]`**，就是 transform 之后的文件 |
| 已发布回复以"角色名："开头 | baichuan 7404、chatglm3 7854、internlm 7851、qwen 7440、xverse 7463（共 8,032 行） | 前缀是常态，不能去掉 |
| 已发布回复首尾有空白 | baichuan 3、chatglm3 3、xverse 418 | 官方不 strip |
| 奖励模型输入超过 4096 字符（用已发布回复估算） | baichuan 16、chatglm3 7、internlm 3、qwen 2、xverse 3；最长 12,201 字符 | token 数没法算（没下载 tokenizer.model），截断只影响极少数样本 |
| 奖励模型训练数据 `rm_train_data.json` | 7,228 行，4,241 个 id **全部是测试集 id**，context 与测试集完全相同；回复只有 21 行与已发布回复相同（因为同一行可能同时匹配多个模型，按模型累计为 32 次）；"角色名："前缀占 6,317 行；标签为字符串 "1"–"5" | 奖励模型是在测试集的 context 上训练的（回复不同），属于效度问题，不影响复现 |

---

## 3. 冲突与未说明项（按优先级给出处理）

| # | 项目 | 论文 | 代码 / 发布物 | 处理 |
|---|---|---|---|---|
| 1 | 生成用的提示词 | 只说"同一提示词，闭源模型略作调整"（:535），没给原文 | `get_response.py:50-52, 14-16`，**没有 system 角色** | 论文未说明 → 按代码逐字执行；"略作调整"的内容未知，平台不做任何调整，并登记 |
| 2 | 角色先说话的处理 | 未说明（:208 的公式默认以 q1 开头） | 首句并入固定确认语 | 按代码 |
| 3 | 解码参数 | 未说明 | ChatGLM3 `chat()` 默认值（未在固定来源中核实） | 平台选择，须写明理由（例如 temperature 用服务商默认值或固定值、max_tokens 留足以免首行被截断） |
| 4 | 首行截断、ERROR/None 过滤 | 未说明 | `transform_format.py:14-15` | 按代码。永久失败的样本按官方做法排除（不计分），但必须统计失败数并公开；如果平台想改成计 1 分，就属于偏离，须升级协议版本 |
| 5 | 奖励模型输入模板、4096 左截断、bf16 | 未说明 | `run_char_rm.py` | 按代码 |
| 6 | 分数映射 | 五分制（:471） | sigmoid×4+1（训练用 MSE 对 labels/4） | 一致 |
| 7 | 维度 Avg. | 表 5 有，公式未说明 | 代码中没有 | 采用数字能复现的读法：不加权平均各指标均值（15/15 行吻合） |
| 8 | 总分 | 表 5 没有 | 代码中没有 | 平台如果展示总分，属于平台选择，须注明不是论文指标 |
| 9 | 第 13 项人格回测（MBTI） | :361-363, :473 有描述，表 5 有一列 | **没有代码，没有问卷，没有 54 个 MBTI 标签，没有作答协议** | **无法忠实复现**，平台标注"未复现"或明确登记为偏离 |
| 10 | 样本数 | 摘要 11,376（:126）；表 2 中 6,811+4,564=**11,375**（:381）；README 写 23,020 | 测试文件 4,564，实际打分 4,531 个 id、8,032 行 | 以测试文件为准；33 个 id 不打分，按代码 |
| 11 | 基线数量 | "10 baselines"（:530） | 表 5 列了 15 个模型 | 论文内部不一致，不影响协议 |
| 12 | 训练/测试划分 | "split … based on examples"（:387） | 奖励模型训练数据的 4,241 个 id 全部来自测试集 | 不影响复现。在报告中注明奖励模型在测试 context 上做过训练 |
| 13 | 回复格式（行为放在括号里） | :212 | 代码不解析、不强制 | 按代码，不做处理 |
| 14 | 奖励模型与人工的相关性（表 3） | :419-441 | 人工标注的测试数据未发布（只有训练集 rm_train_data） | D2 无法完整做；可以用训练集检查端点是否正常工作，但不能当作效度证据 |

## 4. 妨碍忠实复现的问题与必要的验证

1. **MBTI 人格回测无法复现**（第 3 节 #9）。
2. **评分端点的等价性尚未验证**：分词（tokenizer.model 和 4.36.2 慢速分词路径）、bf16、eager attention、batch=1。建议的锚定实验（D1/D5，不需要生成费用，但会调用奖励模型端点，须经用户批准）：把 5 个已发布的 `generation_trans_*.jsonl` 共 40,160 行送入端点，与论文表 5 中 ChatGLM3-6B、Baichuan2-7B、Qwen-7B、InternLM-7B、XVERSE-7B 的 12 个指标逐项比较（3 位小数）。这是唯一能端到端校准"端点 + 汇总"的发布物。
3. **生成侧没有可锚定的发布物**：论文中的模型（ChatGLM3 等）只给了输出，没有给解码设置；GPT-4（`gpt-4`）和 GPT-3.5（`gpt-3.5-turbo-1106`）的"略作调整"未知。生成侧的锚定只能做到近似。
4. **`str(profile)` 必须用 Python 离线生成**后冻结，否则 9 个含 `'` 的字符串和嵌套结构很容易被写错。
5. 本轮没有核实的外部事实：ChatGLM3 `chat()` 的默认采样参数及其是否 strip 回复；tokenizer.model 的归一化设置（add_dummy_prefix 等），因为这个文件没有下载。

（分析脚本在 `scratchpad/scripts/ce_a1.py` 到 `ce_a10.py`，都只读数据，用 `python3 -I` 运行。）
