BabyHemingway D2+D3: entities, base-rate gender resolver, rename preset, leak gate passes

This commit is contained in:
Vuong Hoang
2026-09-16 07:55:34 -07:00
parent 9598d0b4a7
commit 03b4a3f62c
5 changed files with 329 additions and 1 deletions
@@ -0,0 +1,184 @@
"""D2b for BabyHemingway: resolve entity gender by MAJORITY VOTE over nearby pronouns.
The honorific-and-local-window resolver inherited from the Bronte/Yarros line fails badly
here. Measured on this corpus before writing a line of replacement: **397 male, 20 female**
across 1,102 entity records, with Catherine Barkley, Brett Ashley, Pilar, Maria, Marita and
Mary all held neutral and `Helen` and `Audrey` resolved outright WRONG. A corpus containing
those characters does not have twenty women in it.
Why it fails is the same mechanism Yarros exposed from the other side: Hemingway's women
appear mostly inside male characters' scenes, so the pronouns nearest their names are
predominantly `he`. Yarros solved its version with the POV chapter header; Hemingway's
editions have no such header, so that fix does not transfer and a different signal is needed.
⭐ THE SIGNAL THAT WORKS IS VOLUME. A major character is named hundreds of times, so instead
of trusting the nearest pronoun in one window, every occurrence votes and the majority wins.
A single window is dominated by whoever else is in the scene; three hundred windows are
dominated by the person being written about.
⚠ THE INSTRUMENT REFUSES TO WRITE UNLESS IT BEATS WHAT IT REPLACES, scored against a
hand-verified control list. That is the same guard `pov_gender.py` carried, and it is the
only reason to believe a replacement is an improvement rather than a different set of errors.
"""
from __future__ import annotations
import argparse, json, re, sys
from collections import Counter
from pathlib import Path
MALE = {"he", "him", "his", "himself"}
FEMALE = {"she", "her", "hers", "herself"}
WINDOW = 12 # words either side of the mention
MIN_VOTES = 6 # below this the evidence is too thin to overrule a hold
MARGIN = 0.60 # winning share required, else HELD neutral
def load_text(corpus: Path) -> str:
out = []
for f in sorted((corpus / "works").glob("*.jsonl")):
for line in f.read_text(encoding="utf-8").splitlines():
out.append(json.loads(line)["text"])
return "\n".join(out)
def tally_all(text: str, surfaces: set) -> dict:
"""One pass over the corpus for EVERY surface at once.
The obvious shape -- rescan the text once per surface -- is 1,102 surfaces x 995,000
words and does not finish in any useful time. Tokenise once, walk once, and carry running
prefix counts of male and female pronouns so a window costs two subtractions instead of a
25-word inner loop.
"""
words = [w.lower() for w in re.findall(r"[A-Za-z'’]+", text)]
n = len(words)
pm = [0] * (n + 1)
pf = [0] * (n + 1)
for i, w in enumerate(words):
pm[i + 1] = pm[i] + (1 if w in MALE else 0)
pf[i + 1] = pf[i] + (1 if w in FEMALE else 0)
want = {s.lower(): s for s in surfaces}
out = {s: [0, 0] for s in surfaces}
for i, w in enumerate(words):
s = want.get(w)
if s is None:
continue
lo, hi = max(0, i - WINDOW), min(n, i + WINDOW + 1)
out[s][0] += pm[hi] - pm[lo]
out[s][1] += pf[hi] - pf[lo]
return {k: (v[0], v[1]) for k, v in out.items()}
def decide(m: int, f: int, base_m: float = 0.5):
"""Score a name's local pronoun mix AGAINST THE CORPUS BASE RATE, not against 50:50.
⚠ MEASURED, and it is why the first version of this was refused by its own gate: a raw
majority vote scored 18 correct but FIVE wrong against the incumbent's one, and every
error was female-read-as-male -- Pilar m=426 f=243, Brett m=249 f=137. Both are strongly
female-associated; they merely appear in a corpus where male pronouns outnumber female
ones several times over, so a bare majority is dominated by the background rate rather
than by the character.
The correction is to ask whether a name's neighbourhood is male-heavy RELATIVE TO the
corpus, which is what `base_m` supplies. A hold stays the safe outcome: the gate counts a
wrong answer as worse than no answer, because rename can leave a held entity neutral but
cannot undo a man's name given to a woman.
"""
tot = m + f
if tot < MIN_VOTES:
return None
base_f = 1.0 - base_m
# odds of the observed mix under each hypothesis, expressed as a share after dividing
# out the background. lift_m > lift_f means male-heavy beyond what the corpus explains.
lift_m = (m / tot) / base_m if base_m else 0.0
lift_f = (f / tot) / base_f if base_f else 0.0
s = lift_m + lift_f
if not s:
return None
if lift_m / s >= MARGIN:
return "m"
if lift_f / s >= MARGIN:
return "f"
return None
def score(resolved: dict, truth: dict) -> tuple[int, int, int]:
ok = bad = held = 0
for name, want in truth.items():
got = resolved.get(name, "__absent__")
if got == "__absent__":
continue
if got is None:
held += 1
elif got == want:
ok += 1
else:
bad += 1
return ok, held, bad
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("corpus")
ap.add_argument("--entities", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--control", required=True,
help="Name=g,Name=g ... hand-verified, the gate this must beat")
a = ap.parse_args()
truth = {}
for item in a.control.split(","):
n, _, g = item.partition("=")
truth[n.strip()] = g.strip()
ents = json.loads(Path(a.entities).read_text(encoding="utf-8"))
text = load_text(Path(a.corpus))
incumbent = {}
for blob in ents.values():
for v in blob["entities"].values():
incumbent.setdefault(v["surface"], v.get("gender"))
surfaces = {v["surface"] for blob in ents.values() for v in blob["entities"].values()}
tallies = tally_all(text, surfaces)
toks = [w.lower() for w in re.findall(r"[A-Za-z'’]+", text)]
gm = sum(1 for w in toks if w in MALE)
gf = sum(1 for w in toks if w in FEMALE)
base_m = gm / (gm + gf)
print(f" corpus pronoun base rate: male {gm:,} / female {gf:,} -> base_m {base_m:.3f}")
resolved, detail = {}, {}
for s in sorted(surfaces):
m, f = tallies[s]
g = decide(m, f, base_m)
resolved[s] = g
detail[s] = {"gender": g, "male_votes": m, "female_votes": f}
ok_i, held_i, bad_i = score(incumbent, truth)
ok_n, held_n, bad_n = score(resolved, truth)
print(f" incumbent (honorific/window): correct {ok_i} held {held_i} WRONG {bad_i}")
print(f" proximity majority vote : correct {ok_n} held {held_n} WRONG {bad_n}")
for n, want in truth.items():
if resolved.get(n) not in (want, None):
print(f" ⚠ still wrong: {n} want={want} got={resolved.get(n)} "
f"(m={detail[n]['male_votes']} f={detail[n]['female_votes']})")
if bad_n > bad_i or (bad_n == bad_i and ok_n <= ok_i):
print(" REFUSING to write: does not beat the incumbent", file=sys.stderr)
return 1
counts = Counter(v["gender"] for v in detail.values())
print(f" gender distribution: {dict(counts)}")
out = json.loads(Path(a.entities).read_text(encoding="utf-8"))
changed = 0
for blob in out.values():
for v in blob["entities"].values():
g = resolved.get(v["surface"])
if g != v.get("gender"):
changed += 1
v["gender"] = g
Path(a.out).write_text(json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8")
print(f" wrote {a.out} ({changed} gender fields changed)")
return 0
if __name__ == "__main__":
raise SystemExit(main())