我很喜歡 diff。它從來不說「大致正確」。
兩份文字擺在一起,哪裡多了、哪裡少了、哪裡換掉,一條一條攤給你看,沒有平均,也沒有總分可以躲。Day 2 那個負號消失的錯,是 diff 讓我看到的。Day 6 的「阻燃」變「防燃」,也是。
昨天 Harness 接起來以後,我手上多了一堆 trace_id、一堆 FLAG、一堆設計值:logprob 門檻 0.80、Tesseract 的 min_conf 60、Day 18 切片的 400 字上限、Day 20 判重複退化的 0.3。這些數字全都是我先抓的。要讓它們變成有根據的數字,只能靠跑出來的錯誤回頭餵。
這就是飛輪。聽起來很帥,但做起來第一步其實很土:先把錯誤變成可以數的東西。
experiments/analysis/compare_ocr.py 從 Day 2 就在了。它做的事情很簡單:
normalize():NFKC、全形轉半形、剝掉 Markdown 的 |、---、**,去掉所有空白difflib.SequenceMatcher(autojunk=False) 在正規化後的字串上跑 get_opcodes()
replace、delete、insert 三種 opcode 寫進 analysis/diffs/*.diff.txt
Day 6 的錯誤分類就是建在這些 opcode 上。九頁、87 個 diff 塊,分類器把其中 37 塊丟進 other,Day 8 寫成「分類器未知率 43%」。
當時我就覺得這個 43% 怪怪的。後來翻 step4_zh_error_summary.md 的方法限制,自己寫的那段話講得很清楚:
同一個語意錯誤有時會被切成多個相鄰的小 diff 塊,或反過來把兩個不相關的錯誤合併成一塊 replace;次數統計是「diff 塊數」而非「錯誤事件數」。
換句話說,飛輪如果直接吃 opcode,它吃進去的東西單位就是錯的。一個負號消失,在 opcode 層可能是兩塊:一塊刪掉 (,一塊刪掉 )。你拿這個去統計「負號錯誤發生幾次」,會算出兩倍。
compare_ocr.py 還有一個問題,是它當初的任務用不到、但飛輪一定會用到的:它比完之後只留正規化後的字串。diff.txt 裡寫的位置 gt(29, 30) 是正規化字串的位置,不是原文的位置。人工稽核的人拿到這個座標,回原文找不到東西。
所以今天要改的,就是這兩件事。
Day 7 那條原則我一直記著:正規化只能用在比較鍵,不能寫回原文。這裡把它貫徹到底,比較的時候用鍵,回報的時候每一個差異都要指回原文的位置。
做法是在建比較鍵的時候,順手記下每個鍵字元來自原文的第幾個字元:
from __future__ import annotations
import difflib
import re
import unicodedata
from dataclasses import dataclass
# Day 7 的異體字表(節錄),只收方向明確、同義的
VARIANT_FOLD = {"臺": "台", "裏": "裡", "爲": "為", "綫": "線"}
FULLWIDTH = {",": ",", "(": "(", ")": ")", "%": "%", ":": ":"}
_DROP = re.compile(r"[\s |*`#•]") # 空白與 Markdown 骨架
_NUM_TOKEN = re.compile(r"\(?-?\d[\d,]*(?:\.\d+)?\)?")
def keyed(raw: str) -> tuple[str, list[int]]:
"""回傳 (比較鍵, offsets),offsets[k] = 鍵第 k 個字元在原文的位置。"""
key_chars, offsets = [], []
for i, ch in enumerate(raw):
c = unicodedata.normalize("NFC", ch)
c = FULLWIDTH.get(c, c)
c = VARIANT_FOLD.get(c, c)
if _DROP.match(c):
continue
key_chars.append(c)
offsets.append(i)
return "".join(key_chars), offsets
跟 compare_ocr.py 比,我拿掉了 NFKC,只做 NFC 加一張手寫的全形表。這是 Day 7 那個 Tip 的延伸:NFKC 會把一些有意義的全形標點一起折掉,拿來算 CER 還好,拿來當飛輪的輸入就太粗了。
然後是對齊本身:
@dataclass
class DiffEvent:
tag: str # replace / delete / insert
gt_raw: str # 原文片段(GT 或人工修正版)
hyp_raw: str # 模型輸出片段
gt_span: tuple[int, int] # 原文座標,人可以直接回去找
hyp_span: tuple[int, int]
gt_key: str
hyp_key: str
def _expand(key: str, a: int, b: int) -> tuple[int, int]:
"""差異碰到數字 token 的話,擴成整個 token。"""
for m in _NUM_TOKEN.finditer(key):
s, e = m.start(), m.end()
touches = (s < b and a < e) if b > a else (s <= a <= e)
if touches:
a, b = min(a, s), max(b, e)
return a, b
def _raw_span(offsets: list[int], raw: str, k1: int, k2: int) -> tuple[int, int]:
if k1 == k2: # 純插入點
pos = offsets[k1] if k1 < len(offsets) else len(raw)
return pos, pos
return offsets[k1], offsets[k2 - 1] + 1
def align(gt_raw: str, hyp_raw: str, merge_gap: int = 2) -> list[DiffEvent]:
gk, go = keyed(gt_raw)
hk, ho = keyed(hyp_raw)
sm = difflib.SequenceMatcher(a=gk, b=hk, autojunk=False)
spans: list[tuple[int, int, int, int]] = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
continue
i1, i2 = _expand(gk, i1, i2)
j1, j2 = _expand(hk, j1, j2)
if spans and i1 <= spans[-1][1] + merge_gap and j1 <= spans[-1][3] + merge_gap:
a1, a2, b1, b2 = spans[-1]
spans[-1] = (a1, max(a2, i2), b1, max(b2, j2))
else:
spans.append((i1, i2, j1, j2))
events = []
for i1, i2, j1, j2 in spans:
tag = "replace" if (i2 > i1 and j2 > j1) else ("delete" if i2 > i1 else "insert")
gs = _raw_span(go, gt_raw, i1, i2)
hs = _raw_span(ho, hyp_raw, j1, j2)
events.append(DiffEvent(tag, gt_raw[gs[0]:gs[1]], hyp_raw[hs[0]:hs[1]],
gs, hs, gk[i1:i2], hk[j1:j2]))
return events
兩個跟原版不一樣的地方。
_expand 會把碰到數字的差異擴成完整的數字 token。這是專門為負號寫的。第一版我只有 merge_gap(相鄰兩個 opcode 距離兩個字元以內就合併),拿 (1,678) 對 1,678 一跑,出來兩個事件:刪了 (、刪了 )。中間隔著 1,678 五個字元,merge_gap=2 合不起來。
我盯著那兩行輸出看了一下,覺得調大 merge_gap 不是正解,調到 5 會把很多不相干的錯誤黏在一起。數字在財報裡本來就是一個單位,差異碰到它,就應該整個一起算。
_raw_span 則把鍵上的座標翻回原文座標。
拿一段手寫的小例子跑(不是某一頁的實測,是我照 page_61 和 Day 2 的錯誤形狀拼起來的):
gt = "《114年5月8日竹環字第1140014905號》 其他 (1,678) 臺灣"
hyp = "《114年5月8日竹環字第\n1140014905號》\n| 其他 | 1,678 |\n台灣"
for e in align(gt, hyp):
print(e.tag, repr(e.gt_raw), "→", repr(e.hyp_raw), e.gt_span, e.hyp_span)
replace '(1,678)' → '1,678' (29, 36) (34, 39)
只剩一個事件。
換行切開的公告字號沒有被報成錯,Markdown 表格的 | 沒有被報成錯,臺和台也沒有被報成錯。唯一剩下的,就是那個真的會讓一筆支出變收入的負號。而且 (29, 36) 是原文的位置,回 GT 找就是 (1,678) 那七個字元。
這個例子裡的換行,是從 experiments/mops/analysis/page_61_tight_key_facts.txt 學來的。那份核對檔裡三個公告字號有兩個 raw_hit=False、norm_hit=True,原因就是緊裁輸出把 竹環字第 跟數字切在兩行。原文比對說它錯,比較鍵說它對,比較鍵是對的。
Tip:
align的兩個參數gt_raw和hyp_raw,我刻意不寫成truth和prediction。因為在飛輪裡,「左邊那份」不一定是真相。有時候是 PDF 文字層,有時候是 Tesseract,有時候是人工修正過的版本。只有最後一種能當答案卷。名字取錯,三個月後就會有人把 Tesseract 的輸出當 ground truth 拿去調門檻。
有了事件,才有東西可以轉。整個迴路我畫成這樣:
Harness 跑完一份文件
│
▼
┌───────────────┐ 三種對照組:
│ 1 收集對照組 │ a. VLM vs Tesseract(線上就有,但沒有真相)
└───────┬───────┘ b. VLM vs PDF 文字層(有文字層的頁才有,有瑕疵)
│ c. VLM vs 人工修正(Day 27 抽樣稽核後才有)
▼
┌───────────────┐
│ 2 align() │ 比較鍵上比,原文上報,數字整顆算
└───────┬───────┘
▼
┌───────────────┐
│ 3 標記 │ 先自動標初步類別,人工稽核後覆寫
└───────┬───────┘
▼
┌───────────────┐
│ 4 彙整 │ 依區塊類型、DPI、類別統計(Day 26 的 SQLite)
└───────┬───────┘
▼
┌───────────────┐ logprob 門檻、Tesseract min_conf、
│ 5 校準 │ Day 18 的 400 字上限、VARIANT_FOLD 表、
└───────┬───────┘ 輸出格式 prompt
▼
┌───────────────┐
│ 6 全樣本重跑 │ 改完一條規則,舊樣本全部重跑比對
└───────┬───────┘
└──────────► 回到 Harness
第 3 步的自動標記,只是一個很粗的初篩:
def auto_label(ev: DiffEvent) -> str:
g, h = ev.gt_key, ev.hyp_key
if _NUM_TOKEN.fullmatch(g or "x") or _NUM_TOKEN.fullmatch(h or "x"):
if g.strip("()") == h.strip("()") and g != h:
return "sign_lost" # 數字一樣,括號不見
return "number_unit"
if ev.tag == "replace" and len(g) == len(h):
return "char_swap" # 形近字候選,例如 阻→防
if ev.tag == "delete":
return "omission"
if ev.tag == "insert":
return "insertion"
return "other"
sign_lost 是我特地拉出來的一類。在 Day 6 的分類表裡,它會混在 number_unit 裡面,跟「22 讀成 60」算同一種。但這兩種錯的危險程度差很多,負號錯是金額方向整個反過來,我希望在統計上它是獨立的一行,不會被其他數字錯誤淹沒。
大部分講 data flywheel 的文章,都在講它轉起來以後有多好。我比較在意的是它會不會轉錯方向。
上面三種對照組裡,只有 c(人工修正)有真相。a 和 b 都沒有。
a 是 VLM 對 Tesseract。兩邊不一致,只代表有一邊錯,甚至兩邊都錯。如果我拿 a 的分歧量當目標去調 Tesseract 的 min_conf,調到分歧最少,得到的是「兩個系統最常同意彼此」的門檻。這跟「最常抓到真的錯」是兩回事。極端一點,把 min_conf 調到 100,Tesseract 幾乎什麼字都不交,分歧就歸零了。
b 是 VLM 對 PDF 文字層。這個我在 Day 2 就吃過虧:PyMuPDF 抽出來的文字層有 PUA 怪字,還有 ㇐(CJK 筆畫區)這種長得跟「一」一模一樣、碼位卻不同的字。Day 6 分類表裡 page_01 那筆 other,就是 GT 寫 ㇐、模型寫 一。模型是對的,GT 是抽取瑕疵。如果飛輪不分青紅皂白地把 b 的差異都當成模型的錯,它會學著去「修正」一個本來就對的輸出。
所以我給飛輪訂了一條規矩:
只有 c 可以拿來調門檻。a 和 b 只能拿來排優先順序,決定哪些區塊要先送去給人看。
這條規矩讓飛輪轉得很慢,因為 c 的來源是人工稽核,一次只有幾個百分點的樣本。但轉得慢總比轉錯好。一個轉錯方向的飛輪,比沒有飛輪還危險,因為它會讓錯誤看起來越來越少。
講這麼多,第 5 步到底在做什麼?用 Day 22 那個拍腦袋的 min_prob=0.80 當例子。
等 Day 27 的人工稽核累積了一批事件,每個事件都有「人工判定這裡是錯的」或「這裡其實沒錯」的標記,加上那個位置當時的 logprob。校準就是回答一個問題:門檻要設多少,才能抓到大部分真的錯,又不要誤報太多?
def calibrate_min_prob(labeled: list[tuple[float, bool]],
target_recall: float = 0.9) -> tuple[float, float]:
"""labeled: (該位置的 p1, 人工判定是否真的錯)。
回傳 (門檻, 在此門檻下的誤報比例)。
target_recall 是設計選擇:寧可多看,不要漏。
"""
errors = sorted(p for p, is_err in labeled if is_err)
if not errors:
raise ValueError("還沒有任何人工確認的錯誤,不能校準")
k = min(len(errors) - 1, int(len(errors) * target_recall))
threshold = errors[k] # 有 target_recall 比例的真錯落在門檻以下
flagged = [is_err for p, is_err in labeled if p <= threshold]
false_alarm = flagged.count(False) / len(flagged)
return threshold, false_alarm
算出來的東西有兩個:門檻,還有這個門檻會帶來多少誤報。後者比前者重要。如果要抓到九成的真錯,代價是七成的 FLAG 都是誤報,那人工複核的人很快就會麻痺,這時候我寧可降低 target_recall,或者乾脆承認 logprob 這層在這類區塊上沒用。
這段程式碼目前沒有資料可以餵,因為 Day 22 講過,我到現在所有的請求都沒開 logprobs。所以它是一個等著被填的函式。第一步很明確:下次跑任何東西,把 logprobs=True 打開。
Tip:校準之後要做第 6 步,全樣本重跑。Day 10 我寫過類似的話:正規化規則改完要重跑全部樣本,不能只看有沒有救到那一頁。門檻也一樣。調一個門檻救了 A 類的錯,可能讓 B 類的誤報翻倍。沒有重跑,你只會看到自己想看的那一半。
一個 DiffEvent 現在是記憶體裡的一個 dataclass,跑完就沒了。
飛輪要轉,事件就得活得比一次執行久。還得跟昨天那些 trace_id、msg_id 接得起來:這個負號錯誤是在哪個區塊、幾 DPI、哪條規則生出來的任務、第幾次重試的時候發生的?人工後來怎麼判?判的人是誰?
這些問題全部指向同一件事:記錄的格式。明天就來設計它,JSONL 跟 SQLite 各管一半。