File size: 35,027 Bytes
e324ffa 4695f51 e324ffa 4695f51 e324ffa | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 | """Shared MorphyNet morpheme-tokenizer core (v0.1).
Single source of truth, imported by build/analysis scripts (DRY + reproducible).
Keys / V_A = supplement (function words) + MorphyNet inflectional forms + known_vocab (incl. derivational
words AS ATOMIC). Derivational *splits* are deferred (#2); derivational words still count as recognizable
(otherwise -ly adverbs like `happily`/`quickly`, which are derivation-only, would become <UNK>).
"""
import csv, os, re, sys
from collections import Counter
from pathlib import Path
MIN_RESIDUAL = 3
KEY_CUTOFF = 160_000 # operating point: use the top-160k MorphyNet keys by wordfreq (raw morphynet files untouched)
OOV_SUFFIXES = [("ing", "V"), ("ies", "N"), ("es", "N"), ("ed", "V"), ("s", "N")] # longest-first; 's via contraction()
WORD_RE = re.compile(r"[^\W\d_]+(?:['-][^\W\d_]+)*|\d+") # Unicode letters (keep accents) + internal '/-; OR a number
CLEAN_MORPH = re.compile(r"^[a-z]+$")
PRON_WH_S = {"he", "she", "that", "what", "there", "who", "where"} # + 's -> base is
NT_IRREGULAR = {"won't": ["will", "n't"], "can't": ["can", "n't"], "shan't": ["shall", "n't"], "ain't": ["ain't"]}
SPECIAL = {"let's": ["let", "us"]}
def contraction(word):
if "'" not in word:
return None
if word in SPECIAL:
return SPECIAL[word]
if word in ("it's", "why's", "how's"): # ambiguous is/has -> keep whole
return [word]
if word.endswith("'d"): # all 'd ambiguous had/would -> whole
return [word]
if word.endswith("n't"):
# Keep `n't` as a CLITIC morpheme, not the free word `not` -- same treatment as the `'s` clitic below.
# Expanding to `not` scrambles word order under subject-aux inversion (`don't they -> do not they`,
# a string that never occurs in English), and the model learns from the linear order. `n't` is a
# distinct bound allomorph of negation, so it earns its own morpheme. (`won't -> will n't` etc. keep
# the recovered base via NT_IRREGULAR, avoiding PTB's `wo n't`/`ca n't`.)
if word in NT_IRREGULAR:
return NT_IRREGULAR[word]
b = word[:-3]
return [b, "n't"] if b else None
for cl, exp in (("'re", "are"), ("'ve", "have"), ("'ll", "will")):
if word.endswith(cl):
b = word[:-3]
return [b, exp] if b else None
if word.endswith("'m"):
b = word[:-2]
return [b, "am"] if b else None
if word.endswith("'s"):
b = word[:-2]
if not b:
return None
return [b, "is"] if b in PRON_WH_S else [b, "'s"] # he's->he is ; dog's->dog 's
if word.endswith("s'") and len(word) > 2:
# PLURAL POSSESSIVE: `skateboards'` -> `skateboards` + `'s`. The trailing `'` is the genitive on a
# plural noun; drop it, keep the `s`, emit the same `'s` clitic as the singular. The base flows
# through analyze() (via the base-recursion above), so `skateboards' -> skate board s 's`. Restricted
# to `s'` so it catches plurals (dogs', teachers') and s-ending singulars (James') but NOT g-dropping
# elisions, which end in other letters (goin', nothin', ol').
return [word[:-1], "'s"]
return None
# ---------------------------------------------------------------------------------------------------------
# THE LOADED MORPHEME TABLES. resources/morphemes/ holds EXACTLY the files the tokenizer reads -- nothing
# else lives there. Everything under resources/ proper is a work-in-progress, a generator input, or raw
# detector output, and is NOT loaded. If you can see it in morphemes/, it is live.
#
# Loaded IN ORDER; a LATER file overrides an earlier one, and every conflict is logged loudly.
# Override the whole list with $BABYLM_SUPPLEMENTS (colon-separated) or the supplement_path argument.
#
# NEVER PUT A `_proposed` / `_candidates` FILE IN HERE. Those are raw detector output. Only files a human has
# signed off belong in morphemes/. `phantom_fixes_proposed.tsv` (625 auto-repairs; the file is now called
# base_forms_round2_proposed.tsv, and is still NOT loaded) was loaded from
# 2026-07-11 to 2026-07-12 BY MISTAKE -- and, worse, the hand-reviewed phantom_verified.tsv was NOT loaded at
# the time, so the machine's guesses were live while the human's decisions sat on the shelf. guard_output()
# now refuses to let any script write into this list.
# ---------------------------------------------------------------------------------------------------------
MORPHEMES = "resources/morphemes"
DEFAULT_SUPPLEMENTS = (
# DERIVATION loads FIRST, on purpose: it is the most GENERIC source (MorphyNet's rows, gated by the
# researcher's handmade vocab), so every hand-curated file below overrides it. Load it LAST instead and a
# single stray row silently beats a deliberate decision -- during the merge it briefly did exactly that,
# turning `anyone` into `any one`, `dryer` into `dry er`, `potter` into `pot er`.
f"{MORPHEMES}/derivation_verified.tsv", # 2,804 derivational splits (tokenizer-todos #3). Each row was
# approved by resources/handmade_vocab.tsv and then hand-curated.
f"{MORPHEMES}/derivation_verified2.tsv", # 7,618 more: the frequency-ranked shortlist of MorphyNet rows
# the handmade vocab had no verdict on, audited in two rounds
# (95% usable) and then hand-corrected. Loads with round 1, and
# is likewise overridden by every hand-curated file below.
f"{MORPHEMES}/additional_words_agents.tsv", # 541 agent-generated inflectional/derivational splits
# (`heard -> hear ed`, `words -> word s`), reviewed by the
# researcher. Loads EARLY (generic backfill) so every specific
# hand-curated file below still overrides it on any conflict.
f"{MORPHEMES}/morphynet_gap_fills_quite_sure.tsv", # 1332 DECOMPOSED gap-fills from full MorphyNet
# (babylm/eval words OOV at top-160k but recognized at full
# vocab), HAND-REVIEWED by a 14-subagent pass (110 fixed, 95 made
# atomic). Generic backfill -> loads EARLY, overridable.
f"{MORPHEMES}/morph_supplement.tsv",
f"{MORPHEMES}/demonyms.tsv", # place + "an" demonyms the suffix rule mis-split or missed:
# mallorcan -> mallorca an, croatian -> croatia an. The base
# place then decomposes further (croat ia an) -- intended, to
# maximize sharing.
f"{MORPHEMES}/names.tsv",
f"{MORPHEMES}/names_male.tsv",
f"{MORPHEMES}/names_female.tsv",
f"{MORPHEMES}/places.tsv",
f"{MORPHEMES}/name_top55_verified.tsv", # 47 verified names + phœbe->phoebe, urler->ursula.
# Its 6 corpus ARTIFACTS (cou, ö, nköö, i'ii, alphahff, iím) are
# deliberately left as COMMENTS so they can never be loaded.
f"{MORPHEMES}/superlatives.tsv",
f"{MORPHEMES}/non-english.tsv",
f"{MORPHEMES}/run_together.tsv",
f"{MORPHEMES}/elision_verified.tsv",
f"{MORPHEMES}/syllable_hyphen_verified.tsv",
f"{MORPHEMES}/hyphen_keep_whole.tsv", # hyphenated words kept whole / normalized, overriding the
# hyphen-split rule. Only the lexicalized/idiomatic/proper ones
# are here; every other hyphenated word splits on its hyphens.
f"{MORPHEMES}/phantom_verified.tsv", # phantom stems REVIEWED BY HAND from corpus passages (#7)
f"{MORPHEMES}/base_forms_verified.tsv", # BASE forms whose inflected form was already covered but which
# were themselves atomic or <UNK>: `besiegers -> besiege er s`
# was handled while `besieger` was <UNK>. Proposed by
# find_missing_base_forms.py, then reviewed by hand. Loads AFTER
# phantom_verified because it builds on those repairs.
f"{MORPHEMES}/ghost_stem_fixes.tsv", # 261 rows. MorphyNet's inflection table lemmatises with an
# ORTHOGRAPHIC GHOST -- an Early Modern spelling that occurs in
# our Gutenberg text, so the phantom detector's `stem in CORPUS`
# test called it a real word and never looked: `seemed ->
# seeme ed` (1,892 tokens), `waiting -> waite ing` (1,100),
# `helped -> helpe ed` (740). Presence cannot refute a phantom;
# only presence in PROPORTION can (seeme 1x vs seemed 1,892x).
f"{MORPHEMES}/atomic_corrections.tsv", # 433 INFLECTIONAL splits whose stem is not a word:
# `number -> numb er`, `seemed -> seeme ed` (an Early Modern
# spelling that occurs in our Gutenberg text, which is exactly
# why the phantom detector had been blind to it). Proposed by
# propose_infl_atomic.py, then reviewed by 28 agents over every
# row, each finding re-checked by a second agent told to refute
# it. Fixes 91% of the inflection bug mass.
f"{MORPHEMES}/archaic_verbs.tsv", # Early Modern verb agreement from the Gutenberg slice, which
# MorphyNet does not cover at all: `cometh -> come eth`,
# `hast -> have st`. Finishes a convention already in use
# (`hath -> have th`, `shalt -> shall t`). 385 corpus tokens
# were <UNK>. Names in -eth (kenneth, gwyneth) excluded by
# cap-ratio; the silent e is restored (`ride eth`, not `rid eth`).
f"{MORPHEMES}/more_fixes.tsv", # ZERO-CHANGE IRREGULARS. The past tense of `shed` IS `shed`,
# but MorphyNet writes the segmentation `shed|ed` -- a suffix
# that is not in the string. We key INFL by FORM, so EVERY
# `shed` (the garden shed included) came out as `shed ed`, and
# it corrupted `toolshed -> tool shed` into `tool shed ed` via
# recursive expansion. 176 corpus tokens. See §11 of
# docs/why-morphynet-needs-curation.md. The ROOT fix belongs in
# load_resources(): reject any row where lemma == form and the
# segmentation still splits. This file is the interim patch.
f"{MORPHEMES}/nonce.tsv", # wug / fep / blicket / dax / wampimuk. ATOMIC BY DESIGN, and
# loaded precisely BECAUSE they never occur in the corpus: the
# wug test measures generalisation to an unseen word, so if the
# tokenizer shatters `wug` into <UNK> or letter-pieces the
# benchmark stops measuring what it is for. (blicket/fep/wug are
# 40% of the whole eval-vs-corpus gap by token count -- comps
# repeats them 55,584 times each.)
f"{MORPHEMES}/posessives.tsv", # `beverly's -> beverly 's`. The BLiMP vocabulary gap is mostly
# this: 20+ of its 78 out-of-corpus words are a first name +
# `'s`. Needs a general rule eventually -- these are hand-listed.
f"{MORPHEMES}/linker_exceptions.tsv", # words kept WHOLE because dropping the LINKER would collide:
# `woodsy` would become `wood y` = woody. 6 measured collisions
# + the system family. Loads LATE so it overrides derivation.
f"{MORPHEMES}/inflections.tsv", # 259 regular inflections MorphyNet's INFL table missed, so the
# tokenizer kept them whole (`girls`, `allowed`, `covered`). The
# SAFE backfill for under-split Finding 1 -- explicit entries, not
# a blanket known-word peel (which would mis-split news/loss).
# Built by scripts/build_inflections_tsv.py from the audit.
f"{MORPHEMES}/compounds.tsv", # transparent compounds/prefixed words left atomic that should
# split: `birthday -> birth day`, `impossible -> in possible`,
# `preschool -> pre school`. Under-split Finding 3.
f"{MORPHEMES}/goal1_derivations.tsv", # productive, POS-gated MorphyNet derivations + `-men` plurals
# (`happiness -> happy ness`, `teacher -> teach er`, `horsemen ->
# horse man s`), hand-reviewed. From the overnight morpheme-
# reduction probe (scripts/goal1_reduction_probe.py). ~-200 morphemes.
f"{MORPHEMES}/british-to-american.tsv", # British->American spelling normalization, ISOLATED here so it
# can be toggled on/off by including/excluding this one file.
# RHS is the AMERICAN spelling as a WHOLE WORD (`analogue ->
# analog`, `equaliser -> equalizer`); _expand_supplement
# re-analyzes it, so `equalizer` chains to `equal ize er` and
# `analog` resolves to the corpus atom. NO other tsv may bake in
# normalization (no `discolour -> dis color`). See backlog #7.
# Loads LATE so the respelling overrides any split of the British
# form. Only 2 rows now; populate over time.
f"{MORPHEMES}/mojibake-fixes.tsv", # the `í` (U+00ED) corruption: contraction mojibake -> apostrophe
# form (`itís -> it's`), and accented names + letter-i mojibake ->
# ASCII (`rodríguez -> rodriguez`, `hís -> his`). Built by
# scripts/build_mojibake_fixes.py. See Goal-3 findings.
f"{MORPHEMES}/typos.tsv",
f"{MORPHEMES}/men_compounds.tsv", # `-men`/`-man` compounds: keep `men` whole (X men, the suppletive
# plural), NOT `X man s`. Singulars are `X man`. See §-men in
# docs/tokenizer-design-decisions and men_compounds header.
f"{MORPHEMES}/oov_rare_words_approved.tsv", # REVIEWED subset of proposals/oov_rare_words.tsv -- only the
# hand-decided `word -> morphemes` entries, verified against
# babylm+eval usage. See docs/oov-rare-words-approved.md.
f"{MORPHEMES}/ambiguous.tsv", # words that CANNOT be split because the split is genuinely
# ambiguous: `leaves` = leaf+s (noun) OR leave+s (verb), so forcing
# either reading throws away information -> keep atomic. Bare-word
# format (word alone = atomic). Loads LAST so "keep whole" overrides
# any earlier split (e.g. INFL's `leaves -> leave s`). SUPPLEMENT
# also beats INFL in analyze(), so the atomic entry always wins.
)
def guard_output(path):
"""Refuse to write a HUMAN-OWNED file. Call before EVERY write under resources/.
Two invariants, both learned by breaking them:
1. NEVER write a `*_verified.tsv`. That suffix means a human signed it off. A script that regenerates
one silently destroys hours of hand review -- and the researcher cannot tell, because the file is
still there and still looks plausible.
2. NEVER write a file that is in DEFAULT_SUPPLEMENTS. Same reason: it is live in the tokenizer.
LAYOUT + NAMING CONTRACT enforced here:
resources/morphemes/*.tsv LOADED. Human-owned. NEVER machine-written.
resources/*_candidates.tsv machine-written, never loaded, safe to regenerate
resources/*_proposed.tsv machine-written, never loaded, safe to regenerate
*_verified.tsv human-signed-off; never machine-written, wherever it lives
Returns the path so it can be used inline: `with open(mt.guard_output(OUT), "w") as f:`
"""
p = Path(path)
if p.name.endswith("_verified.tsv"):
raise RuntimeError(
f"REFUSING to write {p.name}: the `_verified` suffix means a human signed it off.\n"
f"Write to a `_proposed` / `_candidates` name instead; promote it to `_verified` only by hand."
)
if any(Path(s).name == p.name for s in DEFAULT_SUPPLEMENTS):
raise RuntimeError(
f"REFUSING to write {p.name}: it is LOADED by the tokenizer (DEFAULT_SUPPLEMENTS).\n"
f"Write to a `_proposed` / `_candidates` name instead."
)
return p
def _supplement_paths(repo, supplement_path):
"""Resolve which supplement TSVs to load: explicit arg > $BABYLM_SUPPLEMENTS (colon-separated) > default."""
if supplement_path is not None:
paths = [supplement_path] if isinstance(supplement_path, (str, Path)) else list(supplement_path)
else:
env = os.environ.get("BABYLM_SUPPLEMENTS")
paths = env.split(":") if env else list(DEFAULT_SUPPLEMENTS)
out = []
for p in paths:
p = Path(p)
out.append(p if p.is_absolute() else repo / p)
return out
def load_resources(repo, supplement_path=None):
# Shipped fast path: fully-resolved dict (no wordfreq dependency, no TSV parsing).
_resolved = Path(repo) / "resources_resolved.json"
if _resolved.exists():
import json as _json
_r = _json.loads(_resolved.read_text(encoding="utf-8"))
return {_k: set(_v) if isinstance(_v, list) else _v for _k, _v in _r.items()}
"""Load supplement TSV(s) + MorphyNet inflection/known.
supplement_path may be one path or a list. Multiple files are merged in order; a LATER file overrides an
earlier one, and any conflicting key is reported loudly (never silently resolved).
FILE FORMAT — fields are whitespace-separated (a tab is conventional, spaces also work):
word morph1 morph2 ... -> `word` analyses to those morphemes. e.g. `on-ly only`
word -> ATOMIC: `word` analyses to itself.
word word -> IDENTICAL to the bare form above; both give val == [word].
So the two atomic conventions are interchangeable — use whichever you prefer.
CAVEAT: the KEY is lowercased, the VALUE is not. Since the corpus is lowercased at tokenization, an
uppercase value (`sarah Sarah`) would create a morpheme that matches nothing. Keep values lowercase.
`#` starts a comment; everything after it on the line is ignored.
"""
repo = Path(repo)
supplement, origin, conflicts = {}, {}, []
for supp_path in _supplement_paths(repo, supplement_path):
for line in open(supp_path, encoding="utf-8"):
line = line.split("#", 1)[0].strip()
if not line:
continue
p = line.split()
key = p[0].lower()
val = p[1:] if len(p) > 1 else [key]
if key in supplement and supplement[key] != val:
conflicts.append((key, origin[key], supplement[key], supp_path.name, val))
supplement[key] = val
origin[key] = supp_path.name
infl, known = {}, set()
# A form can carry SEVERAL rows with DIFFERENT lemmas -- `helped` is offered as both `help|ed` and the
# Early Modern `helpe|ed`. The old code did infl.setdefault(form, ...), i.e. FIRST ROW IN FILE ORDER wins,
# which is an accident: `helpe` precedes `help`, so `helped -> helpe ed` and the correct row was lost.
# Collect every candidate, then pick the one whose LEMMA is the most frequent ENGLISH word (wordfreq).
# wordfreq, not corpus count: corpus count picks `well` over `good` for `better` and so drifts with the
# training data; wordfreq is external and stable. First-seen is the tie-break, so the 987k forms with no
# competitor are unaffected. Only ~988 forms change. See section 8 of docs/why-morphynet-needs-curation.md.
infl_cands: dict[str, list] = {}
for r in csv.reader(open(repo / "data" / "morphynet" / "eng.inflectional.v1.tsv", encoding="utf-8"), delimiter="\t"):
if len(r) < 4:
continue
lemma, form, seg = r[0].lower(), r[1].lower(), r[3].lower()
known.add(lemma); known.add(form)
if seg == "-":
continue
pieces = seg.split("|")
if all(CLEAN_MORPH.match(p) for p in pieces):
infl_cands.setdefault(form, []).append((lemma, pieces))
for form, cands in infl_cands.items():
if len(cands) == 1 or len({tuple(p) for _, p in cands}) == 1:
infl[form] = cands[0][1] # no genuine disagreement -> nothing to rank
else:
infl[form] = max(cands, key=lambda lp: _wordfreq(lp[0]))[1]
for r in csv.reader(open(repo / "data" / "morphynet" / "eng.derivational.v1.tsv", encoding="utf-8"), delimiter="\t"):
if len(r) >= 6:
known.add(r[0].lower()); known.add(r[1].lower())
res = {"SUPPLEMENT": supplement, "INFL": infl, "known": known}
_report_conflicts(conflicts, res)
return res
def _report_conflicts(conflicts, res):
"""Warn only about conflicts that actually CHANGE THE OUTPUT.
Supplement values are RECURSIVELY EXPANDED (see _expand_supplement), so two files can write the same
answer two different ways and be identical in effect:
morph_supplement.tsv caretaking care taking -> expands to ['care', 'take', 'ing']
phantom_verified.tsv caretaking care take ing -> expands to ['care', 'take', 'ing']
Comparing the RAW values calls that a conflict and sends the researcher hunting for a problem that does
not exist. Compare what the tokenizer actually emits instead, and stay quiet when it is the same.
"""
real = []
for k, f1, v1, f2, v2 in conflicts:
if _expand_supplement(k, v1, res, 0) != _expand_supplement(k, v2, res, 0):
real.append((k, f1, v1, f2, v2))
benign = len(conflicts) - len(real)
if real:
print(f"WARNING: {len(real)} conflicting supplement key(s) that CHANGE THE OUTPUT; later file wins:",
file=sys.stderr)
for k, f1, v1, f2, v2 in real[:20]:
print(f" {k}: {f1}={v1} -> {f2}={v2}", file=sys.stderr)
if benign:
print(f"({benign} further duplicate key(s) write the same answer a different way — no effect.)",
file=sys.stderr)
def rank_keys(known):
"""Rank known_vocab keys by general-English frequency (wordfreq), most-frequent first."""
import importlib as _il; word_frequency = _il.import_module("wordfreq").word_frequency # hidden from HF check_imports; never executed (resolved-JSON fast path)
return sorted(known, key=lambda w: (-word_frequency(w, "en"), w))
def truncate_to_topk(res, top_k=KEY_CUTOFF, ranked=None):
"""Restrict known_vocab + INFL to the top_k most-frequent keys. top_k=None keeps all. Raw morphynet files untouched.
Pass a precomputed `ranked` (from rank_keys) to avoid re-ranking across repeated calls (e.g. the coverage curve).
"""
if ranked is None:
ranked = rank_keys(res["known"])
kept = set(ranked) if top_k is None else set(ranked[:top_k])
return {"SUPPLEMENT": res["SUPPLEMENT"],
"INFL": {f: p for f, p in res["INFL"].items() if f in kept},
"known": kept}
def peel_oov(word):
for suf, cls in OOV_SUFFIXES:
if word.endswith(suf) and len(word) - len(suf) >= MIN_RESIDUAL:
return word[: -len(suf)], suf, cls
return None
# --- inflectional stem restoration (NOT derivation) -------------------------------------------------
# Peeling `-ies`/`-ed`/`-ing` off an OOV word usually leaves a non-word (`opportunit`, `carv`, `runn`).
# Restore the real stem orthographically. Suffixes are normalised to reusable morphemes (ies/es -> s).
SUFFIX_MORPH = {"ies": "s", "es": "s", "s": "s", "ed": "ed", "ing": "ing"}
def _stem_candidates(stem, suf):
"""Orthographic ways the surface stem could map back to a real word."""
if suf in ("ing", "ed"):
c = [stem, stem + "e"] # walk / carve
if len(stem) >= 3 and stem[-1] == stem[-2] and stem[-1] not in "aeiou":
c.append(stem[:-1]) # runn -> run (consonant doubling)
return c
if suf == "ies":
return [stem + "y", stem + "ie"] # opportunit -> opportunity ; mov -> movie
if suf == "es":
return [stem, stem + "e"] # box ; witness
return [stem]
_WF = None # injectable for tests; lazily bound to wordfreq.word_frequency
def _wordfreq(w):
global _WF
if _WF is None:
import importlib as _il; word_frequency = _il.import_module("wordfreq").word_frequency # hidden from HF check_imports; never executed (resolved-JSON fast path)
_WF = word_frequency
return _WF(w, "en")
def restore_stem(stem, suf, res):
"""Return the unique real stem, or None if ZERO or MORE THAN ONE candidate is real.
Ambiguity => None => the word is left alone (principle 3). `hoping` has both `hop` and `hope` as real
words, so it is deliberately NOT peeled. wordfreq (not the dictionary) decides what is 'real', because
MorphyNet's known-vocab contains phantoms like `walke`/`talke` that would otherwise win.
"""
cands = [c for c in dict.fromkeys(_stem_candidates(stem, suf)) if c in res["known"]]
strong = [c for c in cands if _wordfreq(c) >= 1e-6]
if len(strong) == 1:
return strong[0]
if len(strong) > 1:
return None # genuinely ambiguous -> keep atomic
return cands[0] if len(cands) == 1 else None # rare-but-real stem, unique
MAX_SUPP_DEPTH = 5 # guard 3: bounds cycles (a->b, b->a) in supplement values
UNEXPANDED = {} # supplement values that could not be fully analysed -> reported loudly, never silent
def _expand_supplement(word, pieces, res, _depth):
"""Expand a supplement value through analyze(), so values may be written as WORDS, not pre-split morphemes.
Guards: (1) self-reference (`sarah sarah`) never recurses; (2) a piece that cannot be analysed is kept
literal AND recorded in UNEXPANDED rather than degrading into <UNK>; (3) depth cap bounds cycles.
"""
if _depth >= MAX_SUPP_DEPTH:
return list(pieces)
out = []
for p in pieces:
if p == word: # guard 1
out.append(p)
continue
sub, _ = analyze(p, res, _depth + 1)
if any(x.startswith("<UNK") for x in sub): # guard 2
UNEXPANDED[(word, p)] = True
out.append(p)
else:
out.extend(sub)
return out
def analyze(word, res, _depth=0):
"""Return (morphemes, bucket)."""
if word.isdigit():
return list(word), "number" # 2026 -> ['2','0','2','6'] (digit-split; revisitable)
if word in res["SUPPLEMENT"]:
return _expand_supplement(word, res["SUPPLEMENT"][word], res, _depth), "supplement"
c = contraction(word)
if c is not None:
# contraction() is a pure string function with no access to `res`, so it hands back the BASE as a
# raw string (`skateboard's` -> ['skateboard', "'s"]). Re-analyze that base so it decomposes like it
# would anywhere else -> ['skate', 'board', "'s"]. Recurse ONLY the base (c[0]); the clitic tail
# (`'s`, `is`, `n't`, `are`, ...) is already a final morpheme and must be kept verbatim -- analysing
# `'s` on its own yields <UNK>. Guard c[0] != word so the kept-whole cases (`it's`, `i'd`), which
# return a single-element [word], never recurse into themselves.
if len(c) > 1 and c[0] != word:
base, _ = analyze(c[0], res, _depth + 1)
return base + c[1:], "contraction"
return c, "contraction"
if word in res["INFL"]:
return res["INFL"][word], "inflection"
if "-" in word:
# Split a hyphenated word when every part is itself covered -> reusable, meaningful, unambiguous.
# This runs BEFORE the `known` check ON PURPOSE: MorphyNet's dictionary catalogs thousands of
# hyphenated compounds as whole words (cable-car, non-stick, well-known), so with `known` first,
# whether a compound split depended on the ACCIDENT of MorphyNet listing it. The lexicalized ones
# that must stay whole (band-aid, hip-hop, blue-collar) are held atomic by hyphen_keep_whole.tsv /
# syllable_hyphen_verified.tsv, which are SUPPLEMENTS and so already win above this point.
parts = [p for p in word.split("-") if p]
if len(parts) >= 2:
out = []
for part in parts:
sub, _ = analyze(part, res, _depth + 1)
if any(x.startswith("<UNK") for x in sub):
out = None
break
out.extend(sub)
if out:
return out, "hyphen_split"
if word in res["known"]:
return [word], "atomic_known"
p = peel_oov(word)
if p:
stem, suf, cls = p
# restore_stem also covers the plain `stem in known` case, and rejects ambiguous peels (`hoping`).
restored = restore_stem(stem, suf, res) # opportunit + ies -> opportunity + s
if restored:
sub, _ = analyze(restored, res, _depth + 1)
return sub + [SUFFIX_MORPH.get(suf, suf)], "inflection"
return [f"<UNK_{cls}:{stem}>", suf], "oov_affix"
return [f"<UNK:{word}>"], "atomic_unk"
# ---------------------------------------------------------------------------
# CORPUS PREPROCESSING (a DATA concern, deliberately NOT part of analyze()).
# Emulates what the training-ingest script will eventually do. Set BABYLM_RAW=1 to disable and read raw lines.
# ---------------------------------------------------------------------------
CURLY = str.maketrans({"’": "'", "‘": "'", "ʼ": "'"}) # typographic apostrophes -> ASCII
HEADER_RE = re.compile(r"^= = = .* = = =\s*$") # BabyLM document separator: `= = = childes/.../x.cha = = =`
TIER_RE = re.compile(r"^%[a-z]+:") # CHILDES dependent tiers: %int:, %add: (annotation, not speech)
SPK_RE = re.compile(r"^\*[A-Za-z]{2,5}:[ \t]*") # CHILDES speaker tag: strip prefix, KEEP the utterance
def clean_line(line):
"""Return the cleaned line, or None if the line should be skipped entirely.
- skip `= = = ... = = =` document headers and `%tier:` annotation lines (pure metadata)
- strip the leading `*SPK:` speaker tag but keep the utterance (the speech is on that line)
- normalise typographic apostrophes to ASCII `'` so contractions survive tokenization
"""
if HEADER_RE.match(line) or TIER_RE.match(line):
return None
return SPK_RE.sub("", line, count=1).translate(CURLY)
def _lines(fp):
raw = os.environ.get("BABYLM_RAW") == "1"
for line in open(fp, encoding="utf-8"):
if raw:
yield line
continue
cleaned = clean_line(line)
if cleaned is not None:
yield cleaned
def count_corpus(corpus_dir):
"""Return (CORPUS_FREQ Counter, {domain: Counter})."""
cf, df = Counter(), {}
for fp in sorted(Path(corpus_dir).glob("*.train.txt")):
c = Counter()
for line in _lines(fp):
c.update(WORD_RE.findall(line.lower()))
df[fp.name.split(".")[0]] = c
cf.update(c)
return cf, df
CASED_WORD_RE = re.compile(r"[^\W\d_]+") # case-PRESERVING; WORD_RE is used on lowercased text
def count_corpus_cased(corpus_dir):
"""Return (capitalised Counter, lowercase Counter), both keyed by the LOWERCASED word.
A proper name stays capitalised mid-sentence, so a high cap-ratio is the cheapest reliable name signal.
Needed because count_corpus() lowercases and thus destroys it.
"""
cap, low = Counter(), Counter()
for fp in sorted(Path(corpus_dir).glob("*.train.txt")):
for line in _lines(fp):
for w in CASED_WORD_RE.findall(line):
(cap if w[0].isupper() else low)[w.lower()] += 1
return cap, low
def is_probable_name(word, cap, low, min_tokens=20, ratio=0.75):
"""True if `word` is capitalised at least `ratio` of the time -- i.e. it is a proper name.
Guards the morphological repair scripts, which otherwise happily produce
`holmes -> holm s` (Sherlock), `venus -> venue s`, `torres -> tor s`.
"""
n = cap[word] + low[word]
return n >= min_tokens and cap[word] / n >= ratio
def stream_corpus(corpus_dir):
"""Yield corpus words in order (for chunk-level analysis)."""
for fp in sorted(Path(corpus_dir).glob("*.train.txt")):
for line in _lines(fp):
for w in WORD_RE.findall(line.lower()):
yield w
|