textmachine/eval/exp15/chunker.py

441 lines
17 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""exp15 — chunkers for Q1: a faithful zh source-sentence splitter + the §B1.5 LOGICAL DP chunker.
Q1 contrast (design §D2): A0 (greedy-1500, from the real backend) vs a LOGICAL-boundary arm sized to
A0's ACTUAL est_out distribution (±10% mean). The ONLY variable is cut ALIGNMENT (greedy is blind to
scene structure; logical aligns cuts to scene-shift boundaries and avoids mid-scene/dialogue cuts).
Size is held equal so Q1 does not confound boundary class with chunk size (that is Q2's domain).
Ports (backend/internal/pipeline/chunker.go):
splitSourceSentences / isOpenQuote / isCloseQuote / isSourceTerminator / isCJKTerminator /
isClosingBracket / isASCIISpace (chunker.go:196-357) — quote-depth-aware, lossless tiling.
Token math:
est_tokens = cjk + other//3 (EstimateTokens units — the greedy 1500 budget space)
est_out = 1.1978*cjk + 0.3852*other (fertility_calib.json — glm-4.6 ru output tokens; §B1.2)
The logical chunker implements §B1.4 boundary classes (B0 chapter/separator, B1 scene-shift lexicon,
B2 TextTiling cohesion valley, B3 paragraph, B4 sentence) and §B1.5 DP packing (Knuth-Plass shortest
path over candidate cuts, INTEGER-scaled costs, asymmetric size penalty). It does NOT peek at the
ground-truth seam offsets — scene-shifts are DISCOVERED (that is the Q1 test).
Self-review by execution: `python chunker.py --selftest` checks tiling losslessness + a size sweep.
"""
from __future__ import annotations
import math
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
import regex
_RE_CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]")
TARGET_CHUNK_TOKENS = 1500 # chunker.go:47 const
F_CJK, F_OTHER = 1.1978, 0.3852 # fertility_calib.json (glm-4.6 ru out tokens from src char classes)
# --- char-class token estimators -------------------------------------------------------------------
def _classes(text: str):
cjk = other = 0
for ch in text:
if _RE_CJK.match(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk, other
def est_tokens(text: str) -> int:
"""EstimateTokens units (source side): cjk + other//3, floored at 16 (chunker.go estTokensFrom)."""
cjk, other = _classes(text)
v = cjk + other // 3
return max(v, 16)
def est_out(text: str) -> float:
"""Predicted glm-4.6 ru OUTPUT tokens for a source slice (fertility model; §B1.2)."""
cjk, other = _classes(text)
return F_CJK * cjk + F_OTHER * other
# --- GREEDY chunker port (chunker.go:82-194 + render.go:285 NormalizeSource) -----------------------
def normalize_source(s: str) -> str:
"""render.go:285 — BOM strip, CRLF/CR->LF, valid-UTF8 (Python strs already are), NFC, TrimSpace."""
s = s.lstrip("")
s = s.replace("\r\n", "\n").replace("\r", "\n")
s = unicodedata.normalize("NFC", s)
return s.strip() # Go strings.TrimSpace strips Unicode whitespace incl. U+3000 (== Python str.strip())
def _class_counts(s: str):
cjk = other = 0
for ch in s:
if _RE_CJK.match(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk, other
def _est_tokens_from(cjk, other):
return max(cjk + other // 3, 16) # chunker.go estTokensFrom (integer // 3, floor 16)
def split_paragraphs(chapter: str):
return [p.strip() for p in chapter.split("\n\n") if p.strip()]
def _pack_sentences(paragraph: str):
segs = split_source_sentences(paragraph)
chunks, buf, bc, bo = [], [], 0, 0
def flush():
nonlocal buf, bc, bo
if buf:
chunks.append("".join(buf)); buf, bc, bo = [], 0, 0
for s in segs:
sc, so = _class_counts(s)
if buf and _est_tokens_from(bc + sc, bo + so) > TARGET_CHUNK_TOKENS:
flush()
buf.append(s); bc, bo = bc + sc, bo + so
flush()
return chunks
def greedy_chunks(raw_text: str):
"""Port of chunker.go SplitChunks for ONE chapter (S2' has no chapter markers). Returns chunk texts."""
chapter = normalize_source(raw_text)
paras = split_paragraphs(chapter)
chunks, buf, bc, bo = [], [], 0, 0
def flush():
nonlocal buf, bc, bo
if buf:
text = "\n\n".join(buf).strip()
if text:
chunks.append(text)
buf, bc, bo = [], 0, 0
for p in paras:
pc, po = _class_counts(p)
if _est_tokens_from(pc, po) > TARGET_CHUNK_TOKENS:
flush()
for sub in _pack_sentences(p):
t = sub.strip()
if t:
chunks.append(t)
continue
if buf and _est_tokens_from(bc + pc, bo + po) > TARGET_CHUNK_TOKENS:
flush()
buf.append(p); bc, bo = bc + pc, bo + po
flush()
return chunks
# --- source sentence splitter (port of chunker.go:196-357) -----------------------------------------
_OPEN_Q = set("「『(【《〈[{“‘")
_CLOSE_Q = set("」』)】》〉]}”’")
_TERM = set("。!?.!?…")
_CJK_TERM = set("。!?")
_CLOSE_BRK = set("」』】))》〉]]}\"'”’")
_ASCII_SPACE = set(" \t\n\r\f\v ")
def split_source_sentences(paragraph: str):
runes = list(paragraph)
segs = []
start = i = depth = 0
n = len(runes)
while i < n:
r = runes[i]
if r in _OPEN_Q:
depth += 1
i += 1
continue
if r in _CLOSE_Q:
if depth > 0:
depth -= 1
i += 1
continue
if r not in _TERM:
i += 1
continue
j = i
cjk = False
while j < n and runes[j] in _TERM:
if runes[j] in _CJK_TERM:
cjk = True
j += 1
depth_here = depth
e = j
while e < n and runes[e] in _CLOSE_BRK:
if runes[e] in _CLOSE_Q and depth > 0:
depth -= 1
e += 1
if cjk:
boundary = depth_here == 0
else:
boundary = depth_here == 0 and (e >= n or runes[e] in _ASCII_SPACE)
# abbrev guard omitted for zh source (CJK terminators dominate; en not in S2')
if not boundary:
i = e
continue
w = e
while w < n and runes[w] in _ASCII_SPACE:
w += 1
segs.append("".join(runes[start:w]))
start = i = w
if start < n:
segs.append("".join(runes[start:]))
return segs
# --- units: split the text into atomic sentence-ish spans that TILE it -----------------------------
def units_from_text(text: str):
"""Atomic units for the DP = source sentences. Preserves whether each unit ENDS a paragraph
(\\n\\n) or a line (\\n) for boundary classification. Tiling: ''.join(u.text) == text."""
out = []
# split into paragraph blocks on blank lines, remember the separator that followed each block
# (so paragraph-end vs line-end vs sentence-end is recoverable). We tile exactly by re-attaching
# the separators to the preceding unit.
for seg, sep in _iter_blocks(text):
sents = split_source_sentences(seg)
for k, s in enumerate(sents):
ends_para = (k == len(sents) - 1) and sep == "\n\n"
ends_line = (k == len(sents) - 1) and sep == "\n"
out.append((s, ends_para, ends_line))
# re-attach the block separator to the last unit so tiling is exact
if out and sep:
s, ep, el = out[-1]
out[-1] = (s + sep, ep, el)
return out
def _iter_blocks(text: str):
"""Yield (block_text, separator) where separator is '\\n\\n' | '\\n' | ''. Tiles the input."""
i = 0
n = len(text)
buf_start = 0
while i < n:
if text[i] == "\n":
if i + 1 < n and text[i + 1] == "\n":
yield text[buf_start:i], "\n\n"
i += 2
buf_start = i
continue
else:
yield text[buf_start:i], "\n"
i += 1
buf_start = i
continue
i += 1
if buf_start < n:
yield text[buf_start:], ""
# --- boundary classification (§B1.4) ---------------------------------------------------------------
# B1 scene-shift lexicon: STRONG time/place/POV shift markers in the first chars of the NEXT unit.
# Curated generic zh markers (NOT the S2' seam text — scene-shifts must be DISCOVERED). Kept to strong
# temporal/spatial jumps + narrative shift particles; weak mid-scene connectors (顿时/忽然) excluded.
_B1_MARKERS = [
# temporal jumps
"次日", "翌日", "第二天", "第三天", "隔日", "数日后", "数天后", "几天后", "三天后", "三天三夜",
"一个星期", "一周", "几个月", "半年", "一年后", "三年后", "五年后", "多年后",
"当天", "这一天", "那一天", "这一日", "翌朝", "清晨", "早晨", "拂晓", "五更", "天亮", "天明",
"傍晚", "黄昏", "夜里", "半夜", "深夜", "入夜", "朝阳", "红日", "夕阳", "日落", "日出", "正午",
"转眼", "转眼间", "很快", "不久", "不多时", "没过多久", "光阴荏苒", "时光飞逝",
# narrative-shift particles / POV or place switch
"却说", "再说", "话说", "单说", "且说", "与此同时", "另一边", "另一方", "一方", "此时此刻",
"回到", "镜头", "视线",
]
def _b1_scene_shift(next_unit_text: str) -> bool:
head = next_unit_text.lstrip("  \n\t")[:12]
return any(m in head for m in _B1_MARKERS)
def _dialogue_boundary_penalty(prev_unit: str, next_unit: str) -> int:
"""Penalize cutting between a reply and its attribution or inside a dialogue exchange (§A.6):
if prev ends inside/with a quote and next continues speech attribution, cutting is bad."""
p = prev_unit.rstrip("  \n\t")
nx = next_unit.lstrip("  \n\t")
pen = 0
# prev ends with a closing quote AND next starts with attribution-ish (说道/道/问/答 ...) -> keep together
if p and p[-1] in _CLOSE_Q and nx[:2] in ("说道", "", "", "问道", "答道", "喝道", "笑道"):
pen += 1
# next starts a quote while prev did not close one cleanly (mid-exchange) — mild
if nx[:1] in _OPEN_Q and p and p[-1] in _OPEN_Q:
pen += 1
return pen
# --- TextTiling cohesion valley (B2) ---------------------------------------------------------------
def _char_bigrams(text: str):
t = [c for c in text if not c.isspace()]
return [t[k] + t[k + 1] for k in range(len(t) - 1)]
def _cosine(a: dict, b: dict) -> float:
if not a or not b:
return 0.0
dot = sum(v * b.get(k, 0) for k, v in a.items())
na = math.sqrt(sum(v * v for v in a.values()))
nb = math.sqrt(sum(v * v for v in b.values()))
return dot / (na * nb) if na and nb else 0.0
def _bag(units, lo, hi):
d = {}
for u in units[lo:hi]:
for bg in _char_bigrams(u[0]):
d[bg] = d.get(bg, 0) + 1
return d
def texttiling_depths(units, w=4):
"""Depth score of the lexical-cohesion valley at each inter-unit gap (higher = stronger boundary).
Sliding window of w units either side; cosine of char-bigram bags; depth = local valley prominence."""
n = len(units)
gaps = n - 1
sims = [0.0] * gaps
for g in range(gaps):
left = _bag(units, max(0, g + 1 - w), g + 1)
right = _bag(units, g + 1, min(n, g + 1 + w))
sims[g] = _cosine(left, right)
# depth score: for each gap, (peak_left - sim) + (peak_right - sim)
depths = [0.0] * gaps
for g in range(gaps):
lpk = sims[g]
k = g
while k > 0 and sims[k - 1] >= sims[k]:
k -= 1
lpk = max(lpk, sims[k])
rpk = sims[g]
k = g
while k < gaps - 1 and sims[k + 1] >= sims[k]:
k += 1
rpk = max(rpk, sims[k])
depths[g] = (lpk - sims[g]) + (rpk - sims[g])
return depths
# --- §B1.5 DP logical chunker ----------------------------------------------------------------------
@dataclass
class ChunkPlan:
chunks: list # list of (text, est_out, right_boundary_class)
cut_offsets: list # rune offsets in the ORIGINAL text where a cut falls (chunk starts, excl 0)
# boundary-class penalty (§B1.5): B0=0 < B1 < B2 < B3 < B4 (prefer cutting at strong boundaries)
_CLASS_PEN = {"B0": 0, "B1": 1, "B2": 3, "B3": 6, "B4": 10}
def logical_chunks(text: str, target_out: float, hard_max_out: float, min_out: float = None,
w_size=3, w_bound=60, w_dialog=200, b2_z=0.8,
corridor_lo=0.62, corridor_hi=1.12) -> ChunkPlan:
"""§B1.5 DP: minimize Σ [w_size·size_pen + w_bound·class_pen(right boundary) + w_dialog·dialogue].
Cuts only at unit (sentence) boundaries — never mid-sentence (B5 forbidden). Costs INTEGER-scaled.
target_out/hard_max_out = A0's actual est_out mean/max (size-match)."""
units = units_from_text(text)
n = len(units)
if min_out is None:
min_out = 0.45 * target_out
# per-unit est_out and cumulative rune offsets
out_of = [est_out(u[0]) for u in units]
cum_out = [0.0] * (n + 1)
cum_rune = [0] * (n + 1)
for k in range(n):
cum_out[k + 1] = cum_out[k] + out_of[k]
cum_rune[k + 1] = cum_rune[k] + len(units[k][0])
# boundary class at each inter-unit gap g (cut AFTER unit g, i.e. between unit g and g+1)
depths = texttiling_depths(units)
if depths:
mu = sum(depths) / len(depths)
sd = math.sqrt(sum((d - mu) ** 2 for d in depths) / len(depths)) or 1e-9
gap_class = []
gap_dialog = []
for g in range(n - 1):
cls = "B4" # default: sentence boundary
if units[g][2] or units[g][1]:
cls = "B3" # ends a line/paragraph
if depths and (depths[g] - mu) / sd >= b2_z:
cls = "B2" # TextTiling cohesion valley
if _b1_scene_shift(units[g + 1][0]):
cls = "B1" # scene-shift lexicon in next unit
gap_class.append(cls)
gap_dialog.append(_dialogue_boundary_penalty(units[g][0], units[g + 1][0]))
lo_band, hi_band = corridor_lo * target_out, corridor_hi * target_out
def size_pen(a, b):
"""Corridor size penalty (§B1.5 intent: size defines a BUDGET CORRIDOR; boundary class decides
WITHIN it). Flat (0) inside [lo_band, hi_band]; steep outside so chunks stay near target size."""
o = cum_out[b] - cum_out[a]
if o > hard_max_out:
return 100000 + int(40 * (o - hard_max_out)) # hard ceiling — never exceed
if o > hi_band:
return int(30 * (o - hi_band)) # over corridor
if o < lo_band:
return int(30 * (lo_band - o)) # under corridor
return 0 # inside corridor: size is free, boundary decides
INF = float("inf")
# dp[b] = min cost to segment units[0:b]; back[b] = previous cut index a
dp = [INF] * (n + 1)
back = [0] * (n + 1)
dp[0] = 0
for b in range(1, n + 1):
# try all chunk starts a < b (bounded to a window where size is plausible)
a = b - 1
while a >= 0:
o = cum_out[b] - cum_out[a]
if o > hard_max_out * 1.6 and a < b - 1:
break # window: chunks far over hardMax are never optimal
cost = dp[a] + w_size * size_pen(a, b)
if b < n: # boundary penalty on the RIGHT cut (gap b-1)
cost += w_bound * _CLASS_PEN[gap_class[b - 1]] + w_dialog * gap_dialog[b - 1]
if cost < dp[b]:
dp[b] = cost
back[b] = a
a -= 1
# reconstruct
cuts = []
b = n
while b > 0:
a = back[b]
cuts.append((a, b))
b = a
cuts.reverse()
chunks = []
cut_offsets = []
for (a, b) in cuts:
txt = "".join(units[k][0] for k in range(a, b))
rcls = gap_class[b - 1] if b < n else "B0"
chunks.append((txt, est_out(txt), rcls))
if a > 0:
cut_offsets.append(cum_rune[a])
return ChunkPlan(chunks=chunks, cut_offsets=cut_offsets)
if __name__ == "__main__":
if "--selftest" in sys.argv:
text = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt").read_text(encoding="utf-8")
units = units_from_text(text)
tiled = "".join(u[0] for u in units)
print("units:", len(units), "tiling lossless:", tiled == text)
assert tiled == text, "UNIT TILING NOT LOSSLESS"
total_out = est_out(text)
print(f"S2' total est_out={total_out:.0f} est_tokens={est_tokens(text)}")
# size sweep: how many logical chunks at a few targets
for tgt in (1500, 1797, 2200):
plan = logical_chunks(text, target_out=tgt, hard_max_out=tgt * 1.25)
outs = [c[1] for c in plan.chunks]
classes = [c[2] for c in plan.chunks[:-1]] # right-boundary classes (excl last)
from collections import Counter
print(f" target_out={tgt}: {len(plan.chunks)} chunks "
f"mean_out={sum(outs)/len(outs):.0f} min={min(outs):.0f} max={max(outs):.0f} "
f"cut-classes={dict(Counter(classes))}")
print("chunker selftest OK")