昨天收在一個很尷尬的地方:第 61 頁三個公文字號,整頁送 0/6,切半送還是 0/6。
交叉引用解析要吃文字。沒有文字,後面什麼都不用談。所以今天分兩段:前半段先把字讀出來,後半段才輪到今天真正的題目。
病因昨天已經找到了,是文字密度對上固定的 256 個視覺 token。那修法就很直覺:不要整頁塞,也不要切半,直接把要讀的那一小段裁出來,順便把渲染 DPI 從 200 拉到 400。
裁切座標第一次就抓錯了。
我原本沿用昨天切半的寬度,以為左半頁就是一個欄位。結果裁出來的 ground truth 裡混進一句「展望未來,台積公司將持續以員工體驗為核心⋯」。這是隔壁欄的字。第 61 頁的左半頁,內部其實還分兩欄,昨天模型把兩欄交錯拼接(案例集 M-08),原因就在這裡。
改成只取公文字號那一欄的實際欄寬,x 座標 40 到 305,不是半頁寬。這次乾淨了。
這件事讓我對「切半」這個做法有點改觀。它聽起來像一個幾何問題,找到中線切下去就好。但版面的欄位不會乖乖對齊你切的那條線。要裁,得看內容實際的文字框,不是頁面的幾何中心。
修完重打一次 GB10:
| 版本 | CER | 覆蓋率 | 3 字號+3 金額命中 | completion tokens | 耗時 |
|---|---|---|---|---|---|
| 整頁(Day 14) | 0.9792 | 0.0318 | 0/6 | 3000(撞上限) | 58.2 秒 |
| 左半頁(Day 14) | 0.9313 | 0.1405 | 0/6 | 3000(撞上限) | 58.4 秒 |
| 緊裁單欄+400dpi(今天) | 0.0845 | 0.9966 | 6/6 | 277 | 5.6 秒 |
看到 6/6 的時候我真的鬆了一口氣。
輸出幾乎逐字正確。唯一挑得出來的錯是開頭「民國一百一十四年」少了一個「一」,變成「民國一百十四年」。又是國字數字。這個錯跟昨天那種整段幻覺、七百行空表格,已經是完全不同量級的問題了。
時間也從將近一分鐘降到 5.6 秒。一分鐘產出垃圾,跟五秒產出正確答案,差別不只在準確率。
有一個小細節:核對腳本在比對三個字號時,有兩個是 raw_hit=False, norm_hit=True。原因是模型照著原圖的斷行,把「竹環字第」跟「1140014905號」分在兩行。去掉空白之後就對上了。這個斷行問題等一下在抽取器裡又會遇到一次。
OCR 堪用之後,才輪到第一種交叉引用:把「XX字第NNNNNNNNNN號」這種公文字號,連同它關聯的罰鍰金額,從文字裡抽出來。
程式在 D:\iron-people\harness\citation_extractor.py。核心是兩個正則加一點領域規則,但過程中踩了三個坑,每一個都是真的跑出錯誤才發現的。
PyMuPDF 用 text 模式抽出來的文字,是照 PDF 版面逐行斷的。第 61 頁左半的三個字號裡,有兩個剛好被斷在「字第」跟數字之間,或數字跟「號」之間:
...《114年5月8日竹環字第
1140014905號》函裁定...
原本那個不容許中間有空白的正則,直接漏掉這兩筆。解法是在每個字面元件之間都允許空白:
_CITATION_CORE_RE = re.compile(r"字\s*第\s*(\d{4,12})\s*號")
_AMOUNT_RE = re.compile(r"新台幣\s*([\d,]+(?:\.\d+)?\s*萬?\s*元)")
數字本身(\d{4,12})刻意不允許中間夾換行。這份文本裡數字沒有被從中間切開,我不想為了一個沒見過的情況把正則放得太寬。如果以後真的遇到,會抓不到,這是已知限制。
台灣公文字號的慣例寫法是「民國…年…月…日+機關簡稱+字第+數字+號」。機關簡稱前面緊接的是日期的「日」。
如果只是從「字」往回抓連續的中文字,會抓到「日竹環」。
_DATE_SUFFIX_CHARS = set("年月日時分")
_MAX_AGENCY_LEN = 6
def _extract_agency(text: str, zi_idx: int) -> str:
chars = []
i = zi_idx - 1
while i >= 0 and len(chars) < _MAX_AGENCY_LEN and _is_han(text[i]):
chars.append(text[i])
i -= 1
chars.reverse()
# 剝掉開頭的日期單位字,例如 "日竹環" -> "竹環"
k = 0
while k < len(chars) and chars[k] in _DATE_SUFFIX_CHARS and len(chars) - k >= 2:
k += 1
return "".join(chars[k:])
往回最多抓 6 個字當候選,再把開頭的年、月、日、時、分剝掉。這條規則只針對「日期+機關簡稱+字第」這個樣式,不是通用的中文斷詞。機關簡稱本身如果剛好以「年」或「月」開頭,就會誤判。我知道,但現在手上沒有這種樣本,先不處理。
這個坑最陰險,因為輸出看起來完全正常。
我一開始用「絕對距離最近」配對字號跟金額。結果第 2 筆字號被配到第 1 筆的罰鍰:前一筆的「新台幣40萬元」離第 2 筆字號只有 12 個字,第 2 筆自己的「新台幣45萬元」在後面 46 個字。
程式沒有報錯。三筆都有金額。只是其中一筆是錯的。
回頭看原文的句型:「《字號》函裁定…,被處以罰鍰新台幣OO元;《下一個字號》…」。金額幾乎都寫在自己字號的後面,而前一筆的金額會緊貼著下一筆字號的開頭。所以改成先找字號之後 50 字內最近的金額,找不到才往前找。
def _pick_nearest_amount(amount_matches, citation_start, citation_end):
best_fwd, best_fwd_dist = None, None
best_bwd, best_bwd_dist = None, None
for m in amount_matches:
if m.start() >= citation_end:
dist = m.start() - citation_end
if dist <= _ASSOC_WINDOW and (best_fwd is None or dist < best_fwd_dist):
best_fwd, best_fwd_dist = m, dist
elif m.end() <= citation_start:
dist = citation_start - m.end()
if dist <= _ASSOC_WINDOW and (best_bwd is None or dist < best_bwd_dist):
best_bwd, best_bwd_dist = m, dist
return best_fwd if best_fwd is not None else best_bwd
還有一個相關的小坑:我本來是先把字號前後 50 字切成一個小字串,再對小字串跑金額正則。結果視窗邊界剛好切在「新台幣」跟「40萬元」中間,金額整個比對不到。後來改成對整份原文跑一次金額正則,再用字元位置算距離。
Tip:配對類的邏輯(字號配金額、科目配附註、人名配職稱)最容易出「安靜的錯」。每一筆都有配到東西,所以數量檢查會過。要驗證,得拿一份你確定答案的資料,逐筆比對「配到的是不是對的那個」,只數筆數沒有用。
對 ground truth(第 61 頁左半,1305 字):
[1] citation_full='竹環字第1140012798號' agency='竹環' citation_number='1140012798' amount='新台幣40萬元'
[2] citation_full='竹環字第1140014905號' agency='竹環' citation_number='1140014905' amount='新台幣45萬元'
[3] citation_full='竹環字第1140028822號' agency='竹環' citation_number='1140028822' amount='新台幣45萬元'
驗證: 抓到 3/3 筆字號; 數字集合正確=True; 機關簡稱正確=True; 每筆都有關聯金額=True
對今天緊裁的真實 OCR 輸出(332 字)再跑同一支程式,結果一樣:3/3,三個字號跟 ground truth 完全相同,金額也都配對正確。
抽取器裡沒有任何針對 OCR 雜訊的特殊處理。這是刻意的。OCR 讀得好不好是上游的事,抽取器只負責吃乾淨的文字。如果我在抽取器裡加一堆「OCR 可能把 0 讀成 O」之類的猜測,兩個問題就會攪在一起,以後哪一邊出錯都查不清楚。
規劃書這篇的標題是「年報附註與主表之間的字義怎麼對齊」。主表上寫「應收帳款淨額,附註十」,附註十裡面要真的在講應收帳款,合計數要跟主表對得上。
我興沖沖去年報裡找附註,然後發現一件事:這份年報裡沒有完整的合併財務報表。
Day 14 已經看到第 62 頁那句「請至公開資訊觀測站參閱本公司合併財務報告」。再往後翻,第 91 頁講關係企業合併財務報表時,也是同一句話。年報本身只放摘要跟敘事,完整的主表加附註是另一份文件,放在 MOPS 上。
我有點傻眼。原本以為附註跟主表會在同一份 PDF 裡互相引用,結果這份年報最常見的引用,是指到別份文件去。
但在年報裡搜「請參閱」,冒出來的東西比我預期的多。整理一下,這份 PDF 裡至少有四種引用:
| 引用類型 | 實際例子(取自 PDF 文字層) | 指向 |
|---|---|---|
| 章節+頁碼 | 請參閱本年報「2.3.1 董事會成員簡介」(第22-29頁) | 同一份文件的另一段 |
| 註號 | 應遵循事項辦法」(註二)相關規定 | 同一頁或同一表的註解 |
| 外部文件 | 請至公開資訊觀測站參閱本公司合併財務報告 | 另一份文件(MOPS) |
| 公文字號 | 竹環字第1140012798號 | 外部機關的公文 |
最後一種剛剛做完了。今天先把第一種做起來,因為它在文件內部,可以驗證。
規劃書的目標是「語意理解不能只靠通用字典,要從文件本身建立」。章節引用是最直接的例子:「2.3.1」到底是哪一節、在哪一頁,只有這份年報自己知道。
做法是三步:先建印刷頁碼對 PDF 頁的對照,再從各頁掃出章節標題建成章節表,最後把每一個「請參閱」解析出來,檢查它指的章節是不是真的出現在它說的頁碼範圍內。
第一步就踩坑了。
年報寫「第22-29頁」,指的是印刷頁碼。這份 PDF 是對開的,一個 PDF 頁含兩個印刷頁。我以為換算很簡單,乘以 2 再加減個常數就好。
結果去看每頁開頭那兩個三位數的頁碼:PDF 第 5 頁是 008/009,第 13 頁是 022/023,第 44 頁是 086/087,第 61 頁是 118/119。套不出一個固定的公式,中間一定有些頁不是標準的對開。
所以不能用公式算,只能從每一頁的文字層讀出頁碼,一頁一頁建表。
import re
_PRINTED_RE = re.compile(r"^\s*(\d{3})\s*\n\s*(\d{3})\s*\n")
_HEADING_RE = re.compile(r"^(\d+(?:\.\d+){1,3})\s*([^\s((,。]{2,20})", re.M)
_XREF_RE = re.compile(
r"請(?:參閱|詳見)本(?:年報|報告)\s*「\s*(\d+(?:\.\d+){1,3})\s*([^」]*)」"
r"(?:\s*說明)?\s*[((]\s*第?\s*(\d+)\s*(?:[--~]\s*(\d+))?\s*頁\s*[))]")
def build_page_map(doc):
"""印刷頁碼 -> PDF 頁。每個 PDF 頁開頭有兩個三位數頁碼。"""
pmap = {}
for i, page in enumerate(doc):
m = _PRINTED_RE.match(page.get_text())
if m:
for g in m.groups():
pmap[int(g)] = i + 1
return pmap
def build_section_table(doc):
"""章節編號 -> 所有出現位置 [(標題, PDF 頁), ...]"""
table = {}
for i, page in enumerate(doc):
for m in _HEADING_RE.finditer(page.get_text()):
table.setdefault(m.group(1), []).append((m.group(2), i + 1))
return table
def resolve_xrefs(doc):
pmap, sections = build_page_map(doc), build_section_table(doc)
results = []
for i, page in enumerate(doc):
text = re.sub(r"\s*\n\s*", "", page.get_text()) # 引用常被斷行切開
for m in _XREF_RE.finditer(text):
num, title, p_from, p_to = m.groups()
p_from, p_to = int(p_from), int(p_to or p_from)
wanted = {pmap.get(p) for p in range(p_from, p_to + 1)} - {None}
hit = next((h for h in sections.get(num, []) if h[1] in wanted), None)
results.append({
"from_pdf_page": i + 1, "section": num, "title": title.strip(),
"printed_pages": (p_from, p_to), "resolved": hit,
"status": "OK" if hit else "FLAG",
})
return results
這段我在 scratch 目錄裡對台積電年報跑過原型,結果沒有落檔進 experiments/,所以下面只講看到的現象,不列統計數字。
第一版的 build_section_table 用 setdefault 只記每個章節編號第一次出現的位置。跑下去冒出一堆 FLAG,我一筆一筆點開看,全部指向 PDF 第 4 頁。
第 4 頁是目錄。
目錄裡每個章節標題都有,而且是全書第一個出現的地方。所以「第一次出現」永遠是目錄那一頁,永遠不在引用說的頁碼範圍內。這個錯誤很好笑,但也很典型:從文件本身建對照表,文件本身就會用各種方式誤導你。
改成記錄所有出現位置,只要有一個落在引用的頁碼範圍內就算解析成功。FLAG 幾乎全部消失,只剩一筆:
「3.2 董事會—董事會多元化及獨立性」(第47頁)
「3.2 董事會」這個標題在印刷第 44 頁,引用寫的是第 47 頁。第 47 頁是 3.2 底下的小節「董事會多元化及獨立性」,那個小節沒有自己的編號。所以程式找不到 3.2 出現在第 47 頁,判成 FLAG。
這筆 FLAG 是錯殺,引用本身沒錯。但我決定不修。原因是另一個更麻煩的發現:章節編號在這份文件裡不是唯一的。掃出來的「3.2」除了董事會那一節,還有「3.2 一般董事及獨立董事之酬金(註一)」「3.2 品質與良率精進」這些。有的是表格編號,有的是其他章底下的小節。如果我為了消掉這一筆 FLAG 把比對放寬,例如只要標題文字有部分相同就算對,那些撞號的標題就會開始互相誤配。
寧可留一筆錯殺讓人看,也不要為了漂亮的數字把規則放鬆。
回到附註跟主表。年報裡沒有完整財報,這一段沒辦法用台積電的真實資料跑。但對照表的建構邏輯可以先寫好,等拿到 MOPS 上的合併財報再接上。
邏輯跟章節引用是同一個套路,只是換了對象:
import re
from decimal import Decimal
_ROW_RE = re.compile(
r"^(?P<account>[^\d\s((]+?)\s*(?:附註)?\s*"
r"(?P<note>[一二三四五六七八九十]+|\d+)\s+(?P<amount>\(?[\d,]+\)?)\s*$")
_NOTE_HEAD_RE = re.compile(r"^(?P<note>[一二三四五六七八九十]+|\d+)\s*[、..]\s*(?P<title>\S+)", re.M)
_NOTE_TOTAL_RE = re.compile(r"(?:合計|淨額)\s+(?P<amount>\(?[\d,]+\)?)")
def to_decimal(s: str) -> Decimal:
neg = s.startswith("(") and s.endswith(")") # 財報慣用括號表示負數
v = Decimal(s.strip("()").replace(",", ""))
return -v if neg else v
def build_account_map(main_lines, notes_text):
notes = {}
heads = list(_NOTE_HEAD_RE.finditer(notes_text))
for i, h in enumerate(heads):
end = heads[i + 1].start() if i + 1 < len(heads) else len(notes_text)
body = notes_text[h.end():end]
totals = [to_decimal(m.group("amount")) for m in _NOTE_TOTAL_RE.finditer(body)]
notes[h.group("note")] = {"title": h.group("title"),
"total": totals[-1] if totals else None}
rows = []
for line in main_lines:
m = _ROW_RE.match(line.strip())
if not m:
continue
acc, note = m.group("account"), m.group("note")
amt = to_decimal(m.group("amount"))
n, flags = notes.get(note), []
if n is None:
flags.append(f"附註{note}不存在")
else:
if acc not in n["title"] and n["title"] not in acc:
flags.append(f"科目「{acc}」與附註{note}標題「{n['title']}」對不上")
if n["total"] is not None and n["total"] != amt:
flags.append(f"主表 {amt} ≠ 附註合計 {n['total']}")
rows.append({"account": acc, "note": note, "amount": amt, "flags": flags})
return rows
我用一組自己編的合成資料測過邏輯。三個科目,其中「存貨」故意標成附註十二,但附註區塊裡只有附註十一:
{'account': '現金及約當現金', 'note': '六', 'amount': Decimal('1000000'), 'flags': []}
{'account': '應收帳款淨額', 'note': '十', 'amount': Decimal('350000'), 'flags': []}
{'account': '存貨', 'note': '十二', 'amount': Decimal('280000'), 'flags': ['附註十二不存在']}
再強調一次,這些數字全是合成的,只用來確認程式邏輯。這支程式還沒碰過任何一份真實的財報。
寫完之後我對它的信心其實不高。真實財報的主表是表格,不是一行一行乾淨的文字;附註編號可能寫成「附註六及二七」這種一對多;科目名稱在主表跟附註標題之間常常不完全一樣(主表寫「應收帳款淨額」,附註標題可能只寫「應收帳款」)。上面那個「互相包含就算對」的比對,是我能想到最保守、也最容易被打臉的版本。
所以這一段的定位是:流程先定下來,比對規則等真實資料來了再逐條修。跟章節引用那段一樣,寧可一開始 FLAG 多一點。
| 項目 | 狀態 | 備註 |
|---|---|---|
| 緊裁+400dpi 後的 CER、覆蓋率、耗時、token 數 | 已查證 | 真實請求,讀過原始輸出 |
| 6 個關鍵事實在緊裁 OCR 裡全部命中 | 已查證 | 逐字比對,只有一個「一」字缺漏 |
| citation_extractor.py 對 GT 與緊裁 OCR 都是 3/3 | 已查證 | 重跑 python harness/citation_extractor.py 確認 |
| 年報內沒有完整合併財報,第 62、91 頁指向 MOPS | 已查證 | PDF 文字層原文 |
| 印刷頁碼與 PDF 頁不是固定倍數 | 已查證 | 讀各頁開頭頁碼(第 5、13、44、61 頁) |
| 章節引用解析原型在年報上的結果 | 部分查證 | scratch 原型跑過,結果未落檔,文中只描述現象 |
| 機關簡稱規則的泛化能力 | 未查證 | 只在「竹環」一種樣本上驗證 |
| 科目對照表邏輯在真實財報上的表現 | 未查證 | 只用合成資料測過,年報內無完整財報 |
| 緊裁座標能否自動決定 | 未做 | 目前是手動抓的,留給 Day 18 的 Region Routing |
今天做完兩種引用,還有兩種沒做。註號引用我覺得不難,外部文件引用就麻煩了,它得先有辦法把 MOPS 上的那份文件拿進來。
另外我一直想到昨天的 M-01。字號、章節、附註,今天處理的都是「這個東西指到哪裡」。但就算每個引用都指對了,指過去的那個日期還是可能少一個「百」字。字讀對了、引用也對了,值本身合不合理,是另一層問題。明天處理這一層。