Spaces:
Running on Zero
Running on Zero
File size: 7,257 Bytes
93cdbec | 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 | """One canonical spelling for every dataset, shared by training and scoring.
The sources disagree: the Bible text is truecase with punctuation, the radio transcripts are lowercase
with none, `history-data` is lowercase with glued clitics. A model trained on the mix has to guess which
convention a given recording wants, and every wrong guess counts as an error. So the training targets and
the metric both go through `normalize`, and the model only ever has to learn one form: lowercase, no
punctuation, digits spelled out, modern spelling without apostrophes.
Casing and punctuation are a separate job for a text model on top of the transcript.
"""
import re
import unicodedata
# Kreyòl numerals. 0-19 are irregular; 20-99 is built below; the forms follow common usage
# (25 vennsenk, 75 swasannkenz, 98 katrevendizuit, 2026 de mil vennsis).
UNITS = ["zewo", "en", "de", "twa", "kat", "senk", "sis", "sèt", "uit", "nèf",
"dis", "onz", "douz", "trèz", "katòz", "kenz", "sèz", "disèt", "dizuit", "diznèf"]
TENS = {20: "ven", 30: "trant", 40: "karant", 50: "senkant", 60: "swasant"}
NASAL = {20: "venn", 30: "trann", 40: "karann", 50: "senkann", 60: "swasann"}
HARD = {20: "vent", 30: "trant", 40: "karant", 50: "senkant", 60: "swasant"}
HUNDREDS = [None, "san", "desan", "twasan", "katsan", "senksan", "sisan", "sètsan", "uisan", "nèfsan"]
LETTER = re.compile(r"[^\W\d_]", re.UNICODE)
# Tokens that appear in French but not in Kreyòl, used to flag French-language rows
FRENCH = {"les", "des", "est", "sont", "nous", "vous", "dans", "avec", "qui", "que", "qu", "une",
"cette", "ces", "il", "elle", "ils", "elles", "leur", "notre", "votre", "etait", "était",
"peut", "faire", "aussi", "donc", "alors", "tres", "très", "beaucoup", "toujours", "jamais",
"français", "francais", "haïti", "sur", "plus", "cet", "vers", "chez", "quelque", "le",
"du", "au", "aux", "ce", "vient", "suis", "sais", "veux", "avez", "avons", "ont", "été",
"comment", "pourquoi", "aujourd", "hui", "parce", "puis", "ensuite", "toute", "autre",
"meme", "même", "c", "j", "quand", "notamment", "ainsi"}
# Kreyòl writes only à, è and ò; any other accent is a French (or English) spelling
NON_KREYOL = re.compile(r"[éêëçùûîïôœæáíúñ]")
def spell_int(n):
"""Spell a non-negative integer below one million."""
if n < 20:
return UNITS[n]
if n < 100:
ten, unit = n // 10 * 10, n % 10
if ten == 70:
return "swasann" + UNITS[10 + unit]
if ten == 80:
return "katreven" if unit == 0 else f"katreven {UNITS[unit]}"
if ten == 90:
return "katreven" + UNITS[10 + unit]
if unit == 0:
return TENS[ten]
if unit == 1:
return HARD[ten] + "eyen"
if unit in (8, 9):
return HARD[ten] + UNITS[unit]
return NASAL[ten] + UNITS[unit]
if n < 1000:
hund, rest = n // 100, n % 100
return HUNDREDS[hund] + ("" if rest == 0 else " " + spell_int(rest))
thou, rest = n // 1000, n % 1000
head = "mil" if thou == 1 else f"{spell_int(thou)} mil"
return head + ("" if rest == 0 else " " + spell_int(rest))
def spell_digits(text):
"""Replace every run of digits with its Kreyòl words, plus the symbols that read as words."""
text = re.sub(r"(\d)\s*%", r"\1 pousan", text)
text = re.sub(r"(?<=\d)[ ,](?=\d{3}\b)", "", text) # 1 000 / 1,000 -> 1000
def one(m):
whole, frac = m.group(1), m.group(2)
n = int(whole)
out = spell_int(n) if n < 1_000_000 else " ".join(spell_int(int(c)) for c in whole)
if frac:
out += " pwen " + " ".join(spell_int(int(c)) for c in frac)
return out
return re.sub(r"(\d+)(?:[.,](\d+))?", one, text)
def modern_spelling(text):
"""Drop the apostrophes the older orthography used: `pa t' gen` -> `pa t gen`, `t'ap` -> `t ap`."""
t = text.replace("’", "'").replace("‘", "'").replace("ʼ", "'")
t = re.sub(r"(\w)'(?=[\s,.;:!?\"»”)\]]|$)", r"\1", t)
t = re.sub(r"(\w)'(?=\w)", r"\1 ", t)
return t
def normalize(text, lower=True, punct=False):
"""The canonical form used for both training targets and error rates."""
if not text:
return ""
t = unicodedata.normalize("NFC", text)
t = t.replace("", " ").replace("\xa0", " ")
t = re.sub(r"[“”«»]", '"', t).replace("–", "-").replace("—", "-").replace("…", " ")
t = modern_spelling(t)
t = spell_digits(t)
if lower:
t = t.lower()
if not punct:
t = re.sub(r"[^\w\s'-]", " ", t, flags=re.UNICODE)
t = re.sub(r"(?<!\w)[-']|[-'](?!\w)", " ", t)
return re.sub(r"\s+", " ", t).strip()
def metric_form(text):
"""Strip accents on top of `normalize`, so è/e and ò/o never decide a word error."""
t = unicodedata.normalize("NFKD", normalize(text))
return "".join(c for c in t if not unicodedata.combining(c))
def french_score(text):
"""Share of tokens that are French-only words; radio broadcasts switch into French mid-sentence."""
toks = [p for t in normalize(text).split() for p in t.split("-") if p]
if not toks:
return 0.0
return sum(t in FRENCH for t in toks) / len(toks)
def is_french(text, min_share=0.18):
"""A row written in French rather than Kreyòl.
Kreyòl prose about Haiti is full of French-spelled proper nouns (Éstimé, François, Déjoie), so the
accent test skips capitalised words and only judges ordinary ones.
Catches French *text*. It cannot catch French *speech* that a Kreyòl-only ASR wrote down as
Kreyòl-looking words, which is how French appears in the radio transcripts.
"""
ordinary = []
for word in text.split():
first = next((c for c in word if c.isalpha()), "")
if not first or first.islower():
ordinary.append(word)
return french_score(text) >= min_share or len(NON_KREYOL.findall(" ".join(ordinary))) >= 2
def suspect(text, duration=None):
"""Reasons a row is bad training data. Empty list means the row looks usable."""
why, norm = [], normalize(text)
if len(norm) < 2:
why.append("empty")
if re.search(r"\d", norm):
why.append("digits_left")
if is_french(text):
why.append("french")
letters = len(LETTER.findall(norm))
if letters and len(norm) and letters / len(norm) < 0.7:
why.append("low_letter_ratio")
if norm and max((len(w) for w in norm.split()), default=0) > 24:
why.append("long_token")
if duration:
rate = letters / duration
if rate < 5 or rate > 25:
why.append(f"rate_{rate:.0f}")
return why
if __name__ == "__main__":
for n in (0, 1, 8, 15, 21, 25, 31, 48, 70, 75, 80, 81, 90, 98, 100, 101, 250, 1000, 1804, 2026, 15000):
print(f"{n:>6} {spell_int(n)}")
for s in ["Jenèz chapit pemye. Istwa kreyasyon an.",
"Nan lane 1804, 25 moun te peye 50% nan 2,500 goud.",
"pa t' gen anyen, epi t'ap vini",
"Qu'est-ce que le vaudou, d'où vient-il?"]:
print(f"\n{s}\n-> {normalize(s)}\n french={french_score(s):.2f} suspect={suspect(s)}")
|