Buckets:
| """ | |
| ORÚKỌ-mini: does a small frontier LLM preserve the identity of African names? | |
| Core module: dataset, prompt construction, and identity-preservation scoring. | |
| Everything here is pure Python (no GPU needed) so it can be unit-tested anywhere. | |
| """ | |
| import csv, json, os, re, difflib, unicodedata as ud | |
| from collections import defaultdict | |
| # --------------------------------------------------------------------------- | |
| # 1. Unicode helpers | |
| # --------------------------------------------------------------------------- | |
| # Letters that are *distinct letters*, not accented forms. NFD does not | |
| # decompose them, so a naive "strip combining marks" keeps them, while an | |
| # ASCII fold turns them into a different letter (or drops them). | |
| SPECIAL_FOLD = { | |
| "ɓ": "b", "Ɓ": "B", "ɗ": "d", "Ɗ": "D", "ƙ": "k", "Ƙ": "K", | |
| "ƴ": "y", "Ƴ": "Y", # Hausa hooked letters | |
| "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", # controls | |
| } | |
| HOOKED = set("ɓƁɗƊƙƘƴƳ") | |
| TONE_MARKS = {"\u0300", "\u0301", "\u0302", "\u0304", "\u030c"} # grave, acute, circumflex, macron, caron | |
| MARK_NAMES = { | |
| "\u0300": "grave(low)", "\u0301": "acute(high)", "\u0323": "dot-below", | |
| "\u0307": "dot-above", "\u0302": "circumflex", "\u0304": "macron", | |
| "\u030c": "caron", "\u0308": "diaeresis", "\u0327": "cedilla", | |
| "\u031b": "horn", "\u0303": "tilde", "\u030a": "ring", | |
| } | |
| def nfc(s): | |
| return ud.normalize("NFC", s) | |
| def units(s): | |
| """Split a string into [base_char, [combining marks]] units (via NFD).""" | |
| out = [] | |
| for ch in ud.normalize("NFD", s): | |
| if ud.combining(ch) and out: | |
| out[-1][1].append(ch) | |
| else: | |
| out.append([ch, []]) | |
| return out | |
| def strip_marks(s): | |
| """Remove every diacritic and fold special letters -> plain letters. | |
| This is what most 'text normalisation' pipelines effectively do.""" | |
| return nfc("".join(SPECIAL_FOLD.get(b, b) for b, _ in units(s))) | |
| def ascii_ignore(s): | |
| """What `s.encode('ascii','ignore')` does: special letters vanish entirely.""" | |
| return nfc(s).encode("ascii", "ignore").decode() | |
| def features(unit, ignore_tone=False): | |
| """Identity-bearing features of one character unit.""" | |
| b, marks = unit | |
| f = {m for m in marks if not (ignore_tone and m in TONE_MARKS)} | |
| if b in SPECIAL_FOLD: | |
| f.add("SPECIAL:" + b.lower()) | |
| return f | |
| def n_identity_marks(s, ignore_tone=False): | |
| return sum(len(features(u, ignore_tone)) for u in units(s)) | |
| def diacritic_density(s, ignore_tone=False): | |
| letters = [u for u in units(s) if u[0].isalpha()] | |
| return n_identity_marks(s, ignore_tone) / max(1, len(letters)) | |
| def yoruba_tone_pattern(s): | |
| """Tone per vowel: acute=H, grave=L, unmarked=M (standard Yorùbá orthography).""" | |
| pat = [] | |
| for b, marks in units(s): | |
| if b.lower() in "aeiou": | |
| pat.append("H" if "\u0301" in marks else "L" if "\u0300" in marks else "M") | |
| return "-".join(pat) | |
| # --------------------------------------------------------------------------- | |
| # 2. Dataset | |
| # --------------------------------------------------------------------------- | |
| # conf: "high" = standard, widely attested spelling; "check" = please confirm | |
| # with a native speaker before release (bearer-defined correctness). | |
| # kw: list of keyword groups; a meaning answer is correct if every group has | |
| # at least one keyword present (case-insensitive substring). | |
| NAMES = [ | |
| # ---- Yorùbá (tone marks + under-dots) ---- | |
| ("yo01", "yoruba", "Olúwadámilárè", "God has vindicated me", [["god", "lord"], ["justif", "vindicat", "right", "innocen"]], "check"), | |
| ("yo02", "yoruba", "Adéṣọlá", "the crown makes wealth/honour", [["crown", "royal"], ["wealth", "honou", "honor", "riches", "prosper"]], "high"), | |
| ("yo03", "yoruba", "Ọláyínká", "wealth/honour surrounds me", [["wealth", "honou", "honor", "riches"], ["surround", "encircl", "around"]], "high"), | |
| ("yo04", "yoruba", "Fọláṣadé", "uses wealth/honour as a crown", [["wealth", "honou", "honor", "riches"], ["crown"]], "high"), | |
| ("yo05", "yoruba", "Olúwaṣeun", "thank God / God has done it", [["god", "lord"], ["thank", "done", "did"]], "high"), | |
| ("yo06", "yoruba", "Ayọ̀délé", "joy has come home", [["joy", "happiness"], ["home", "arriv", "come", "came"]], "high"), | |
| ("yo07", "yoruba", "Ìyábọ̀dé", "mother has returned", [["mother"], ["return", "come back", "came back", "has come"]], "high"), | |
| ("yo08", "yoruba", "Bọ́lájí", "one who wakes up with wealth", [["wealth", "honou", "honor", "riches"]], "high"), | |
| ("yo09", "yoruba", "Fúnmiláyọ̀", "give me joy", [["joy", "happiness"], ["give"]], "high"), | |
| ("yo10", "yoruba", "Adébáyọ̀", "the crown meets joy", [["crown", "royal"], ["joy", "happiness"]], "high"), | |
| ("yo11", "yoruba", "Ọlámidé", "my wealth/honour has come", [["wealth", "honou", "honor", "riches"], ["come", "arriv", "came"]], "high"), | |
| ("yo12", "yoruba", "Ìfẹ́olúwa", "the love of God", [["love"], ["god", "lord"]], "high"), | |
| ("yo13", "yoruba", "Táíwò", "first-born of twins", [["twin"]], "high"), | |
| ("yo14", "yoruba", "Kẹ́hìndé", "second-born of twins", [["twin"]], "high"), | |
| ("yo15", "yoruba", "Ìdòwú", "child born after twins", [["twin"]], "high"), | |
| ("yo16", "yoruba", "Babátúndé", "father has returned", [["father"], ["return", "come back", "came back", "again"]], "high"), | |
| ("yo17", "yoruba", "Olúṣẹ́gun", "God has conquered", [["god", "lord"], ["conquer", "victor", "triumph", "win", "defeat", "overcome"]], "high"), | |
| ("yo18", "yoruba", "Àbẹ̀ní", "we asked for her and got her", [["ask", "beg", "pray", "request"]], "check"), | |
| ("yo19", "yoruba", "Yéwándé", "mother has returned", [["mother"], ["return", "come back", "came back", "has come"]], "high"), | |
| ("yo20", "yoruba", "Adéwálé", "the crown has come home", [["crown", "royal"], ["home", "come", "arriv", "came"]], "high"), | |
| # ---- Igbo (dotted vowels ị ọ ụ; tone usually unwritten in names) ---- | |
| ("ig01", "igbo", "Chiọma", "good God / good personal spirit", [["god", "chi", "spirit", "destiny"], ["good"]], "high"), | |
| ("ig02", "igbo", "Ụchenna", "the father's will / thought", [["father"], ["will", "thought", "mind", "wish", "intention"]], "high"), | |
| ("ig03", "igbo", "Obiọma", "kind heart", [["heart"], ["good", "kind"]], "high"), | |
| ("ig04", "igbo", "Ifeọma", "good thing", [["good", "beautiful"], ["thing"]], "high"), | |
| ("ig05", "igbo", "Chinọnso", "God is near", [["god"], ["near", "close"]], "check"), | |
| ("ig06", "igbo", "Ụzọamaka", "the road/path is good", [["road", "path", "way", "journey"], ["good", "beautiful"]], "check"), | |
| ("ig07", "igbo", "Ifeanyịchukwu", "nothing is impossible for God", [["god"], ["impossible", "nothing", "beyond", "too difficult", "too hard", "defeat"]], "check"), | |
| ("ig08", "igbo", "Ngọzi", "blessing", [["bless"]], "check"), | |
| ("ig09", "igbo", "Chukwuebuka", "God is great (no dotted letters: over-correction control)", [["god"], ["great", "big", "mighty"]], "high"), | |
| ("ig10", "igbo", "Nkechi", "God's own (no dotted letters: over-correction control)", [["god"], ["own", "belong", "gift", "creat"]], "high"), | |
| # ---- Hausa (hooked letters ɓ ɗ ƙ ƴ are distinct letters) ---- | |
| ("ha01", "hausa", "Ɗanjuma", "boy born on Friday", [["friday", "juma"]], "high"), | |
| ("ha02", "hausa", "Ɗanladi", "boy born on Sunday", [["sunday", "lahadi"]], "high"), | |
| ("ha03", "hausa", "Ɗanazumi", "boy born during the fast (Ramadan)", [["fast", "ramadan", "azumi"]], "check"), | |
| ("ha04", "hausa", "Ladi", "girl born on Sunday (no hooked letters: control)", [["sunday", "lahadi"]], "high"), | |
| # ---- High-resource controls (diacritics in well-served languages) ---- | |
| ("ct01", "control", "José", "Spanish", None, "high"), | |
| ("ct02", "control", "François", "French", None, "high"), | |
| ("ct03", "control", "Søren", "Danish", None, "high"), | |
| ("ct04", "control", "Björn", "Swedish", None, "high"), | |
| ("ct05", "control", "Łukasz", "Polish", None, "high"), | |
| ("ct06", "control", "Jürgen", "German", None, "high"), | |
| ("ct07", "control", "Dvořák", "Czech", None, "high"), | |
| ("ct08", "control", "Nguyễn Thị Hương", "Vietnamese (tonal, like Yorùbá)", None, "high"), | |
| ("ct09", "control", "Zhāng Wěi", "Mandarin pinyin (tonal)", None, "high"), | |
| ] | |
| CONTROL_LANG = {"ct01": "Spanish", "ct02": "French", "ct03": "Danish", "ct04": "Swedish", | |
| "ct05": "Polish", "ct06": "German", "ct07": "Czech", "ct08": "Vietnamese", | |
| "ct09": "Mandarin (pinyin)"} | |
| NO_RESTORE = {"ct09"} # pinyin restoration is genuinely ambiguous | |
| # Minimal pairs: the same bare letters, different words. | |
| MINIMAL_PAIRS = [ | |
| # set, lang, word, gloss, keywords | |
| ("oko", "yoruba", "oko", "farm", ["farm"]), | |
| ("oko", "yoruba", "ọkọ", "husband", ["husband"]), | |
| ("oko", "yoruba", "ọkọ̀", "vehicle / boat", ["vehicle", "car", "boat", "canoe", "ship", "lorry", "transport", "bus"]), | |
| ("oko", "yoruba", "ọkọ́", "hoe", ["hoe"]), | |
| ("igba", "yoruba", "igbá", "calabash", ["calabash", "gourd"]), | |
| ("igba", "yoruba", "ìgbà", "time / period", ["time", "period", "season", "era"]), | |
| ("igba", "yoruba", "igba", "two hundred", ["200", "two hundred", "two-hundred"]), | |
| ("igba", "yoruba", "ìgbá", "garden egg", ["garden egg", "eggplant", "aubergine", "garden-egg"]), | |
| ("akwa", "igbo", "àkwà", "bed", ["bed", "bridge"]), | |
| ("akwa", "igbo", "ákwà", "cloth", ["cloth", "fabric", "garment", "wrapper", "clothes", "clothing"]), | |
| ("akwa", "igbo", "ákwá", "cry", ["cry", "weep", "tears"]), | |
| ("akwa", "igbo", "àkwá", "egg", ["egg"]), | |
| ("kara", "hausa", "kara", "stalk / reed", ["stalk", "reed", "stem", "cane"]), | |
| ("kara", "hausa", "ƙara", "increase / complaint", ["increase", "add", "complain", "more", "noise", "sound", "lawsuit"]), | |
| ("dari", "hausa", "dari", "cold (weather)", ["cold", "chill", "harmattan"]), | |
| ("dari", "hausa", "ɗari", "hundred", ["hundred", "100"]), | |
| ] | |
| LANG_LABEL = {"yoruba": "Yoruba", "igbo": "Igbo", "hausa": "Hausa"} | |
| IGNORE_TONE = {"yoruba": False, "igbo": True, "hausa": True, "control": False} | |
| def name_rows(): | |
| rows = [] | |
| for nid, lang, name, meaning, kw, conf in NAMES: | |
| name = nfc(name) | |
| it = IGNORE_TONE[lang] | |
| rows.append(dict( | |
| id=nid, lang=lang, | |
| sublang=CONTROL_LANG.get(nid, LANG_LABEL.get(lang, "")), | |
| name=name, stripped=strip_marks(name), ascii_ignore=ascii_ignore(name), | |
| n_marks=n_identity_marks(name, it), density=round(diacritic_density(name, it), 3), | |
| has_hooked=any(c in HOOKED for c in name), | |
| n_letters=sum(1 for u in units(name) if u[0].isalpha()), | |
| tone_pattern=yoruba_tone_pattern(name) if lang == "yoruba" else "", | |
| meaning=meaning if lang != "control" else "", | |
| meaning_keywords=json.dumps(kw) if kw else "", | |
| confidence=conf, | |
| )) | |
| return rows | |
| def pair_rows(): | |
| return [dict(set=s, lang=l, word=nfc(w), bare=strip_marks(w), gloss=g, keywords=json.dumps(k)) | |
| for s, l, w, g, k in MINIMAL_PAIRS] | |
| # --------------------------------------------------------------------------- | |
| # 3. Prompts | |
| # --------------------------------------------------------------------------- | |
| COPY_TEMPLATES = { | |
| "c1_repeat": "Repeat the following name exactly as written, with no changes: {name}", | |
| "c2_welcome": "Write a one-sentence welcome message for a new colleague named {name}.", | |
| "c3_json": 'Create a JSON object for a hospital patient record with the fields "full_name" and "greeting". The patient\'s name is {name}. Output only the JSON.', | |
| "c4_translate": 'Translate into French: "{name} will arrive tomorrow morning."', | |
| "c5_upper": "Write the name {name} in capital letters, as it would appear on a boarding pass.", | |
| } | |
| def restore_prompt(r): | |
| bare = r["stripped"] | |
| if r["lang"] == "yoruba": | |
| return (f'The Yoruba name "{bare}" has been written without its tone marks and under-dots. ' | |
| "Rewrite it in correct standard Yoruba orthography with all diacritics (tone marks and under-dots). " | |
| "Reply with only the name.") | |
| if r["lang"] == "igbo": | |
| return (f'The Igbo name "{bare}" has been written without diacritics. Rewrite it in standard Igbo orthography, ' | |
| "adding the dotted vowels (ị, ọ, ụ) or ṅ wherever they belong; if none belong, leave it unchanged. " | |
| "Tone marks are not needed. Reply with only the name.") | |
| if r["lang"] == "hausa": | |
| return (f'The Hausa name "{bare}" has been written in plain ASCII. Rewrite it in standard Hausa (Boko) orthography, ' | |
| "using the hooked letters (ɓ, ɗ, ƙ, ƴ) wherever they belong; if none belong, leave it unchanged. " | |
| "Reply with only the name.") | |
| return (f'The {r["sublang"]} name "{bare}" has been written without its accents or special letters. ' | |
| f'Rewrite it in correct {r["sublang"]} spelling with all diacritics. Reply with only the name.') | |
| def tone_prompt(r): | |
| return ("In standard Yoruba orthography, an acute accent (´) marks a high tone (H), a grave accent (`) marks a low tone (L), " | |
| "and a vowel with no tone mark has a mid tone (M). Under-dots (ẹ, ọ, ṣ) are not tone marks. " | |
| f'Give the tone of each vowel in the name "{r["name"]}", in order, as a sequence like H-M-L. ' | |
| "Reply with only the sequence.") | |
| def meaning_prompt(r, form): | |
| return (f'What does the {r["sublang"]} name "{r[form]}" mean in English? Answer in one short sentence.') | |
| def build_prompts(): | |
| names, pairs = name_rows(), pair_rows() | |
| P = [] | |
| for r in names: | |
| for tid, t in COPY_TEMPLATES.items(): | |
| P.append(dict(task="copy", template=tid, item=r["id"], prompt=t.format(name=r["name"]))) | |
| if r["id"] not in NO_RESTORE: | |
| P.append(dict(task="restore", template="restore", item=r["id"], prompt=restore_prompt(r))) | |
| if r["lang"] == "yoruba": | |
| P.append(dict(task="tone", template="tone", item=r["id"], prompt=tone_prompt(r))) | |
| if r["lang"] != "control": | |
| for form in ("name", "stripped"): | |
| if form == "stripped" and r["stripped"] == r["name"]: | |
| continue | |
| P.append(dict(task="meaning", template=f"meaning_{form}", item=r["id"], prompt=meaning_prompt(r, form))) | |
| for i, p in enumerate(pairs): | |
| L = LANG_LABEL[p["lang"]] | |
| P.append(dict(task="pair", template="pair_marked", item=f'{p["set"]}:{i}', | |
| prompt=f'What does the {L} word "{p["word"]}" mean in English? Answer with just the English word or a short phrase.')) | |
| for s in sorted({p["set"] for p in pairs}): | |
| L = LANG_LABEL[next(p["lang"] for p in pairs if p["set"] == s)] | |
| P.append(dict(task="ambiguity", template="ambiguity", item=s, | |
| prompt=(f'In {L}, the word "{s}" is written here without tone marks or diacritics, so it could stand for ' | |
| "several different words. List every distinct word it could be, giving each one's correct spelling " | |
| "with diacritics and its English meaning."))) | |
| for i, p in enumerate(P): | |
| p["pid"] = f"p{i:04d}" | |
| return names, pairs, P | |
| def write_dataset(outdir="data"): | |
| os.makedirs(outdir, exist_ok=True) | |
| names, pairs, prompts = build_prompts() | |
| for fn, rows in (("names.csv", names), ("minimal_pairs.csv", pairs)): | |
| with open(os.path.join(outdir, fn), "w", newline="", encoding="utf-8") as f: | |
| w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) | |
| w.writeheader(); w.writerows(rows) | |
| with open(os.path.join(outdir, "prompts.jsonl"), "w", encoding="utf-8") as f: | |
| for p in prompts: | |
| f.write(json.dumps(p, ensure_ascii=False) + "\n") | |
| return names, pairs, prompts | |
| # --------------------------------------------------------------------------- | |
| # 4. Scoring | |
| # --------------------------------------------------------------------------- | |
| def _folded_with_map(text): | |
| us = units(text) | |
| folded, idx = [], [] | |
| for i, (b, _) in enumerate(us): | |
| f = SPECIAL_FOLD.get(b, b).lower() | |
| if len(f) != 1: # rare case-mapping expansions; keep alignment | |
| f = f[:1] or " " | |
| folded.append(f); idx.append(i) | |
| return "".join(folded), idx, us | |
| def _search(hay, needle): | |
| m = re.search(r"(?<!\w)" + re.escape(needle) + r"(?!\w)", hay) | |
| return m.start() if m else -1 | |
| def compare_aligned(gold, pred, ignore_tone=False): | |
| """Position-by-position comparison of identity features (same base letters).""" | |
| gu, pu = units(gold), units(pred) | |
| tp = fp = fn = 0 | |
| lost = defaultdict(int) | |
| for g, p in zip(gu, pu): | |
| G, Pf = features(g, ignore_tone), features(p, ignore_tone) | |
| tp += len(G & Pf); fp += len(Pf - G); fn += len(G - Pf) | |
| for m in G - Pf: | |
| lost[MARK_NAMES.get(m, m)] += 1 | |
| return tp, fp, fn, dict(lost) | |
| def classify(gold, pred, ignore_tone=False): | |
| tp, fp, fn, lost = compare_aligned(gold, pred, ignore_tone) | |
| total = tp + fn | |
| if fn == 0 and fp == 0: | |
| status = "exact" | |
| elif tp == 0 and fp == 0 and total > 0: | |
| status = "fully_stripped" | |
| elif fp > 0 and fn == 0: | |
| status = "spurious_marks" | |
| elif fp > 0: | |
| status = "wrong_marks" | |
| else: | |
| status = "partially_stripped" | |
| return dict(status=status, tp=tp, fp=fp, fn=fn, lost=lost) | |
| def find_rendition(output, gold): | |
| """Locate the model's rendering of `gold` inside free text.""" | |
| out = nfc(output) | |
| hay, idx, us = _folded_with_map(out) | |
| needle = strip_marks(gold).lower() | |
| pos = _search(hay, needle) | |
| if pos >= 0: | |
| a, b = idx[pos], idx[pos + len(needle) - 1] + 1 | |
| return nfc("".join(c + "".join(m) for c, m in us[a:b])), "found" | |
| # catastrophic loss: a special letter vanished entirely (ASCII 'ignore') | |
| if any(c in SPECIAL_FOLD for c in nfc(gold)): | |
| dropped = "".join(c for c in nfc(gold) if c not in SPECIAL_FOLD) | |
| if _search(hay, strip_marks(dropped).lower()) >= 0: | |
| return None, "catastrophic" | |
| # otherwise: name was altered (misspelt/transliterated) or missing | |
| words = hay.split() | |
| k = max(1, len(needle.split())) | |
| best = 0.0 | |
| for i in range(len(words)): | |
| cand = " ".join(words[i:i + k]).strip(".,;:!?\"'()[]{}") | |
| best = max(best, difflib.SequenceMatcher(None, needle, cand).ratio()) | |
| return None, ("altered" if best >= 0.6 else "missing") | |
| def appears_exact(gold, output, ignore_tone=False): | |
| """True if ANY occurrence of gold's letters in output carries exactly gold's marks.""" | |
| hay, idx, us = _folded_with_map(nfc(output)) | |
| needle = strip_marks(gold).lower() | |
| for m in re.finditer(r"(?<!\w)" + re.escape(needle) + r"(?!\w)", hay): | |
| a, b = idx[m.start()], idx[m.start() + len(needle) - 1] + 1 | |
| rend = nfc("".join(c + "".join(mk) for c, mk in us[a:b])) | |
| if classify(gold, rend, ignore_tone)["status"] == "exact": | |
| return True | |
| return False | |
| def score_copy(gold, output, ignore_tone=False): | |
| rend, how = find_rendition(output, gold) | |
| if rend is None: | |
| n = n_identity_marks(gold, ignore_tone) | |
| return dict(status=how, rendition="", tp=0, fp=0, fn=n, lost={}) | |
| d = classify(gold, rend, ignore_tone) | |
| d["rendition"] = rend | |
| return d | |
| def clean_short_answer(text): | |
| text = re.sub(r"<think>.*?</think>", "", text, flags=re.S).strip() | |
| line = next((l for l in text.splitlines() if l.strip()), "") | |
| line = re.sub(r"^\s*(answer|name)\s*:\s*", "", line, flags=re.I) | |
| return nfc(line.strip().strip("*`\"'“”‘’").rstrip(".").strip()) | |
| def score_restore(gold, output, ignore_tone=False): | |
| ans = clean_short_answer(output) | |
| if strip_marks(ans).lower() == strip_marks(gold).lower(): | |
| d = classify(gold, ans, ignore_tone) | |
| else: | |
| rend, how = find_rendition(output, gold) | |
| if rend is None: | |
| n = n_identity_marks(gold, ignore_tone) | |
| d = dict(status="base_changed", tp=0, fp=0, fn=n, lost={}) | |
| else: | |
| d = classify(gold, rend, ignore_tone); ans = rend | |
| d["answer"] = ans | |
| return d | |
| def score_tone(gold_pattern, output): | |
| text = re.sub(r"<think>.*?</think>", "", output, flags=re.S).upper() | |
| pred = re.findall(r"(?<![A-Z])[HML](?![A-Z])", text) | |
| gold = gold_pattern.split("-") | |
| same_len = len(pred) == len(gold) | |
| pos_acc = sum(a == b for a, b in zip(gold, pred)) / len(gold) | |
| return dict(pred="-".join(pred), exact=pred == gold, same_length=same_len, position_acc=round(pos_acc, 3)) | |
| def score_meaning(kw_groups, output): | |
| t = output.lower() | |
| if isinstance(kw_groups, str): | |
| kw_groups = json.loads(kw_groups) | |
| return all(any(k in t for k in grp) for grp in kw_groups) | |
| def score_ambiguity(set_pairs, output): | |
| t = output.lower() | |
| senses = sum(any(k in t for k in json.loads(p["keywords"])) for p in set_pairs) | |
| # Tone is what distinguishes these words, so never ignore it here, and only | |
| # count words that carry marks (a bare form like "kara" is trivially "spelled"). | |
| marked = [p for p in set_pairs if n_identity_marks(p["word"]) > 0] | |
| spelled = sum(appears_exact(p["word"], output) for p in marked) | |
| return dict(senses_recalled=senses, correctly_spelled=spelled, n_senses=len(set_pairs), n_marked=len(marked)) | |
| # --------------------------------------------------------------------------- | |
| # 5. Score everything | |
| # --------------------------------------------------------------------------- | |
| def score_all(names, pairs, prompts, gens): | |
| """gens: dict pid -> model output text. Returns dict of row lists.""" | |
| N = {r["id"]: r for r in names} | |
| res = defaultdict(list) | |
| for p in prompts: | |
| out = gens.get(p["pid"]) | |
| if out is None: | |
| continue | |
| base = dict(pid=p["pid"], task=p["task"], template=p["template"], item=p["item"], output=out) | |
| if p["task"] in ("copy", "restore", "tone", "meaning"): | |
| r = N[p["item"]] | |
| base.update(lang=r["lang"], sublang=r["sublang"], name=r["name"], density=r["density"], | |
| n_marks=r["n_marks"], has_hooked=r["has_hooked"], n_letters=r["n_letters"]) | |
| it = IGNORE_TONE[r["lang"]] | |
| if p["task"] == "copy": | |
| d = score_copy(r["name"], out, it) | |
| elif p["task"] == "restore": | |
| d = score_restore(r["name"], out, it) | |
| elif p["task"] == "tone": | |
| d = score_tone(r["tone_pattern"], out); d["gold"] = r["tone_pattern"] | |
| else: | |
| d = dict(correct=score_meaning(r["meaning_keywords"], out), form=p["template"].split("_")[1]) | |
| if "lost" in d: | |
| d["lost"] = json.dumps(d["lost"]) | |
| base.update(d) | |
| elif p["task"] == "pair": | |
| s, i = p["item"].split(":") | |
| pr = pairs[int(i)] | |
| base.update(lang=pr["lang"], set=s, word=pr["word"], gloss=pr["gloss"], | |
| correct=any(k in out.lower() for k in json.loads(pr["keywords"]))) | |
| elif p["task"] == "ambiguity": | |
| sp = [x for x in pairs if x["set"] == p["item"]] | |
| base.update(lang=sp[0]["lang"], **score_ambiguity(sp, out)) | |
| res[p["task"]].append(base) | |
| return dict(res) | |
| def summarize(res): | |
| """Headline numbers per language group.""" | |
| S = defaultdict(dict) | |
| def frac(rows, f): | |
| return round(sum(1 for r in rows if f(r)) / len(rows), 3) if rows else None | |
| groups = ["yoruba", "igbo", "hausa", "control"] | |
| for g in groups: | |
| c = [r for r in res.get("copy", []) if r["lang"] == g and r["n_marks"] > 0] | |
| S[g]["copy_exact"] = frac(c, lambda r: r["status"] == "exact") | |
| S[g]["copy_fully_stripped"] = frac(c, lambda r: r["status"] == "fully_stripped") | |
| S[g]["copy_catastrophic"] = frac(c, lambda r: r["status"] == "catastrophic") | |
| tp = sum(r["tp"] for r in c); fn = sum(r["fn"] for r in c) | |
| S[g]["copy_mark_recall"] = round(tp / (tp + fn), 3) if tp + fn else None | |
| rs = [r for r in res.get("restore", []) if r["lang"] == g] | |
| rs_m = [r for r in rs if r["n_marks"] > 0] | |
| S[g]["restore_exact"] = frac(rs_m, lambda r: r["status"] == "exact") | |
| tp = sum(r["tp"] for r in rs_m); fp = sum(r["fp"] for r in rs_m); fn = sum(r["fn"] for r in rs_m) | |
| S[g]["restore_mark_f1"] = round(2 * tp / (2 * tp + fp + fn), 3) if (tp + fp + fn) else None | |
| ctrl = [r for r in rs if r["n_marks"] == 0] | |
| S[g]["restore_overcorrection"] = frac(ctrl, lambda r: r["fp"] > 0 or r["status"] == "base_changed") | |
| if g != "control": | |
| m = res.get("meaning", []) | |
| S[g]["meaning_acc_marked"] = frac([r for r in m if r["lang"] == g and r["form"] == "name"], lambda r: r["correct"]) | |
| S[g]["meaning_acc_stripped"] = frac([r for r in m if r["lang"] == g and r["form"] == "stripped"], lambda r: r["correct"]) | |
| pr = [r for r in res.get("pair", []) if r["lang"] == g] | |
| S[g]["minimal_pair_acc"] = frac(pr, lambda r: r["correct"]) | |
| amb = [r for r in res.get("ambiguity", []) if r["lang"] == g] | |
| if amb: | |
| S[g]["ambiguity_sense_recall"] = round(sum(r["senses_recalled"] for r in amb) / sum(r["n_senses"] for r in amb), 3) | |
| S[g]["ambiguity_spelled_correctly"] = round(sum(r["correctly_spelled"] for r in amb) / max(1, sum(r["n_marked"] for r in amb)), 3) | |
| t = res.get("tone", []) | |
| if t: | |
| S["yoruba"]["tone_read_exact"] = frac(t, lambda r: r["exact"]) | |
| S["yoruba"]["tone_read_position_acc"] = round(sum(r["position_acc"] for r in t) / len(t), 3) | |
| return {k: dict(v) for k, v in S.items()} | |
| def copy_by_template(res): | |
| out = defaultdict(dict) | |
| for r in res.get("copy", []): | |
| if r["n_marks"] == 0: | |
| continue | |
| out[r["lang"]].setdefault(r["template"], []).append(r["status"] == "exact") | |
| return {g: {t: round(sum(v) / len(v), 3) for t, v in d.items()} for g, d in out.items()} | |
| def lost_mark_counts(res, task="copy"): | |
| tot = defaultdict(lambda: defaultdict(int)) | |
| for r in res.get(task, []): | |
| for k, v in json.loads(r.get("lost") or "{}").items(): | |
| tot[r["lang"]][k] += v | |
| return {g: dict(d) for g, d in tot.items()} | |
Xet Storage Details
- Size:
- 26.5 kB
- Xet hash:
- 25507b54593ca76df45f38f4d399b9ea4942b15213916544ec5d5245703fdd7d
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.