boffire's picture
Update app.py
71601f6 verified
Raw
History Blame Contribute Delete
11.4 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
app.py
Gradio demo for Kabyle POS Tagger v2.
Pre-splits punctuation and hyphenated clitics, strips hyphens from clitics
before tokenization, then applies a post-processing lookup table to fix
remaining clitic misclassifications.
"""
import re
import gradio as gr
from transformers import AutoTokenizer, AutoModelForTokenClassification
import torch
MODEL_NAME = "boffire/kabyle-pos-v2"
# Load tokenizer and model
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForTokenClassification.from_pretrained(MODEL_NAME)
model.eval()
id2label = model.config.id2label
# Universal Dependencies POS tagset with descriptions
POS_DESCRIPTIONS = {
"ADJ": "Adjective", "ADP": "Adposition", "ADV": "Adverb",
"AUX": "Auxiliary verb", "CCONJ": "Coordinating conjunction",
"DET": "Determiner", "INTJ": "Interjection", "NOUN": "Noun",
"NUM": "Numeral", "PART": "Particle", "PRON": "Pronoun",
"PROPN": "Proper noun", "PUNCT": "Punctuation",
"SCONJ": "Subordinating conjunction", "SYM": "Symbol",
"VERB": "Verb", "X": "Other"
}
# High-contrast color palette (dark backgrounds, light text)
POS_COLORS = {
"NOUN": "#1565c0", # Dark blue
"PROPN": "#0d47a1", # Darker blue
"PRON": "#0277bd", # Ocean blue
"VERB": "#2e7d32", # Forest green
"AUX": "#1b5e20", # Dark green
"ADJ": "#ef6c00", # Burnt orange
"ADV": "#f9a825", # Golden (dark text)
"ADP": "#6a1b9a", # Deep purple
"PART": "#ad1457", # Dark pink
"DET": "#c62828", # Dark red
"NUM": "#00838f", # Teal
"CCONJ": "#00695c", # Dark cyan
"SCONJ": "#004d40", # Darker cyan
"INTJ": "#d84315", # Deep orange
"PUNCT": "#455a64", # Blue grey
"SYM": "#37474f", # Darker blue grey
"X": "#5d4037", # Brown
}
# Text colors
POS_TEXT_COLORS = {
"NOUN": "#ffffff",
"PROPN": "#ffffff",
"PRON": "#ffffff",
"VERB": "#ffffff",
"AUX": "#ffffff",
"ADJ": "#ffffff",
"ADV": "#000000", # Dark text on yellow
"ADP": "#ffffff",
"PART": "#ffffff",
"DET": "#ffffff",
"NUM": "#ffffff",
"CCONJ": "#ffffff",
"SCONJ": "#ffffff",
"INTJ": "#ffffff",
"PUNCT": "#ffffff",
"SYM": "#ffffff",
"X": "#ffffff",
}
# =============================================================================
# POST-PROCESSING: Clitic Lookup Table
# =============================================================================
# These morphemes are closed-class and their POS is deterministic.
# We split into two tiers:
# 1. Unambiguous forms (override always): multi-letter clitics and the
# copula/directional particle "d".
# 2. Hyphen-only forms (override only when hyphenated): short subject
# affixes and preposition-like clitics where standalone usage differs
# from affix usage (e.g., "i" = ADP preposition, "i-" = PRON subject).
# -----------------------------------------------------------------------------
CLITIC_POS = {
# --- Unambiguous: override regardless of hyphenation ---
"d": "PART", # Directional / copula particle
# Possessive plural
"nneɣ": "PRON", "neɣ": "PRON", "nteɣ": "PRON", "nnteɣ": "PRON",
"wen": "PRON", "nwen": "PRON",
"nkent": "PRON", "tkent": "PRON",
"nsen": "PRON", "tsen": "PRON",
"nsent": "PRON", "tsent": "PRON",
"nnek": "PRON", "nnem": "PRON", "nnes": "PRON",
# Accusative / dative
"iyi": "PRON", "yi": "PRON", "ayi": "PRON",
"kem": "PRON", "akem": "PRON",
"tt": "PRON", "itt": "PRON",
"aɣ": "PRON", "yaɣ": "PRON",
"ken": "PRON",
"akent": "PRON",
"ten": "PRON",
"ak": "PRON",
"am": "PRON",
"as": "PRON", "asen": "PRON",
"aneɣ": "PRON", "anaɣ": "PRON", "yanaɣ": "PRON",
"atneɣ": "PRON", "atenteɣ": "PRON",
"awen": "PRON", "atwen": "PRON",
"atkent": "PRON",
"atsen": "PRON", "sen": "PRON",
"asent": "PRON", "atsent": "PRON",
# Demonstratives / determiners
"agi": "DET", "a": "DET",
"nni": "DET",
"nniḍen": "DET", "niḍen": "DET",
}
# Short affixes that are ambiguous when standalone (e.g., "i" = preposition ADP,
# "i-" = subject pronoun PRON). Only override if the user wrote them hyphenated.
CLITIC_POS_HYPHEN_ONLY = {
"ɣ": "PRON", # 1st sg subject affix
"t": "PRON", # 2nd sg / 3rd fem sg subject affix
"k": "PRON", # 2nd masc sg
"m": "PRON", # 2nd fem sg
"n": "PRON", # 1st pl / 3rd masc pl subject affix
"i": "PRON", # 3rd masc sg subject affix (vs. standalone prep.)
"w": "PRON", "iw": "PRON", "inu": "PRON",
"ik": "PRON",
"im": "PRON",
"is": "PRON", # possessive / dative
"kent": "PRON",
"sen": "PRON", "sent": "PRON",
}
def apply_clitic_override(results):
"""
Post-process model predictions using the clitic lookup table.
Overrides known closed-class morphemes to their linguistically correct POS.
"""
for i, token in enumerate(results):
word = token["word"]
stripped = word.strip('-')
# Skip empty or pure punctuation
if not stripped or stripped in CLITIC_POS.get("PUNCT", {}):
continue
# Tier 1: unambiguous forms (override always)
if stripped in CLITIC_POS:
token["entity_group"] = CLITIC_POS[stripped]
token["score"] = max(token["score"], 0.99)
continue
# Tier 2: short affixes — only override if hyphenated (affix context)
if word.startswith('-') or word.endswith('-'):
if stripped in CLITIC_POS_HYPHEN_ONLY:
token["entity_group"] = CLITIC_POS_HYPHEN_ONLY[stripped]
token["score"] = max(token["score"], 0.99)
return results
def tag_text(text):
if not text or not text.strip():
return "", "Please enter some Kabyle text."
try:
# 1. Split text into words, hyphenated clitics, and punctuation
raw_tokens = re.findall(r"-?[\w'’]+|[^\w\s'’-]", text.strip(), re.UNICODE)
display_tokens = []
model_tokens = []
for tok in raw_tokens:
if tok == "-":
display_tokens.append(tok)
model_tokens.append(tok)
elif tok.startswith('-') and len(tok) > 1:
display_tokens.append(tok) # UI: "-nneɣ"
model_tokens.append(tok[1:]) # Model: "nneɣ"
elif tok.endswith('-') and len(tok) > 1:
display_tokens.append(tok) # UI: "akent-"
model_tokens.append(tok[:-1]) # Model: "akent"
else:
display_tokens.append(tok)
model_tokens.append(tok)
# 2. Tokenize the model tokens
inputs = tokenizer(
model_tokens,
is_split_into_words=True,
return_tensors="pt",
return_offsets_mapping=False,
)
word_ids = inputs.word_ids(batch_index=0)
with torch.no_grad():
outputs = model(**inputs)
predictions = torch.argmax(outputs.logits, dim=-1)[0].tolist()
scores = torch.softmax(outputs.logits, dim=-1)[0].max(dim=-1).values.tolist()
# 3. Group subword pieces by original word index
word_groups = {}
for idx, wid in enumerate(word_ids):
if wid is None:
continue
if wid not in word_groups:
word_groups[wid] = {"labels": [], "scores": []}
word_groups[wid]["labels"].append(id2label[predictions[idx]])
word_groups[wid]["scores"].append(scores[idx])
# 4. Build results with majority voting per word
results = []
for wid in sorted(word_groups.keys()):
info = word_groups[wid]
word_text = display_tokens[wid]
counts = {}
for lbl in info["labels"]:
counts[lbl] = counts.get(lbl, 0) + 1
majority = max(counts, key=counts.get)
avg_score = sum(info["scores"]) / len(info["scores"])
results.append({
"word": word_text,
"entity_group": majority,
"score": avg_score
})
# 5. Apply post-processing clitic lookup table
results = apply_clitic_override(results)
except Exception as e:
return "", f"Error: {str(e)}"
if not results:
return "", "No tokens found."
# HTML visualization
html_parts = ['<<div style="font-size: 1.15rem; line-height: 2.2; font-family: system-ui, sans-serif; padding: 10px;">']
for token in results:
word = token["word"]
label = token["entity_group"]
score = token["score"]
bg_color = POS_COLORS.get(label, "#333333")
text_color = POS_TEXT_COLORS.get(label, "#ffffff")
html_parts.append(
'<span style="display: inline-block; margin: 3px; vertical-align: top; box-shadow: 0 1px 3px rgba(0,0,0,0.3);">'
'<span style="background: ' + bg_color + '; color: ' + text_color + '; border-radius: 6px 6px 0 0; padding: 5px 10px; display: block; text-align: center; font-weight: 600; font-size: 1.1rem;">'
+ word +
'</span>'
'<span style="background: #1a1a1a; color: #fff; border-radius: 0 0 6px 6px; padding: 3px 10px; display: block; text-align: center; font-size: 0.8rem; font-weight: 500;">'
+ label + ' <span style="opacity: 0.7;">(' + f"{score:.2f}" + ')</span>'
'</span>'
'</span>'
)
html_parts.append('</div>')
# Markdown table
table_lines = [
"| Token | POS Tag | Description | Confidence |",
"|-------|---------|-------------|------------|"
]
for token in results:
word = token["word"]
label = token["entity_group"]
desc = POS_DESCRIPTIONS.get(label, label)
score = token["score"]
table_lines.append(f"| {word} | `{label}` | {desc} | {score:.3f} |")
return "\n".join(html_parts), "\n".join(table_lines)
examples = [
"Aṭas n medden i yessen.",
"Taqbaylit d tutlayt deg Lezzayer.",
"Yella wuccen ameqqran deg taddart.",
"Tameddakelt-nneɣ teɣra adlis-is.",
"D nekkni i d-yusan d imezwura.",
]
demo = gr.Interface(
fn=tag_text,
inputs=gr.Textbox(
label="Kabyle Text",
placeholder="Enter a sentence in Kabyle (e.g., Aṭas n medden i yessen.)",
lines=2
),
outputs=[
gr.HTML(label="Tagged Visualization"),
gr.Markdown(label="Results Table")
],
title="Kabyle POS Tagger v2",
description="""
<div style="text-align: center;">
<h2>Kabyle Part-of-Speech Tagger</h2>
<p>Enter a sentence in <strong>Kabyle</strong> (Berber language) to see POS tags predicted by
<a href="https://huggingface.co/boffire/kabyle-pos-v2" target="_blank">boffire/kabyle-pos-v2</a>
(XLM-RoBERTa-base, Test F1: 93.8%).</p>
<p style="font-size: 0.9rem; color: #666;">
Tags follow the <a href="https://universaldependencies.org/u/pos/" target="_blank">Universal Dependencies</a> POS tagset.
</p>
</div>
""",
examples=examples,
)
if __name__ == "__main__":
demo.launch()