JeyBii's picture
Increase translation-bias dampening to 0.25 for proper calibration
1f34523 verified
Raw
History Blame Contribute Delete
20.5 kB
"""
Internal Checker β€” ML-based fake news detection + bias analysis
================================================================
Combines:
1. Fake News Validation: Random Forest with hybrid features
(TF-IDF + MiniLM embeddings + stylometric)
2. Bias Analysis: Heuristic-based political leaning + subjectivity scoring
"""
import os
import re
import numpy as np
import joblib
from scipy.sparse import hstack, csr_matrix
from textblob import TextBlob
import textstat
from sentence_transformers import SentenceTransformer
from checker.internal.bias_analyzer import analyze_bias
from checker.internal.structure_analyzer import analyze_structure
# Paths
PROJECT_ROOT = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
DATA_MODELS_DIR = os.path.join(PROJECT_ROOT, "data_models")
# ── MiniLM Model (lazy-loaded singleton) ──
MINILM_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2"
_minilm_model = None
def get_minilm_model():
"""Load the multilingual MiniLM model (cached after first call)."""
global _minilm_model
if _minilm_model is None:
_minilm_model = SentenceTransformer(MINILM_MODEL_NAME)
return _minilm_model
def clean_text(text):
"""Clean text to match the training preprocessing."""
if not text or not isinstance(text, str):
return ""
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"https?://\S+", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
# ── Word lists for linguistic features ──
FIRST_PERSON_PRONOUNS = {
"i",
"me",
"my",
"mine",
"myself",
"we",
"us",
"our",
"ours",
"ourselves",
"ako",
"ko",
"akin",
"aking",
"natin",
"atin",
"namin",
"amin",
"tayo",
"kami",
"ta",
}
AUXILIARY_VERBS = {
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"shall",
"should",
"may",
"might",
"can",
"could",
"must",
"am",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"ay",
"dapat",
"mayroon",
"meron",
"maaari",
"pwede",
"kailangan",
}
ANALYTICAL_WORDS = {
"the",
"a",
"an",
"of",
"in",
"on",
"at",
"to",
"for",
"with",
"by",
"from",
"about",
"between",
"through",
"during",
"before",
"after",
"ang",
"ng",
"sa",
"mga",
"nang",
"para",
"tungkol",
"mula",
}
CERTAINTY_WORDS = {
"always",
"never",
"absolutely",
"definitely",
"certainly",
"undoubtedly",
"clearly",
"obviously",
"without doubt",
"guaranteed",
"proven",
"fact",
"undeniable",
"indisputable",
"every",
"all",
"palagi",
"sigurado",
"tiyak",
"talaga",
"totoo",
"lagi",
"walang duda",
}
TENTATIVE_WORDS = {
"perhaps",
"maybe",
"possibly",
"might",
"could",
"likely",
"unlikely",
"suggests",
"appears",
"seems",
"allegedly",
"reportedly",
"according",
"probable",
"approximately",
"estimated",
"siguro",
"marahil",
"maaaring",
"mukhang",
"parang",
"umano",
"diumano",
}
CLOUT_WORDS = {
"must",
"demand",
"require",
"order",
"command",
"insist",
"decree",
"mandate",
"authority",
"power",
"control",
"dominant",
"superior",
"we must",
"you must",
"kailangan",
"dapat",
"utos",
"kapangyarihan",
"kontrol",
"mando",
}
PAST_FOCUS_WORDS = {
"talked",
"did",
"ago",
"said",
"was",
"were",
"had",
"went",
"told",
"noon",
"nakaraan",
"dati",
"kahapon",
}
PRESENT_FOCUS_WORDS = {
"now",
"is",
"today",
"are",
"being",
"currently",
"ongoing",
"ngayon",
"kasalukuyan",
}
FUTURE_FOCUS_WORDS = {
"soon",
"will",
"may",
"shall",
"going",
"plan",
"expect",
"tomorrow",
"bukas",
"darating",
"magiging",
"gagawin",
}
def extract_stylometric_features(text):
"""Extract the same 25 stylometric features used during training."""
if not text or not isinstance(text, str):
return [0.0] * 25
words = text.split()
token_count = len(words)
if token_count == 0:
return [0.0] * 25
words_lower = [w.lower() for w in words]
text_len = len(text)
exclamation_density = text.count("!") / token_count
question_count = text.count("?")
caps_words = sum(1 for w in words if len(w) >= 2 and w.isupper())
caps_ratio = caps_words / token_count
sentences = re.split(r"[.!?]+", text)
sentences = [s.strip() for s in sentences if s.strip()]
avg_sentence_length = (
sum(len(s.split()) for s in sentences) / len(sentences)
if sentences
else token_count
)
punct_chars = sum(1 for c in text if c in ".,;:!?-\"'()[]{}...")
punctuation_density = (punct_chars / text_len) * 100 if text_len > 0 else 0
unique_words = len(set(words_lower))
unique_word_ratio = unique_words / token_count
avg_word_length = sum(len(w) for w in words) / token_count
try:
subjectivity = TextBlob(text).sentiment.subjectivity
except Exception:
subjectivity = 0.0
try:
flesch_reading_ease = textstat.flesch_reading_ease(text)
flesch_kincaid_grade = textstat.flesch_kincaid_grade(text)
coleman_liau_index = textstat.coleman_liau_index(text)
ari = textstat.automated_readability_index(text)
except Exception:
flesch_reading_ease = 0.0
flesch_kincaid_grade = 0.0
coleman_liau_index = 0.0
ari = 0.0
first_person_count = sum(1 for w in words_lower if w in FIRST_PERSON_PRONOUNS)
first_person_ratio = first_person_count / token_count
aux_count = sum(1 for w in words_lower if w in AUXILIARY_VERBS)
auxiliary_verb_ratio = aux_count / token_count
try:
gunning_fog_index = textstat.gunning_fog(text)
except Exception:
gunning_fog_index = 0.0
analytical_count = sum(1 for w in words_lower if w in ANALYTICAL_WORDS)
analytical_thinking = analytical_count / token_count
certainty_count = sum(1 for w in words_lower if w in CERTAINTY_WORDS)
certainty_score = certainty_count / token_count
tentative_count = sum(1 for w in words_lower if w in TENTATIVE_WORDS)
tentative_score = tentative_count / token_count
clout_count = sum(1 for w in words_lower if w in CLOUT_WORDS)
clout_score = clout_count / token_count
comma_period_count = text.count(",") + text.count(".")
comma_period_density = (comma_period_count / text_len) * 100 if text_len > 0 else 0
informal_count = (
text.count("(")
+ text.count(")")
+ text.count("β€”")
+ text.count("–")
+ text.count("-")
+ text.count("...")
+ text.count("…")
)
informal_punct_density = (informal_count / text_len) * 100 if text_len > 0 else 0
# 23. Past focus ratio
past_count = sum(1 for w in words_lower if w in PAST_FOCUS_WORDS)
past_focus_ratio = past_count / token_count
# 24. Present focus ratio
present_count = sum(1 for w in words_lower if w in PRESENT_FOCUS_WORDS)
present_focus_ratio = present_count / token_count
# 25. Future focus ratio
future_count = sum(1 for w in words_lower if w in FUTURE_FOCUS_WORDS)
future_focus_ratio = future_count / token_count
return [
float(exclamation_density),
float(question_count),
float(caps_ratio),
float(avg_sentence_length),
float(punctuation_density),
float(token_count),
float(unique_word_ratio),
float(avg_word_length),
float(subjectivity),
float(flesch_reading_ease),
float(flesch_kincaid_grade),
float(coleman_liau_index),
float(ari),
float(first_person_ratio),
float(auxiliary_verb_ratio),
float(gunning_fog_index),
float(analytical_thinking),
float(certainty_score),
float(tentative_score),
float(clout_score),
float(comma_period_density),
float(informal_punct_density),
float(past_focus_ratio),
float(present_focus_ratio),
float(future_focus_ratio),
]
class InternalChecker:
"""Dual-output internal checker: News Validation + Bias Analysis.
Language routing:
- Detects the language of the input article (Tagalog / Cebuano / other).
- If a language-specific sub-model exists on disk, uses it automatically.
tl / fil β†’ rf_fakenews_tagalog.pkl
ceb β†’ rf_fakenews_cebuano.pkl
- Falls back to the mixed model (rf_fakenews_model.pkl) for English or
when no language sub-model has been trained yet.
"""
# Lazy-loaded cache: suffix β†’ (model, vectorizer, scaler, svd) or None
_sub_model_cache: dict = {}
def __init__(self):
# Always load the mixed / default model as the base fallback
model_path = os.path.join(DATA_MODELS_DIR, "rf_fakenews_model.pkl")
vectorizer_path = os.path.join(DATA_MODELS_DIR, "tfidf_fakenews.pkl")
scaler_path = os.path.join(DATA_MODELS_DIR, "stylo_scaler.pkl")
svd_path = os.path.join(DATA_MODELS_DIR, "tfidf_svd.pkl")
if not all(
os.path.exists(p) for p in [model_path, vectorizer_path, scaler_path, svd_path]
):
raise FileNotFoundError(
"Model artifacts not found. Run 'python backend/train.py' first."
)
self.model = joblib.load(model_path)
self.vectorizer = joblib.load(vectorizer_path)
self.scaler = joblib.load(scaler_path)
self.svd = joblib.load(svd_path)
# ── Sub-model helpers ─────────────────────────────────────────────
# Exclusive Cebuano function words / particles that rarely appear in Tagalog.
# Three or more hits in the text strongly suggest the input is Cebuano.
_CEBUANO_MARKERS = frozenset({
"ug", "nga", "kang", "gikan", "aron", "usab", "pud", "dili",
"matud", "atong", "nato", "bisan", "samtang", "apan", "siya",
"niya", "nila", "nako", "namo", "ninyo", "imong", "akong",
"niini", "niining", "kadto", "niadto", "kini", "kana",
"giingon", "miingon", "matud", "nagkanayon",
})
@classmethod
def _detect_lang(cls, text: str) -> str:
"""Return ISO language code, with a Cebuano correction heuristic.
langdetect often confuses Cebuano with Tagalog because both are
Filipino languages sharing many root words. When langdetect returns
'tl' but the text contains 3+ exclusive Cebuano markers, we override
to 'ceb' so the correct sub-model is used.
"""
try:
from langdetect import detect
raw = detect(text[:1000])
except Exception:
return "en"
# Cebuano correction: if langdetect says Tagalog but the text has
# enough Cebuano-exclusive markers, reclassify as Cebuano.
if raw in ("tl", "fil"):
words_lower = set(text.lower().split())
hits = words_lower & cls._CEBUANO_MARKERS
if len(hits) >= 3:
return "ceb"
return raw
def _load_sub_model(self, suffix: str):
"""Lazy-load a language-specific sub-model; cache result."""
if suffix in self._sub_model_cache:
return self._sub_model_cache[suffix]
m = os.path.join(DATA_MODELS_DIR, f"rf_fakenews{suffix}.pkl")
v = os.path.join(DATA_MODELS_DIR, f"tfidf_fakenews{suffix}.pkl")
s = os.path.join(DATA_MODELS_DIR, f"stylo_scaler{suffix}.pkl")
d = os.path.join(DATA_MODELS_DIR, f"tfidf_svd{suffix}.pkl")
if all(os.path.exists(p) for p in [m, v, s, d]):
sub = (joblib.load(m), joblib.load(v), joblib.load(s), joblib.load(d))
else:
sub = None # not trained yet β€” will fall back to mixed model
self._sub_model_cache[suffix] = sub
return sub
def _get_components_for(self, lang: str):
"""Return (model, vectorizer, scaler, svd) for the detected language."""
lang_to_suffix = {"tl": "_tagalog", "fil": "_tagalog", "ceb": "_cebuano"}
suffix = lang_to_suffix.get(lang)
if suffix:
sub = self._load_sub_model(suffix)
if sub is not None:
return sub
return self.model, self.vectorizer, self.scaler, self.svd # mixed fallback
# ── Translation-bias detection ──────────────────────────────────────
# The training data has machine-translated text ONLY in the Fake class,
# which causes the model to learn "translation artifacts β†’ Fake".
# This heuristic detects MT-characteristic patterns and applies a
# confidence recalibration to avoid false positives on translated
# real articles.
# Common English loanwords that appear in machine-translated Filipino/Cebuano
_MT_ENGLISH_LOANWORDS = frozenset({
"government", "president", "security", "energy", "department",
"foreign", "affairs", "agreement", "exemption", "transit",
"international", "diplomatic", "critical", "alliance", "defense",
"national", "emergency", "strait", "passage", "toll",
"administration", "bilateral", "coalition", "economy",
"infrastructure", "legislation", "memorandum", "protocol",
"resolution", "sanctions", "sovereignty", "territorial",
"vaccination", "quarantine", "pandemic", "inflation",
"investment", "subsidy", "regulation", "amendment",
})
# Filipino/Cebuano function words that co-occur with English loanwords in MT
_FILIPINO_FUNCTION = frozenset({
"ang", "ng", "sa", "mga", "na", "ay", "at", "ni", "si", "kay",
"para", "dahil", "matapos", "bilang", "kung", "pero",
"nga", "ug", "og", "kang", "alang", "aron", "tungod",
})
@classmethod
def _has_translation_artifacts(cls, text: str, lang: str) -> bool:
"""Detect if text shows machine-translation characteristics.
MT-translated Filipino/Cebuano text typically has:
- High density of English loanwords (untranslated terms)
- Mixed with Filipino/Cebuano function words
- This pattern does NOT occur in natively-written news
"""
if lang not in ("tl", "fil", "ceb"):
return False
words_lower = set(text.lower().split())
total = len(text.split())
if total < 20:
return False
eng_hits = len(words_lower & cls._MT_ENGLISH_LOANWORDS)
fil_hits = len(words_lower & cls._FILIPINO_FUNCTION)
# MT signature: significant English loanwords + Filipino function words
# Native Filipino news rarely has 4+ English technical/political terms
eng_ratio = eng_hits / total if total > 0 else 0
return eng_hits >= 4 and fil_hits >= 3 and eng_ratio > 0.02
# ── Public interface ──────────────────────────────────────────────
def check(self, text, pattern_deviation=None):
"""Run validation, bias analysis, and journalism structure check.
Args:
text (str): Article text to analyze.
pattern_deviation (dict|None): Pre-computed pattern deviation result
from compare_to_external_pattern(). When provided, it is passed
directly into analyze_bias() so deviation from reputable sources
acts as a primary bias signal rather than a post-hoc annotation.
"""
# Detect language once; reuse it for both validation and bias analysis
# so the SVO translation excerpt and Filipino lexicon score use the
# correct source language without a redundant detect() call.
lang = self._detect_lang(text)
return {
"validation": self._validate(text),
"bias": analyze_bias(text, pattern_deviation=pattern_deviation, lang=lang),
"structure": analyze_structure(text),
}
def _validate(self, text):
"""Predict whether the text is Real, Fake, or Uncertain."""
cleaned = clean_text(text)
if not cleaned:
return {"verdict": "Uncertain", "confidence": 0.0, "probabilities": {}}
# Detect language β†’ pick best available model
lang = self._detect_lang(cleaned)
model, vectorizer, scaler, svd = self._get_components_for(lang)
# Build hybrid features (same pipeline as training)
X_tfidf = vectorizer.transform([cleaned])
X_tfidf_svd = svd.transform(X_tfidf)
minilm = get_minilm_model()
embedding = minilm.encode([cleaned])
stylo_raw = np.array([extract_stylometric_features(cleaned)])
stylo_scaled = scaler.transform(stylo_raw)
X_combined = hstack([csr_matrix(X_tfidf_svd), csr_matrix(embedding), csr_matrix(stylo_scaled)])
# Predict
proba = model.predict_proba(X_combined)[0]
classes = model.classes_
prob_map = {int(cls): float(p) for cls, p in zip(classes, proba)}
prob_real = prob_map.get(0, 0.0)
prob_fake = prob_map.get(1, 0.0)
# ── Translation-bias calibration ──────────────────────────────
# If the text shows machine-translation artifacts and the model
# predicts Fake, the prediction may be driven by translation
# patterns rather than genuine fake-news signals. Apply a mild
# dampening to the fake probability.
is_translated = self._has_translation_artifacts(cleaned, lang)
if is_translated and prob_fake > prob_real:
# Dampen: pull fake probability toward 0.5 (uncertainty)
# The translation bias is systematic (translated text exists ONLY
# in the Fake training class), so a strong correction is justified.
# Factor 0.25 means: 83% fake β†’ 58% (Uncertain), 95% β†’ 61% (weak Fake)
# This lets external source verification determine the final verdict.
dampening = 0.25
prob_fake_adj = 0.5 + (prob_fake - 0.5) * dampening
prob_real_adj = 1.0 - prob_fake_adj
prob_fake = prob_fake_adj
prob_real = prob_real_adj
# Verdict with uncertainty threshold
CONFIDENCE_THRESHOLD = 0.60
if prob_real >= CONFIDENCE_THRESHOLD:
verdict = "Real"
confidence = prob_real
elif prob_fake >= CONFIDENCE_THRESHOLD:
verdict = "Fake"
confidence = prob_fake
else:
verdict = "Uncertain"
confidence = max(prob_real, prob_fake)
lang_label = {"tl": "Tagalog", "fil": "Tagalog", "ceb": "Cebuano"}.get(
lang, "English/Other"
)
result = {
"verdict": verdict,
"confidence": confidence,
"probabilities": {"Real": prob_real, "Fake": prob_fake},
"detected_lang": lang_label,
}
# Add a note if translation artifacts were detected
if is_translated:
result["translation_note"] = (
"This text appears to be machine-translated. "
"ML confidence has been calibrated to account for "
"translation artifacts that may affect the prediction."
)
return result