iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 25 篇

Day 25 - Diff-driven Flywheel:從錯誤到校準的迴路

  • 分享至 

  • xImage
  •  

我很喜歡 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 就在了。它做的事情很簡單:

  1. normalize():NFKC、全形轉半形、剝掉 Markdown 的 |、---、**,去掉所有空白
  2. 用 difflib.SequenceMatcher(autojunk=False) 在正規化後的字串上跑 get_opcodes()
  3. 把 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) 是正規化字串的位置,不是原文的位置。人工稽核的人拿到這個座標,回原文找不到東西。

所以今天要改的,就是這兩件事。


diff 對齊:比較鍵上比,原文上報

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 的來源是人工稽核,一次只有幾個百分點的樣本。但轉得慢總比轉錯好。一個轉錯方向的飛輪,比沒有飛輪還危險,因為它會讓錯誤看起來越來越少。


校準長什麼樣:拿 logprob 門檻當例子

講這麼多,第 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 各管一半。


上一篇
Day 24 - Commander-Subagent-Verifier Harness 完整組裝
下一篇
Day 26 - JSONL/SQLite 記錄設計:資料怎麼變成校準燃料
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
justin_log
iT邦新手 4 級 ‧ 2026-10-09 23:03:59

6 早發了

我要留言

立即登入留言