HateSpeech / term_extractor.py
Matias29
Fix: lexicon-anchored Amharic terms, no fragments
cceeb09
Raw
History Blame Contribute Delete
18.7 kB
"""
term_extractor.py -- LLM + XLMR hate term extraction.
English: Qwen LLM (when cached) or XLMR similarity fallback
Amharic: Afro-XLMR semantic similarity (n-gram + similarity scoring)
Oromo: Afro-XLMR semantic similarity (n-gram + similarity scoring)
"""
import os, sys, re, json
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
def detect_language(text):
am = sum(1 for c in str(text) if "\u1200" <= c <= "\u137f")
if am > 2:
return "amharic"
oromo_markers = ["dha", "keenya", "biyya", "qabna", "jirti", "isaan", "kana",
"qabna", "barbaachisaa", "balleessaa", "hordoftoota"]
if any(w in str(text).lower().split() for w in oromo_markers):
return "oromo"
return "english"
def _get_llm(lang="english"):
import torch
hf_token = os.environ.get("HF_TOKEN", None)
_pn = sys.modules.get("pattern_namer")
_reg = getattr(_pn, "_model_registry", {}) if _pn else {}
if lang == "amharic" and "CohereLabs/aya-expanse-8b" in _reg:
return _reg["CohereLabs/aya-expanse-8b"]
model_name = "Qwen/Qwen2.5-1.5B-Instruct"
if model_name in _reg:
return _reg[model_name]
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained(model_name, token=hf_token)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.bfloat16, device_map="auto", token=hf_token
)
model.eval()
if _pn is not None:
if not hasattr(_pn, "_model_registry"):
_pn._model_registry = {}
_pn._model_registry[model_name] = (tokenizer, model)
return tokenizer, model
_COMMON_SINGLES = {
"islam","muslim","muslims","christian","christians","orthodox",
"jewish","jew","oromo","amhara","tigray","ethiopia","ethiopian",
"kill","destroy","destroying","hate","disease","culture","women",
"church","churches","mosque","mosques","religion","people",
}
def _get_xlmr_enc():
"""Get XLMR encoder -- tries ml_engine first, then loads directly."""
import sys
_me = sys.modules.get("detector.ml_engine")
if _me is None:
try:
import detector.ml_engine as _me
except Exception:
return None
enc = getattr(_me, "_xlmr_enc", None)
if enc is None:
try:
_me._clf = None
_me._load()
enc = getattr(_me, "_xlmr_enc", None)
except Exception as e:
print(f" [xlmr] load failed: {e}")
return enc
def _is_meaningful_gram(gram, source_text=""):
"""Filter out grammatical fragments that carry no standalone hate signal."""
words = gram.split()
# Ethiopic: reject if all words are very short (likely particles/conjunctions)
am_words = [w for w in words if any("\u1200" <= c <= "\u137f" for c in w)]
if am_words and all(len(w) <= 3 for w in am_words):
return False
# Reject pure connectors (Amharic common particles)
AM_PARTICLES = {"ነው","ናቸው","ነበር","ይሆናል","ነበሩ","ሲሆን","እና","ወይም",
"ግን","ስለዚህ","ነገር","ሆኖም","አለ","አሉ","ነን","ናቸን"}
if am_words and all(w in AM_PARTICLES for w in am_words):
return False
# Latin: reject if all words are stopwords or particles
lat_words = [w for w in words if all("a" <= c <= "z" for c in w.lower())]
LAT_PARTICLES = {"the","a","an","is","it","in","on","at","to","for","of",
"and","or","but","not","are","was","be","as","by","with",
"from","have","has","they","we","dha","fi","irratti"}
if lat_words and all(w.lower() in LAT_PARTICLES for w in lat_words):
return False
return True
def _xlmr_score(ngrams, hate_anchors, threshold=0.75, max_keep=10):
"""Score n-grams against hate anchors using XLMR similarity."""
import torch
import torch.nn.functional as F
import numpy as np
enc = _get_xlmr_enc()
if enc is None:
return []
def encode(texts):
arr = enc._encode_batch(texts)
embs = torch.tensor(np.array(arr), dtype=torch.float32)
return F.normalize(embs, dim=-1)
# Pre-filter nonsense grams before scoring
filtered_ngrams = {g: v for g, v in ngrams.items()
if _is_meaningful_gram(g, v.get("text",""))}
if not filtered_ngrams:
return []
gram_list = list(filtered_ngrams.keys())[:40]
anchor_embs = encode(hate_anchors)
gram_embs = encode(gram_list)
sims = torch.mm(gram_embs, anchor_embs.T)
best_sims = sims.max(dim=1).values
scored = sorted(
[(gram_list[i], best_sims[i].item()) for i in range(len(gram_list))
if best_sims[i].item() >= threshold],
key=lambda x: -x[1]
)
kept_terms = []
results = []
for gram, sim in scored:
g_words = gram.split()
already_covered = any(
all(w in k.split() for w in g_words) and len(k.split()) > len(g_words)
for k in kept_terms
)
if already_covered:
continue
kept_terms.append(gram)
results.append((gram, sim))
if len(results) >= max_keep:
break
return results
def _get_lexicon_words():
"""Get known hate words from the lexicon DB for anchor filtering."""
try:
from detector.models import LexiconEntry
words = set()
for term in LexiconEntry.objects.exclude(
grouped_label="Normal").values_list("term", flat=True):
for w in str(term).strip().split():
if len(w) > 2:
words.add(w.lower())
return words
except Exception:
return set()
def _extract_amharic_terms(batch, existing_lower, all_results):
HATE_ANCHORS_AM = [
"\u12a0\u123b\u1263\u122a\u12cd\u127d",
"\u130b\u120b\u1276\u127b\u12c8\u1295",
"\u12cd\u123b \u1293\u1278\u12c8",
"\u1218\u1263\u1228\u122d \u12a0\u1208\u1263\u1278\u12c8",
"\u12ad\u122d\u1235\u1272\u12eb\u1293\u127d\u1295 \u12eb\u1243\u1325\u120b\u120d",
"\u130d\u1295\u1299\u12cd\u1295 \u1270\u12cd\u1290\u1271",
"\u1208\u1240\u12c6\u1295 \u1270\u12cd\u1290\u1271",
]
ngrams = {}
for r in batch:
text = r.get("text", "")
label = r.get("label") or r.get("predicted_label", "Identity-Based Hate")
words = re.findall(r"[\u1200-\u137f]+", text)
for n in range(2, 5):
for i in range(len(words) - n + 1):
gram = " ".join(words[i:i+n])
if gram.lower() not in existing_lower and gram not in ngrams:
ngrams[gram] = {"text": text, "label": label}
if not ngrams:
return
try:
# Only keep n-grams containing at least one known hate word from lexicon
lexicon_words = _get_lexicon_words()
if lexicon_words:
filtered = {}
for gram, meta in ngrams.items():
gram_words = {w.lower() for w in gram.split()}
if gram_words & lexicon_words:
filtered[gram] = meta
ngrams = filtered if filtered else ngrams
print(f" [amharic_extract] {len(ngrams)} n-grams contain lexicon words")
scored = _xlmr_score(ngrams, HATE_ANCHORS_AM, threshold=0.78)
for gram, sim in scored:
if any(c["term"].lower() == gram.lower() for c in all_results):
continue
# Prefer shorter focused phrases over long sentence fragments
if len(gram.split()) > 4:
continue
meta = ngrams[gram]
all_results.append({
"term": gram,
"suggested_label": meta["label"],
"extraction_method": "amharic_xlmr",
"score": round(sim, 3),
"source_text": meta["text"][:300],
"language": "amharic",
})
print(f" [amharic_extract] sim={sim:.3f} Kept: \'{gram}\'")
except Exception as e:
print(f" [amharic_extract] Error: {e}")
def _extract_oromo_terms(batch, existing_lower, all_results):
HATE_ANCHORS_OM = [
"diina keenya",
"ari\'uu qabna",
"baasuun barbaachisaa",
"ajjeesuu qabna",
"balleessuun barbaachisaa",
"gad aanaa dha",
"biyya keenya keessaa",
]
EN_STOP = {"the","a","an","is","it","in","on","at","to","for","of","and",
"or","but","not","are","was","be","as","by","this","that","with",
"from","have","has","they","we","you","i","he","she","our","their"}
ngrams = {}
for r in batch:
text = r.get("text", "")
label = r.get("label") or r.get("predicted_label", "Identity-Based Hate")
words = [w for w in re.findall(r"[a-zA-Z\']+", text.lower())
if len(w) > 3 and w not in EN_STOP]
for n in range(2, 5):
for i in range(len(words) - n + 1):
gram = " ".join(words[i:i+n])
if gram not in existing_lower and gram not in ngrams:
ngrams[gram] = {"text": text, "label": label}
if not ngrams:
return
try:
scored = _xlmr_score(ngrams, HATE_ANCHORS_OM)
for gram, sim in scored:
if any(c["term"].lower() == gram.lower() for c in all_results):
continue
meta = ngrams[gram]
all_results.append({
"term": gram,
"suggested_label": meta["label"],
"extraction_method": "oromo_xlmr",
"score": round(sim, 3),
"source_text": meta["text"][:300],
"language": "oromo",
})
print(f" [oromo_extract] sim={sim:.3f} Kept: \'{gram}\'")
except Exception as e:
print(f" [oromo_extract] Error: {e}")
def _extract_english_terms_xlmr(batch, existing_lower, all_results):
HATE_ANCHORS_EN = [
"kill them all",
"must be expelled",
"should be burned",
"muslim savages",
"islamic extremists",
"jihadists terrorists",
"subhuman parasites",
"expel them all",
"infidels must die",
"ethnic cleansing",
"cockroaches vermin",
"must be eliminated",
]
EN_STOP = {"the","a","an","is","it","in","on","at","to","for","of","and",
"or","but","not","are","was","be","as","by","this","that","with",
"from","have","has","they","we","you","i","he","she","our","their",
"these","those","who","what","should","would","could","will","all"}
ngrams = {}
for r in batch:
text = r.get("text", "")
label = r.get("label") or r.get("predicted_label", "Identity-Based Hate")
words = [w.lower() for w in re.findall(r"[a-zA-Z\']+", text)
if len(w) > 2 and w.lower() not in EN_STOP]
for n in range(2, 5):
for i in range(len(words) - n + 1):
gram = " ".join(words[i:i+n])
if gram not in existing_lower and gram not in ngrams:
ngrams[gram] = {"text": text, "label": label}
if not ngrams:
return
try:
scored = _xlmr_score(ngrams, HATE_ANCHORS_EN, threshold=0.72)
for gram, sim in scored:
if any(c["term"].lower() == gram.lower() for c in all_results):
continue
all_results.append({
"term": gram,
"suggested_label": ngrams[gram]["label"],
"extraction_method": "english_xlmr",
"score": round(sim, 3),
"source_text": ngrams[gram]["text"][:300],
"language": "english",
})
print(f" [english_xlmr] sim={sim:.3f} Kept: \'{gram}\'")
except Exception as e:
print(f" [english_xlmr] Error: {e}")
def extract_candidates(hate_flagged_rows, normal_rows=None,
existing_terms=None, max_candidates=40):
"""LLM + XLMR extraction with language routing."""
if not hate_flagged_rows:
return []
if existing_terms is None:
try:
from detector.models import LexiconEntry
existing_terms = set(LexiconEntry.objects.values_list("term", flat=True))
except Exception:
existing_terms = set()
existing_lower = {t.lower().strip() for t in existing_terms}
by_lang = {"english": [], "amharic": [], "oromo": []}
for r in hate_flagged_rows:
by_lang[detect_language(r.get("text", ""))].append(r)
all_results = []
batch_size = 20
lang_batches = []
for lang_key, lang_rows in [("english", by_lang["english"]),
("amharic", by_lang["amharic"]),
("oromo", by_lang["oromo"])]:
for start in range(0, len(lang_rows), batch_size):
b = lang_rows[start:start + batch_size]
if b:
lang_batches.append((lang_key, b))
for lang_key, batch in lang_batches:
if lang_key != "english":
continue
# Check if Qwen is available
_pn = sys.modules.get("pattern_namer")
_reg = getattr(_pn, "_model_registry", {}) if _pn else {}
_qwen = "Qwen/Qwen2.5-1.5B-Instruct" in _reg
if not _qwen:
print(f" [llm_extract] Qwen not cached -- using XLMR for English")
_extract_english_terms_xlmr(batch, existing_lower, all_results)
continue
try:
import torch
tokenizer, model = _get_llm("english")
print(f" [llm_extract] Analyzing {len(batch)} texts (english)...")
texts_block = chr(10).join(
f"{i+1}. {r.get(chr(116)+chr(101)+chr(120)+chr(116), chr(39)+chr(39))[:250]}"
for i, r in enumerate(batch)
)
texts_block = chr(10).join(
f"{i+1}. {r.get('text', '')[:250]}" for i, r in enumerate(batch)
)
prompt = (
"Extract the key hate-bearing PHRASES (2-5 words max) from these texts." + chr(10)
+ "Extract the specific slur, insult, or violent phrase -- NOT the whole sentence." + chr(10)
+ "Copy words exactly as written. Output a flat JSON array only." + chr(10) + chr(10)
+ "Example:" + chr(10)
+ "Input: These muslim savages must be expelled from our country" + chr(10)
+ "Output: [" + chr(34) + "muslim savages" + chr(34)
+ ", " + chr(34) + "must be expelled" + chr(34) + "]" + chr(10) + chr(10)
+ "Texts:" + chr(10) + texts_block + chr(10) + chr(10)
+ "Output (2-5 word phrases only, flat JSON array):"
)
messages = [{"role": "user", "content": prompt}]
try:
text_input = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True)
except Exception:
text_input = prompt
inputs = tokenizer(text_input, return_tensors="pt",
truncation=True, max_length=3500).to(model.device)
with torch.no_grad():
gen_ids = model.generate(**inputs, max_new_tokens=400, do_sample=False,
pad_token_id=tokenizer.pad_token_id)
raw = tokenizer.decode(gen_ids[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True).strip()
print(f" [llm_extract] Response: {raw[:100]}")
match = re.search(r"\[.*?\]", raw, re.DOTALL)
if not match:
continue
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError:
parsed = re.findall(r'"\'([^\']{3,})\"\'', raw)
terms = []
for item in parsed:
if isinstance(item, list):
terms.extend([x for x in item if isinstance(x, str)])
elif isinstance(item, str):
terms.append(item)
batch_text_lower = " ".join(r.get("text", "").lower() for r in batch)
for term in terms:
if not isinstance(term, str) or len(term.strip()) < 3:
continue
t_clean = term.strip()
if t_clean.lower() in existing_lower:
continue
if t_clean.lower() not in batch_text_lower:
print(f" [llm_extract] Rejected hallucination: {t_clean}")
continue
if len(t_clean.split()) == 1 and t_clean.lower() in _COMMON_SINGLES:
print(f" [llm_extract] Rejected common single word: {t_clean}")
continue
if len(t_clean.split()) > 6:
print(f" [llm_extract] Rejected full sentence: {t_clean[:50]}")
continue
if any(c["term"].lower() == t_clean.lower() for c in all_results):
continue
source, label = "", "Identity-Based Hate"
for r in batch:
if t_clean.lower() in r.get("text", "").lower():
source = r.get("text", "")[:300]
label = r.get("label") or r.get("predicted_label", "Identity-Based Hate")
break
all_results.append({
"term": t_clean,
"suggested_label": label,
"extraction_method": "llm_direct",
"score": 8.0,
"source_text": source,
"language": detect_language(t_clean),
})
except Exception as e:
print(f" [llm_extract] Error: {e}")
_extract_english_terms_xlmr(batch, existing_lower, all_results)
# XLMR extraction for Amharic and Oromo
for lang_key, batch in lang_batches:
if lang_key == "amharic":
_extract_amharic_terms(batch, existing_lower, all_results)
elif lang_key == "oromo":
_extract_oromo_terms(batch, existing_lower, all_results)
print(f" [llm_extract] Total extracted: {len(all_results)}")
return all_results[:max_candidates]