| """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 |
| OOV_SUFFIXES = [("ing", "V"), ("ies", "N"), ("es", "N"), ("ed", "V"), ("s", "N")] |
| WORD_RE = re.compile(r"[^\W\d_]+(?:['-][^\W\d_]+)*|\d+") |
| CLEAN_MORPH = re.compile(r"^[a-z]+$") |
| PRON_WH_S = {"he", "she", "that", "what", "there", "who", "where"} |
| 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"): |
| return [word] |
| if word.endswith("'d"): |
| return [word] |
| if word.endswith("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"] |
| if word.endswith("s'") and len(word) > 2: |
| |
| |
| |
| |
| |
| return [word[:-1], "'s"] |
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MORPHEMES = "resources/morphemes" |
|
|
| DEFAULT_SUPPLEMENTS = ( |
| |
| |
| |
| |
| f"{MORPHEMES}/derivation_verified.tsv", |
| |
| f"{MORPHEMES}/derivation_verified2.tsv", |
| |
| |
| |
| f"{MORPHEMES}/additional_words_agents.tsv", |
| |
| |
| |
| f"{MORPHEMES}/morphynet_gap_fills_quite_sure.tsv", |
| |
| |
| |
| f"{MORPHEMES}/morph_supplement.tsv", |
| f"{MORPHEMES}/demonyms.tsv", |
| |
| |
| |
| f"{MORPHEMES}/names.tsv", |
| f"{MORPHEMES}/names_male.tsv", |
| f"{MORPHEMES}/names_female.tsv", |
| f"{MORPHEMES}/places.tsv", |
| f"{MORPHEMES}/name_top55_verified.tsv", |
| |
| |
| 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", |
| |
| |
| f"{MORPHEMES}/phantom_verified.tsv", |
| f"{MORPHEMES}/base_forms_verified.tsv", |
| |
| |
| |
| |
| f"{MORPHEMES}/ghost_stem_fixes.tsv", |
| |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/atomic_corrections.tsv", |
| |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/archaic_verbs.tsv", |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/more_fixes.tsv", |
| |
| |
| |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/nonce.tsv", |
| |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/posessives.tsv", |
| |
| |
| f"{MORPHEMES}/linker_exceptions.tsv", |
| |
| |
| f"{MORPHEMES}/inflections.tsv", |
| |
| |
| |
| |
| f"{MORPHEMES}/compounds.tsv", |
| |
| |
| f"{MORPHEMES}/goal1_derivations.tsv", |
| |
| |
| |
| f"{MORPHEMES}/british-to-american.tsv", |
| |
| |
| |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/mojibake-fixes.tsv", |
| |
| |
| |
| f"{MORPHEMES}/typos.tsv", |
| f"{MORPHEMES}/oov_interjection.tsv", |
| f"{MORPHEMES}/oov_dialectal.tsv", |
| |
| f"{MORPHEMES}/oov_contraction_fixes.tsv", |
| f"{MORPHEMES}/men_compounds.tsv", |
| |
| |
| f"{MORPHEMES}/oov_rare_words_approved.tsv", |
| |
| |
| f"{MORPHEMES}/affixes.tsv", |
| |
| |
| |
| |
| f"{MORPHEMES}/atomic_more.tsv", |
| |
| |
| |
| f"{MORPHEMES}/ambiguous.tsv", |
| |
| |
| |
| |
| |
| f"{MORPHEMES}/irregular_forms_override.tsv", |
| |
| |
| |
| ) |
|
|
|
|
| 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): |
| |
| _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() |
| |
| |
| |
| |
| |
| |
| |
| 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] |
| 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 |
| 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 |
|
|
|
|
| |
| |
| |
| 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"] |
| if len(stem) >= 3 and stem[-1] == stem[-2] and stem[-1] not in "aeiou": |
| c.append(stem[:-1]) |
| return c |
| if suf == "ies": |
| return [stem + "y", stem + "ie"] |
| if suf == "es": |
| return [stem, stem + "e"] |
| return [stem] |
|
|
|
|
| _WF = None |
|
|
|
|
| def _wordfreq(w): |
| global _WF |
| if _WF is None: |
| import importlib as _il; word_frequency = _il.import_module("wordfreq").word_frequency |
| _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 |
| return cands[0] if len(cands) == 1 else None |
|
|
|
|
| MAX_SUPP_DEPTH = 5 |
| UNEXPANDED = {} |
|
|
|
|
| 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: |
| out.append(p) |
| continue |
| sub, _ = analyze(p, res, _depth + 1) |
| if any(x.startswith("<UNK") for x in sub): |
| 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" |
| if word in res["SUPPLEMENT"]: |
| return _expand_supplement(word, res["SUPPLEMENT"][word], res, _depth), "supplement" |
| c = contraction(word) |
| if c is not None: |
| |
| |
| |
| |
| |
| |
| 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: |
| |
| |
| |
| |
| |
| |
| 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 |
| |
| restored = restore_stem(stem, suf, res) |
| 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" |
|
|
|
|
| |
| |
| |
| |
| CURLY = str.maketrans({"’": "'", "‘": "'", "ʼ": "'"}) |
| HEADER_RE = re.compile(r"^= = = .* = = =\s*$") |
| TIER_RE = re.compile(r"^%[a-z]+:") |
| SPK_RE = re.compile(r"^\*[A-Za-z]{2,5}:[ \t]*") |
|
|
|
|
| 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_]+") |
|
|
|
|
| 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 |
|
|