VOCR / postprocess_v2.py
sirimiri's picture
New fine-tuned det+rec, ViLM postprocess v2, no_enhance default, LLM post-edit module
35d250b
Raw
History Blame Contribute Delete
7.03 kB
"""
postprocess_v2 — tầng hậu xử lý nâng cấp cho VOCR pipeline.
Cải tiến so với CharNgramLM (trigram + add-epsilon) trong vocr_pipeline.py:
1. Char 5-gram với stupid backoff (5→4→3→2→1), điểm CHUẨN HÓA THEO ĐỘ DÀI
(per-char avg logprob) — không còn thiên vị candidate ngắn.
2. Kiểm tra ÂM TIẾT tiếng Việt: âm tiết không có trong từ vựng corpus
("qưân", "hoc", "trưòng") bị phạt — đúng loại lỗi dấu thanh phổ biến nhất.
3. Bigram mức âm tiết (word-level) — phân định dấu thanh theo ngữ cảnh
("chữa bệnh" vs "chửa bệnh").
4. Re-rank CÓ ĐIỀU KIỆN: khi CTC đã rất chắc chắn (top1 >= 0.90 và bỏ xa
top2) thì giữ nguyên — tránh over-correction.
API tương thích chỗ gọi cũ: ViLM().train_from_file(path) ; .rerank(candidates, alpha)
Corpus nhận cả 2 định dạng dòng: "path<TAB>text" hoặc text thuần.
"""
import math
import unicodedata
from collections import Counter
_PUNC = set(".,;:!?-–—_()[]{}\"'“”‘’/\\@#$%&*+=<>~`|«»…0123456789")
def _nfc(s):
return unicodedata.normalize('NFC', s)
def _syllables(text):
"""Tách chuỗi thành âm tiết (token chữ), bỏ số và dấu câu."""
out = []
for tok in text.lower().split():
tok = tok.strip(''.join(_PUNC & set(tok)))
if tok and not any(c in _PUNC for c in tok):
out.append(tok)
return out
class ViLM:
"""LM ký tự + âm tiết cho tiếng Việt, huấn luyện từ corpus text."""
def __init__(self, n=5, min_syllable_freq=20):
self.n = n
self.min_syllable_freq = min_syllable_freq
self.char_counts = [Counter() for _ in range(n + 1)] # index = order
self.syl_freq = Counter()
self.syl_bigram = Counter()
self.vocab = set()
self.trained = False
# ---------------- huấn luyện ----------------
def train_from_file(self, filepath):
n_lines = 0
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line = line.rstrip('\n')
if not line:
continue
text = line.split('\t', 1)[1] if '\t' in line else line
self._add_line(_nfc(text))
n_lines += 1
self.vocab = {s for s, c in self.syl_freq.items()
if c >= self.min_syllable_freq}
self.trained = n_lines > 0
return n_lines
def _add_line(self, text):
t = '\x02' * (self.n - 1) + text + '\x03'
for order in range(1, self.n + 1):
cnt = self.char_counts[order]
for i in range(len(t) - order + 1):
cnt[t[i:i + order]] += 1
syls = _syllables(text)
self.syl_freq.update(syls)
for a, b in zip(syls, syls[1:]):
self.syl_bigram[(a, b)] += 1
# ---------------- chấm điểm ----------------
def char_logprob(self, text):
"""Per-char avg logprob, stupid backoff (hệ số 0.4 mỗi bậc lùi)."""
if not self.trained or not text:
return 0.0
t = '\x02' * (self.n - 1) + _nfc(text) + '\x03'
total_chars = self.char_counts[1]
n_uni = sum(total_chars.values()) or 1
lp, n_scored = 0.0, 0
for i in range(self.n - 1, len(t)):
score = None
backoff = 1.0
for order in range(self.n, 0, -1):
ctx_start = i - order + 1
gram = t[ctx_start:i + 1]
c_gram = self.char_counts[order].get(gram, 0)
if c_gram > 0:
if order == 1:
score = backoff * (c_gram / n_uni)
else:
c_ctx = self.char_counts[order - 1].get(gram[:-1], 0)
if c_ctx > 0:
score = backoff * (c_gram / c_ctx)
if score:
break
backoff *= 0.4
lp += math.log(score if score else 1e-9)
n_scored += 1
return lp / max(n_scored, 1)
def syllable_score(self, text):
"""(tỷ lệ âm tiết hợp lệ, per-syllable bigram logprob)."""
syls = _syllables(text)
if not syls:
return 1.0, 0.0
valid = sum(1 for s in syls if s in self.vocab or len(s) <= 1)
bg = 0.0
for a, b in zip(syls, syls[1:]):
c_bg = self.syl_bigram.get((a, b), 0)
c_a = self.syl_freq.get(a, 0)
if c_bg > 0 and c_a > 0:
bg += math.log(c_bg / c_a)
elif self.syl_freq.get(b, 0) > 0:
n_syl = sum(self.syl_freq.values()) or 1
bg += math.log(0.4 * self.syl_freq[b] / n_syl)
else:
bg += math.log(1e-7)
n_bg = max(len(syls) - 1, 1)
return valid / len(syls), bg / n_bg
def lm_score(self, text):
"""Điểm LM tổng hợp, đã chuẩn hóa độ dài. Thang ~[-12, 0]."""
if not text:
return -12.0
char_lp = self.char_logprob(text) # ~[-9, 0]
valid_frac, syl_bg = self.syllable_score(text) # [0,1], ~[-16, 0]
return 0.45 * char_lp + 0.25 * (syl_bg / 2.0) + 3.0 * (valid_frac - 1.0)
# ---------------- re-rank ----------------
def rerank(self, candidates, alpha=0.7,
skip_top1_score=0.90, skip_margin=0.30):
"""Re-rank top-K của CTC. Giữ schema output cũ (ctc_score/lm_score/combined).
Gating: nếu CTC top1 >= skip_top1_score và bỏ xa top2 >= skip_margin
thì tin CTC, không cho LM can thiệp (chống over-correction).
"""
if not candidates or not self.trained:
return candidates
cands = sorted(candidates, key=lambda c: c.get('score', 0), reverse=True)
top1 = cands[0].get('score', 0)
top2 = cands[1].get('score', 0) if len(cands) > 1 else 0.0
gated = top1 >= skip_top1_score and (top1 - top2) >= skip_margin
# softmax LM trong nhóm candidate (temperature 0.25) — so sánh tương đối,
# tín hiệu LM không bị nén như thang tuyệt đối
TAU = 0.25
lms = [self.lm_score(c.get('text', '')) if c.get('text') else -12.0
for c in cands]
m = max(lms)
exps = [math.exp((v - m) / TAU) for v in lms]
z = sum(exps) or 1.0
lm_soft = [e / z for e in exps]
scored = []
for c, lm01 in zip(cands, lm_soft):
combined = alpha * c.get('score', 0) + (1 - alpha) * lm01
scored.append({'text': c.get('text', ''),
'ctc_score': round(c.get('score', 0), 4),
'lm_score': round(lm01, 4),
'combined': round(combined, 4)})
if not gated:
scored.sort(key=lambda x: x['combined'], reverse=True)
return scored