des-abrasileirador / portugality.py
Ana Pinto
Add Des-abrasileirador pt-PT text corrector
1c939fa
Raw
History Blame Contribute Delete
6.29 kB
"""Portugality Index (IPT) - a deterministic pt-PT nativeness metric.
The score counts Brazilian-Portuguese markers in a text and normalises them by
length. It uses no LLM, so it is fully reproducible and auditable, and it never
uses AMALIA (or any model under test) as a judge - which would be circular.
IPT = 100 * exp(-6 * weighted_marker_density)
0 markers -> 100 (fully European); heavy pt-BR -> low score.
The word lists below are pt-BR *data*, not code, so they stay in Portuguese.
"""
import math
import re
# --- Brazilian lexicon (single words) -------------------------------------
# The PT equivalent is kept as a comment only to document the pair; the score
# only cares about the presence of the BR form.
BR_LEXICON = {
"ônibus", "celular", "geladeira", "banheiro", "trem", "bonde", "sorvete",
"suco", "xícara", "açougue", "sacola", "terno", "aeromoça", "pedestre",
"usuário", "usuários", "arquivo", "arquivos", "senha", "tela", "mouse",
"time", "esporte", "planejamento", "registro", "bala", "bacana",
"presunto",
}
# Multi-word Brazilian expressions.
BR_LEXICON_MULTIWORD = {
"café da manhã", "ponto de ônibus", "faixa de pedestres",
"carteira de motorista",
}
# --- Brazilian spelling (pre-AO90 accents / consonant drops) ---------------
# NOTE: some forms such as "ótimo" are also valid under AO90 in PT; keep the
# list conservative so the score is defensible.
BR_SPELLING = {
"econômico", "gênero", "tênis", "quilômetro", "recepção", "concepção",
"aspecto", "úmido", "antônio", "fenômeno", "gênio", "efêmero",
# pre-AO90 accented forms, dropped in the PT norm:
"idéia", "assembléia", "platéia", "heróico", "vôo", "enjôo",
}
# --- Grammar patterns ------------------------------------------------------
GERUND_RE = re.compile(
r"\b(estou|está|estás|estamos|estão|estava|estavam|estavas|"
r"vou|vai|vais|vamos|vão|fico|fica|ficam|continua\w*|segue|seguem)"
r"\s+\w+ndo\b",
re.IGNORECASE,
)
VOCE_RE = re.compile(r"\bvocês?\b", re.IGNORECASE)
A_GENTE_RE = re.compile(r"\ba\s+gente\b", re.IGNORECASE)
# Sentence-initial proclisis ("Me chamo", "Se chama"...), non-native in pt-PT.
PROCLISIS_START_RE = re.compile(
r"(^|[.!?]\s+)(me|te|se|nos|lhe|lhes)\s+\w+", re.IGNORECASE
)
WORD_RE = re.compile(r"\b[\wàáâãéêíóôõúç]+\b")
WEIGHTS = {
"lexicon": 1.0,
"spelling": 1.0,
"gerund": 0.7,
"address": 0.5,
"proclisis": 0.5,
}
def _collect(text):
"""Return (word_list, hits) where hits maps a category to matched strings."""
lowered = text.lower()
words = WORD_RE.findall(lowered)
hits = {key: [] for key in WEIGHTS}
for word in words:
if word in BR_LEXICON:
hits["lexicon"].append(word)
if word in BR_SPELLING:
hits["spelling"].append(word)
for expr in BR_LEXICON_MULTIWORD:
if expr in lowered:
hits["lexicon"].append(expr)
hits["gerund"] = [m.group(0) for m in GERUND_RE.finditer(lowered)]
hits["address"] = VOCE_RE.findall(lowered) + [
m.group(0) for m in A_GENTE_RE.finditer(lowered)
]
hits["proclisis"] = [m.group(0).strip() for m in PROCLISIS_START_RE.finditer(text)]
return words, hits
def portugality(text):
"""Return (score in 0-100, breakdown dict). Deterministic, no GPU."""
words, hits = _collect(text)
n_words = max(len(words), 1)
weighted = sum(WEIGHTS[key] * len(matches) for key, matches in hits.items())
density = weighted / n_words
score = round(100 * math.exp(-6 * density), 1)
breakdown = {
"n_words": n_words,
"weighted_markers": round(weighted, 2),
"density": round(density, 4),
**{key: hits[key] for key in hits},
}
return score, breakdown
def looks_like_correction(original, corrected):
"""Heuristic sanity check: does ``corrected`` look like a rewrite of
``original``? Guards the benchmark against degenerate outputs (tag soup,
endless repetition, empty answers), which would otherwise get a *high*
IPT simply because garbage contains no Brazilian markers."""
if not corrected or not corrected.strip():
return False
n_orig = max(len(WORD_RE.findall(original.lower())), 1)
n_corr = len(WORD_RE.findall(corrected.lower()))
if not 0.5 * n_orig <= n_corr <= 3 * n_orig:
return False
letters = sum(1 for ch in corrected if ch.isalpha() or ch.isspace())
return letters / len(corrected) >= 0.5
def compare_table(results, reference=None):
"""Turn ``{model_name: corrected_text}`` into ranked rows for a dataframe.
Each row: model, IPT, weighted_markers, delta_vs_ref, corrected, breakdown.
Rows are sorted by descending IPT.
"""
rows = []
for name, text in results.items():
score, breakdown = portugality(text)
rows.append(
{
"model": name,
"IPT": score,
"weighted_markers": breakdown["weighted_markers"],
"corrected": text,
"breakdown": breakdown,
}
)
rows.sort(key=lambda row: -row["IPT"])
base = next((row["IPT"] for row in rows if row["model"] == reference), None)
for row in rows:
if base is not None and row["model"] != reference:
row["delta_vs_ref"] = round(row["IPT"] - base, 1)
else:
row["delta_vs_ref"] = 0.0
return rows
def compare(results, reference="EuroLLM-9B"):
"""Human-readable ranking string (handy for CLI / logs)."""
rows = compare_table(results, reference)
lines = []
for row in rows:
delta = ""
if row["model"] != reference and row["delta_vs_ref"]:
delta = f" ({row['delta_vs_ref']:+.1f} vs {reference})"
lines.append(
f"{row['model']:28s} IPT={row['IPT']:5.1f} "
f"markers={row['weighted_markers']:4.1f}{delta}"
)
return "\n".join(lines)
if __name__ == "__main__":
demo = {
"AMALIA-9B": "Vou apanhar o autocarro e tomar o pequeno-almoço.",
"EuroLLM-9B": "Vou pegar o autocarro e tomar o pequeno-almoço.",
"Llama-3.1": "Vou pegar o ônibus e tomar café da manhã.",
}
print(compare(demo, reference="EuroLLM-9B"))