Download code/mix.py from Modularcomputing/dynamic-accent-duplex: direct link, hf CLI and curl.
- Browser
- Download file 8.42 kB
-
https://huggingface.co/Modularcomputing/dynamic-accent-duplex/resolve/ac573f1b218a7c549ca46573107d3833a92d0ed3/code/mix.py
- Command line
-
hf download hf://Modularcomputing/dynamic-accent-duplex@ac573f1b218a7c549ca46573107d3833a92d0ed3/code/mix.py
-
curl -L -o mix.py https://huggingface.co/Modularcomputing/dynamic-accent-duplex/resolve/ac573f1b218a7c549ca46573107d3833a92d0ed3/code/mix.py
8.42 kB
| """Per-word language ID + language-mix estimate for a user turn (and for a reply). | |
| Reuses the Dynamic Accent release tagger (rule-based lexicons/diacritics/Pidgin markers, optional GlotLID clause scores). | |
| estimate("Ndewo, how are you doing today?") -> | |
| {"mix": {"eng": 0.67, "ibo": 0.33}, "matrix": "eng", "pattern": ["greeting:ibo"], "words": [...], "labels": [...]}""" | |
| import re, unicodedata | |
| from tagger import Tagger, fold | |
| LANGS = ["eng", "pcm", "ibo", "yor", "hau"] | |
| NAMES = {"eng": "English", "pcm": "Nigerian Pidgin", "ibo": "Igbo", "yor": "Yoruba", "hau": "Hausa"} | |
| # greeting / closing / discourse lexicon (diacritics folded) -> language | |
| GREET = {"ibo": ["nde wo", "ndewoo", "ndeewo", "kedu", "nnoo", "ututu oma", "ehihie oma", "mgbede oma", "ka chi fo", "kedu ka i mere", "i bola chi"], | |
| "yor": ["e kaaro", "e kaasan", "e kurole", "e ku irole", "bawo", "se daadaa ni", "e nle", "pele", "e kaabo", "e ku abo", "ekaaro", "ekaasan", "ekaro", "e karo", "e kaaro o"], | |
| "hau": ["sannu", "sanu", "sanno", "baka da yamma", "baka da safiya", "barka", "barka da safiya", "barka da yamma", "ina kwana", "ina wuni", "yaya dai", "lafiya lau", "maraba"], | |
| "pcm": ["how far", "how body", "wetin dey", "how you dey", "how una dey", "i hail"]} | |
| CLOSE = {"ibo": ["daalu", "dalu", "dalo", "daalo", "dalow", "ka o di", "ka emesia", "chukwu gozie"], "yor": ["e se", "o se", "o dabo", "e seun", "ese", "odabo"], | |
| "hau": ["na gode", "sai an jima", "sai anjima", "nagode", "mun gode", "na godi", "nagodi"], "pcm": ["we go see", "thank you well well", "make we yarn later"]} | |
| DISC = {"pcm": ["abeg", "abi", "sha", "o", "na so", "omo", "wahala"], "yor": ["sha", "o", "ehen", "abi"], "ibo": ["biko", "nna", "nne", "chai", "ehen", "ngwa"], | |
| "hau": ["to", "kai", "wallahi", "haba"]} | |
| _T = {} | |
| def tagger(lid="glotlid"): | |
| if lid not in _T: | |
| try: | |
| _T[lid] = Tagger(lid if lid != "none" else None) | |
| except Exception as e: # no fasttext / GlotLID: rules only | |
| print("tagger: GlotLID unavailable, rules only:", repr(e)[:120]); _T[lid] = Tagger(None) | |
| return _T[lid] | |
| def _find(fs, phrases): | |
| for p in sorted(phrases, key=len, reverse=True): | |
| if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs): | |
| return p | |
| return None | |
| PCM_MARK = {"dey", "don", "abeg", "wetin", "una", "gree", "wahala", "sabi", "comot", "chop", "oya", "abi", "shey", "wey", "dem", "wan", | |
| "pikin", "oga", "sef", "jare", "naim", "dis", "dat", "wahala", "kpatakpata", "ehn", "sha", "nor", "abeg", "jor", "ehen", "na"} | |
| PCM_BI = {("no", "gree"), ("no", "be"), ("e", "no"), ("make", "we"), ("how", "far"), ("na", "im"), ("i", "go"), ("you", "go"), ("we", "go"), ("e", "go"), | |
| ("no", "go"), ("i", "fit"), ("you", "fit"), ("e", "don"), ("i", "don"), ("my", "guy"), ("na", "so"), ("well", "well"), ("no", "dey")} | |
| def pidgin_relabel(words, labs, text): | |
| """sentence-level Pidgin: a sentence of English-labelled words with Pidgin markers is Pidgin (the word tagger only flips the marker's clause).""" | |
| if not labs: return labs | |
| labs = list(labs); f = [fold(w).strip(".,!?;:'\"") for w in words] | |
| ends = [i for i, w in enumerate(words) if re.search(r"[.!?]$", w)] + [len(words) - 1] | |
| s0 = 0 | |
| for e in sorted(set(ends)): | |
| idx = list(range(s0, e + 1)); s0 = e + 1 | |
| if not idx: continue | |
| hits = sum(1 for i in idx if f[i] in PCM_MARK or f[i] == "omo") + sum(1 for a, b in zip(idx, idx[1:]) if (f[a], f[b]) in PCM_BI and not ((f[a], f[b]) == ("how", "far") and a != idx[0])) | |
| hits += 1 if f[idx[-1]] == "o" and len(idx) > 2 else 0 | |
| lat = [i for i in idx if labs[i] in ("eng", "pcm")] | |
| if hits == 0 and len(lat) > 4 and all(labs[i] == "pcm" for i in lat): | |
| for i in lat: labs[i] = "eng" | |
| continue | |
| if lat and (hits >= 2 or (hits >= 1 and len(idx) <= 8)) and sum(labs[i] == "pcm" for i in lat) < len(lat): | |
| for i in lat: labs[i] = "pcm" | |
| return labs | |
| def window_relabel(words, labs, t, win=4, thr=0.5): | |
| """ASR text has no punctuation/diacritics, so the clause-level tagger sees one long 'English' clause. Slide a GlotLID window | |
| over the words and let confident window votes override the rule labels (rule label keeps a weight-1 vote).""" | |
| from collections import Counter | |
| n = len(words) | |
| if t.lid is None or n < 3: return labs | |
| votes = [Counter({labs[i]: 1.0}) for i in range(n)] | |
| for s0 in range(0, max(1, n - win + 1)): | |
| sc = t._lid_scores(" ".join(words[s0:s0 + win])); l = max(sc, key=sc.get) | |
| if sc[l] >= thr: | |
| for i in range(s0, min(n, s0 + win)): votes[i][l] += sc[l] | |
| return [v.most_common(1)[0][0] for v in votes] | |
| def estimate(text, lid="glotlid", prior=None, prior_w=0.0, asr=False): | |
| """prior: a previous mix dict (conversation-level style), blended with weight prior_w.""" | |
| t = tagger(lid) | |
| toks, labs = t.word_langs(text) if text.strip() else ([], []) | |
| words = [w for _, _, w in toks] | |
| if asr: labs = window_relabel(words, list(labs), t) | |
| labs = pidgin_relabel(words, labs, text) | |
| n = max(1, len(labs)) | |
| mix = {l: labs.count(l) / n for l in LANGS if labs.count(l)} | |
| if prior and prior_w > 0: | |
| keys = set(mix) | set(prior) | |
| mix = {k: (1 - prior_w) * mix.get(k, 0) + prior_w * prior.get(k, 0) for k in keys} | |
| s = sum(mix.values()) or 1; mix = {k: v / s for k, v in mix.items() if v / s >= 0.03} | |
| fs = " " + re.sub(r"[^\w\s']", " ", fold(text)) + " " | |
| fs = re.sub(r"\s+", " ", fs) | |
| pat = [] | |
| head = " ".join(fs.split()[:5]); tail = " ".join(fs.split()[-5:]) | |
| for l in ("ibo", "yor", "hau", "pcm"): | |
| g = _find(head, GREET[l]); c = _find(tail, CLOSE[l]) | |
| if g: pat.append(f"greeting:{l}") | |
| if c: pat.append(f"closing:{l}") | |
| d = [p for p in DISC[l] if re.search(r"(^|\s)" + re.escape(p) + r"(\s|$)", fs)] | |
| if d and l in mix: pat.append(f"discourse:{l}") | |
| # ASR often spells a greeting/closing so that the word tagger calls it English: give the matched words to that language | |
| toks_f = fs.split() | |
| for p in pat: | |
| k, _, l = p.partition(":") | |
| if k in ("greeting", "closing") and mix.get(l, 0) < 0.1 and labs: | |
| phr = _find(head if k == "greeting" else tail, (GREET if k == "greeting" else CLOSE)[l]) or "" | |
| share = min(0.5, len(phr.split()) / max(1, len(labs))) | |
| m2 = max(mix, key=mix.get) | |
| mix[m2] = max(0.0, mix[m2] - share); mix[l] = mix.get(l, 0) + share | |
| mix = {a: b for a, b in mix.items() if b > 0.01} | |
| # switch style: count switches between labelled runs | |
| sw = sum(1 for a, b in zip(labs, labs[1:]) if a != b) | |
| if len(mix) > 1: | |
| pat.append("intra-sentential" if sw >= 3 else "inter-sentential") | |
| matrix = max(mix, key=mix.get) if mix else "eng" | |
| return dict(mix={k: round(v, 2) for k, v in sorted(mix.items(), key=lambda x: -x[1])}, matrix=matrix, pattern=pat, | |
| words=words, labels=labs, n_words=len(labs), switches=sw) | |
| def describe(est): | |
| """natural-language mix instruction for the LLM.""" | |
| mix, pat = est["mix"], est["pattern"] | |
| if not mix: | |
| return "Reply in English." | |
| parts = [f"{NAMES[l]} about {round(100 * p / 5) * 5}%" for l, p in mix.items()] | |
| s = f"Match the user's language mix: {', '.join(parts)} of the words. The main (matrix) language is {NAMES[est['matrix']]}." | |
| for p in pat: | |
| k, _, l = p.partition(":") | |
| if k == "greeting": s += f" The user greeted in {NAMES[l]}: open with a short {NAMES[l]} greeting." | |
| if k == "closing": s += f" The user closed in {NAMES[l]}: you may close in {NAMES[l]}." | |
| if k == "discourse": s += f" Sprinkle a {NAMES[l]} discourse word or two the way the user did." | |
| if "intra-sentential" in pat: s += " The user switches inside sentences; do the same naturally." | |
| if len(mix) == 1: s += f" Reply only in {NAMES[est['matrix']]}." | |
| return s | |
| def tvd(a, b): | |
| k = set(a) | set(b) | |
| return 0.5 * sum(abs(a.get(x, 0) - b.get(x, 0)) for x in k) | |
| if __name__ == "__main__": | |
| import sys, json | |
| for s in sys.argv[1:] or ["Ndewo! How are you doing today? I wan ask you something.", "Ẹ káàárọ̀, ṣé dáadáa ni? I need help with my account."]: | |
| e = estimate(s); print(json.dumps({k: e[k] for k in ("mix", "matrix", "pattern")}, ensure_ascii=False), "|", describe(e)) | |