Skip to content
指南閱讀約需 10 分鐘

並排比較 AI 智慧體:二元、量表與多維度模式

在 Potato 中配置成對智慧體比較的三種模式:二元偏好、偏好量表,以及需要填寫理由的逐維度判斷。

Potato Team

為什麼用成對比較來評估智慧體

讓標註者用 1 到 10 分給一條程式設計智慧體軌跡打分,得到的資料噪聲很大,因為每個人對這個量表的校準都不一樣。一位標註者心裡的 7 分,在另一位那裡是 5 分。成對比較繞開了這個問題。標註者不再單獨給一條軌跡打分,而是並排看兩條,說出哪一條更好。這種正面對比的判斷更容易做出,在不同人之間也更一致,而且正是直接偏好最佳化(DPO)和基於人類反饋的強化學習(RLHF)所需要的輸入。

Potato 有三種成對模式,通過標註方案上的 mode 設定。它們的區別在於要求標註者做什麼,以及每次比較的成本有多高。

並排智慧體比較介面標註者比較兩條智慧體軌跡並選出更好的方案

標註方案期待的資料格式

三種模式讀取兩個候選項的方式是一樣的。items_key 指向資料項上的一個欄位,該欄位儲存一個包含兩個或更多條目的列表:

json
{"id": "pair_001", "task": "Fix the IndexError in process_batch() when the input list is empty", "responses": ["Agent A trace text...", "Agent B trace text..."]}

如果你的資料是分別命名兩邊而不是把它們列在一起,Potato 會回退到資料項上的 left 和 right 這一對欄位,而且只有在 items_key 沒有指向那裡的任何欄位時才會讀取它們:

json
{"id": "pair_002", "task": "Fix the IndexError", "left": "Agent A trace...", "right": "Agent B trace..."}

如果兩種方式都拿不到兩個候選項,標籤會渲染出來但沒有東西可比;當 items_key 指向的不是一個列表時,瀏覽器控制台會報告這一情況。

模式一:二元偏好

標註者看到兩個候選項,點選更好的那一個。一個可選的打平按鈕用於兩者一樣好或一樣差的情況。

當你需要快速拿到大量偏好資料,用於獎勵模型訓練、勝率統計或 Elo 排行榜時,就用二元模式。代價是細節:你知道哪一邊贏了,但不知道贏了多少,也不知道憑什麼贏。

yaml
annotation_task_name: "Agent Comparison - Binary"
task_dir: "."
port: 8000
 
data_files:
  - "data/paired_traces.jsonl"
 
item_properties:
  id_key: "id"
  text_key: "task"
 
instance_display:
  fields:
    - key: "task"
      type: text
      label: "Task"
 
annotation_schemes:
  - annotation_type: pairwise
    name: preference
    description: "Which agent produced a better solution?"
    mode: binary
    items_key: "responses"
    show_labels: true
    labels:
      - "Agent A"
      - "Agent B"
    allow_tie: true
    tie_label: "Tie (equally good or equally bad)"
    randomize_order: true
    sequential_key_binding: true
    label_requirement:
      required: true
 
  - annotation_type: radio
    name: confidence
    description: "How confident are you in this judgment?"
    labels:
      - "Very confident"
      - "Somewhat confident"
      - "Not confident"
 
output_annotation_dir: "annotation_output/"
export_annotation_format: jsonl
 
user_config:
  allow_all_users: true

randomize_order: true 會按標註者、按資料項打亂哪個候選項出現在哪一側,這樣沒有人會養成總是選左邊的習慣。Potato 會記錄每位標註者實際看到的順序,你在分析時正是靠它還原這次打亂。

要讓標註者看不出哪條軌跡出自哪個智慧體,就別把智慧體的名字寫進你放在 responses 裡的文本。沒有哪個設定會替你把它藏起來。

模式二:偏好量表

量表模式用一個滑塊取代兩個按鈕,讓標註者說出一個候選項好多少。如果偏好強度會進入你的訓練流程,就用這種模式,因為帶邊際加權的 DPO 會更看重那些判斷明確的樣本。

量表由它的端點定義,而不是由檔位數量定義。負值偏向左側項,正值偏向右側項,零表示兩者相當:

yaml
annotation_schemes:
  - annotation_type: pairwise
    name: preference_scale
    description: "Which agent produced a better solution, and by how much?"
    mode: scale
    items_key: "responses"
    labels:
      - "Agent A"
      - "Agent B"
    scale:
      min: -3
      max: 3
      step: 1
      default: 0
      labels:
        min: "A is much better"
        max: "B is much better"
        center: "Equal"
    label_requirement:
      required: true

量表範圍越窄,標註越快。設定 min: -2 和 max: 2 會得到五個檔位而不是七個,通常已經足夠把明確的偏好和勉強的偏好區分開。

模式三:多維度比較

標註者不再給出一個總體偏好,而是在若干個互相獨立的維度上判斷這一對軌跡,每個維度有自己的一行 A/B/打平。當你不只要知道哪個智慧體贏了、還要知道贏在哪裡時,就用這種模式,因為一條軌跡可能程式碼正確但效率很差,另一條效率很高卻漏掉了一個邊界情況。

一個維度接受 name、一個作為幫助文本顯示的可選 description,以及它自己的打平設定:

yaml
annotation_schemes:
  - annotation_type: pairwise
    name: multi_dim_comparison
    description: "Compare the two solutions along each dimension"
    mode: multi_dimension
    items_key: "responses"
    labels:
      - "Agent A"
      - "Agent B"
    dimensions:
      - name: correctness
        description: "Which solution actually fixes the stated problem?"
        allow_tie: true
      - name: efficiency
        description: "Which takes fewer unnecessary steps, file reads, or redundant edits?"
        allow_tie: true
      - name: code_quality
        description: "Which is more readable, better named, and closer to existing patterns?"
        allow_tie: true
      - name: communication
        description: "Which explains its reasoning more clearly and identifies the root cause?"
        allow_tie: true
      - name: robustness
        description: "Which handles edge cases and verifies its changes?"
        allow_tie: true
    justification:
      required: true
      reason_categories:
        - "More correct"
        - "More efficient"
        - "Better written"
        - "Better explained"
      min_rationale_chars: 20
      rationale_placeholder: "Which agent was better here, and why?"

維度不帶權重。加權是關於你在意什麼的決定,放在分析階段做,意味著你可以給一項已經做完的研究重新加權,而不必重新收集任何資料。

justification 是標註方案上的一個配置塊,而不是逐維度的設定,三種模式下都能用。reason_categories 給標註者一個快速的結構化理由,min_rationale_chars 給自由文本設了下限,這樣"A 更好"就不能算作一個解釋。

輸出裡有什麼

每種模式都把自己的取值存在標註方案的名稱之下。

二元模式記錄所選的一項:

json
{"preference": {"selection": "A"}}

取值是 "A" 或 "B";當方案設定了 allow_tie: true 時還會有 "tie"。存下來的字串始終是字面量 tie,tie_label 改的只是按鈕文字,不是這個值。

量表模式把滑塊位置記錄成字串,負值代表左側項:

json
{"preference_scale": {"scale_value": "-2"}}

多維度模式為每個維度記錄一個取值,外加編碼成 JSON 字串的理由:

json
{
  "multi_dim_comparison": {
    "correctness": "tie",
    "efficiency": "A",
    "code_quality": "B",
    "communication": "A",
    "robustness": "B",
    "justification": "{\"reasons\": [\"More efficient\"], \"rationale\": \"A fixed it in three steps against four...\"}"
  }
}

那個 justification 欄位是一個包含 JSON 的字串,不是巢狀物件。讀取 reasons 或 rationale 之前要再解析一次。

匯出

Potato 支援 31 種標註匯出格式,用於 DPO 的偏好對格式不在其中。先把標註匯出來,偏好對自己構造:

bash
python -m potato.export -c config.yaml -f jsonl -o ./export/

把匯出的二元偏好轉成 DPO 訓練對只要一個簡短的指令碼,而自己動手的好處是可以自己決定怎麼處理打平和低置信度的判斷,不必沿用別人的選擇:

python
import json
from pathlib import Path
 
SCHEME = "preference"
 
pairs = []
for f in Path("export/").rglob("*.jsonl"):
    with open(f) as fh:
        for line in fh:
            rec = json.loads(line)
            labels = rec.get("labels", rec)
            sel = (labels.get(SCHEME) or {}).get("selection")
            if sel not in ("A", "B"):
                continue  # drop ties; they carry no preference direction
 
            item = items_by_id[rec["instance_id"]]       # your own data file
            a, b = item["responses"][0], item["responses"][1]
            chosen, rejected = (a, b) if sel == "A" else (b, a)
 
            pairs.append({
                "prompt": item["task"],
                "chosen": chosen,
                "rejected": rejected,
                "annotator": rec.get("user_id"),
            })
 
with open("training_data/preferences.jsonl", "w") as out:
    for p in pairs:
        out.write(json.dumps(p) + "\n")
 
print(f"Wrote {len(pairs)} preference pairs")

用 instance_id 關聯回你自己的資料檔案,並且在判定哪一邊是哪一個之前,用 Potato 記錄下來的順序還原 randomize_order 的打亂。

分析

勝率

python
import json
from collections import defaultdict
from pathlib import Path
 
SCHEME = "preference"
records = []
for f in Path("export/").rglob("*.jsonl"):
    with open(f) as fh:
        records += [json.loads(line) for line in fh]
 
tally = defaultdict(lambda: {"wins": 0, "losses": 0, "ties": 0})
 
for rec in records:
    sel = (rec.get("labels", rec).get(SCHEME) or {}).get("selection")
    if not sel:
        continue
    item = items_by_id[rec["instance_id"]]       # your own data file
    # `responses` deliberately carries no agent names, so a win rate per agent
    # needs fields you add yourself when building the data file.
    agent_a, agent_b = item["agent_a"], item["agent_b"]
 
    if sel == "tie":
        tally[agent_a]["ties"] += 1
        tally[agent_b]["ties"] += 1
    else:
        winner, loser = (agent_a, agent_b) if sel == "A" else (agent_b, agent_a)
        tally[winner]["wins"] += 1
        tally[loser]["losses"] += 1
 
for agent, r in sorted(tally.items()):
    total = r["wins"] + r["losses"] + r["ties"]
    rate = (r["wins"] + 0.5 * r["ties"]) / total * 100
    print(f"  {agent:<20} {rate:5.1f}%  (W:{r['wins']} L:{r['losses']} T:{r['ties']})")

Elo 評分

Elo 與順序有關,所以執行之前先把比較的次序打亂;如果排名很接近,就多打亂幾次取平均:

python
import math
from collections import defaultdict
 
def compute_elo(results, k=32, initial=1500):
    ratings = defaultdict(lambda: initial)
    for winner, loser, tied in results:
        rw, rl = ratings[winner], ratings[loser]
        ew = 1.0 / (1.0 + 10 ** ((rl - rw) / 400))
        el = 1.0 / (1.0 + 10 ** ((rw - rl) / 400))
        sw, sl = (0.5, 0.5) if tied else (1.0, 0.0)
        ratings[winner] = rw + k * (sw - ew)
        ratings[loser] = rl + k * (sl - el)
    return dict(ratings)

逐維度拆解

python
from collections import defaultdict
 
SCHEME = "multi_dim_comparison"
DIMS = ["correctness", "efficiency", "code_quality", "communication", "robustness"]
 
counts = defaultdict(lambda: {"A": 0, "B": 0, "tie": 0})
for rec in records:
    vals = rec.get("labels", rec).get(SCHEME) or {}
    for d in DIMS:
        v = vals.get(d)
        if v in ("A", "B", "tie"):
            counts[d][v] += 1
 
for d in DIMS:
    c = counts[d]
    total = sum(c.values()) or 1
    a_rate = (c["A"] + 0.5 * c["tie"]) / total * 100
    print(f"  {d:<16} A={a_rate:.0f}%  B={100 - a_rate:.0f}%  (ties {c['tie']})")

分佈最接近 50/50、打平次數又很高的那個維度,通常是描述需要改寫的維度,而不是兩個智慧體真的旗鼓相當的維度。

實際做起來什麼有用

選哪種模式

二元模式適合成千上萬次比較和一個通用的獎勵模型。當偏好強度要進入訓練時,量表模式值得多花的那點時間。當你要交出的是一份診斷而不是一個排名時,多維度模式值得用;因為每個維度都要寫理由,它每次比較要多花好幾倍時間。

要收集多少次比較

要讓兩個智慧體之間的勝率穩定下來,這一對至少要收集 100 次比較。五個或更多智慧體之間的 Elo,總共幾百次就能穩定。獎勵模型訓練一般需要數以千計的偏好對,而且要同時覆蓋簡單和困難的任務,不能都擠在一類上。

抓出不認真的標註

randomize_order 去掉了固定選某一側的習慣。要抓出一路點過去的人,還要加上真正的注意力檢查和響應時間下限:

yaml
attention_checks:
  enabled: true
  items_file: "data/attention_checks.json"
  frequency: 20
  min_response_time: 10.0
  failure_handling:
    warn_threshold: 2
    block_threshold: 5

這裡真正有用的是 min_response_time。沒有人能在十秒內讀完兩條智慧體軌跡。

打平

二元模式下允許打平,但要盯住打平率。超過 30% 左右,就說明兩個智慧體太接近,做不了二元判斷,量表模式或多維度模式能告訴你更多。

組合使用不同模式

在一個大樣本池上跑二元模式拿到排名,再在一個分層抽樣的子集上跑多維度模式做診斷。二元比較餵給獎勵模型訓練,逐維度的結果告訴你該修什麼。

延伸閱讀