File size: 4,324 Bytes
1b4d8db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Naija-vs-English classifier for H6.

Per protocol_spec.md §4 H6: train a small TF-IDF + logistic-regression classifier
on Naija text (MasakhaNEWS-pcm, public, CC-BY) vs English news, then score
TANDEM's outputs. Cultural-overlay-on outputs should score significantly more
Naija-like than noise-on outputs (paired Wilcoxon).

Trains in seconds on CPU. Saves the fitted model to `results/naija_classifier.joblib`.

Run via:  python -m src.eval.classifier
"""
from __future__ import annotations

import gzip
import json
import urllib.request
from pathlib import Path

import joblib
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

# Public mirrors. Both are CC-BY (verified in phase3_cultural_resources.md §1.2).
PCM_URL = (
    "https://huggingface.co/datasets/masakhane/masakhanews/resolve/main/"
    "data/pcm/test.tsv"
)
EN_NEWS_URL = (
    "https://huggingface.co/datasets/SetFit/ag_news/resolve/main/test.jsonl"
)

CACHE_DIR = Path("data/raw")
MODEL_PATH = Path("results/naija_classifier.joblib")
SEED = 42


def _download(url: str, dest: Path) -> Path:
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        return dest
    print(f"  downloading {url} -> {dest}")
    urllib.request.urlretrieve(url, dest)
    return dest


def _load_pcm() -> list[str]:
    """MasakhaNEWS-pcm test split. TSV with `label\\theadline\\ttext`."""
    path = _download(PCM_URL, CACHE_DIR / "masakhanews_pcm_test.tsv")
    docs: list[str] = []
    for line in path.read_text(encoding="utf-8").splitlines()[1:]:  # skip header
        parts = line.split("\t")
        if len(parts) >= 3 and parts[2].strip():
            docs.append(parts[2].strip())
    return docs


def _load_english() -> list[str]:
    """AG News test split (English news, public, similar register/length)."""
    path = _download(EN_NEWS_URL, CACHE_DIR / "ag_news_test.jsonl")
    docs: list[str] = []
    for line in path.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        rec = json.loads(line)
        text = rec.get("text", "").strip()
        if text:
            docs.append(text)
    return docs


def train(save_path: Path = MODEL_PATH) -> dict:
    naija = _load_pcm()
    english = _load_english()
    # Balance corpora so the classifier doesn't trivially exploit prior probabilities.
    n = min(len(naija), len(english))
    rng = np.random.default_rng(SEED)
    rng.shuffle(naija)
    rng.shuffle(english)
    naija, english = naija[:n], english[:n]
    print(f"  Naija docs: {len(naija)}, English docs: {len(english)}")

    X = naija + english
    y = [1] * len(naija) + [0] * len(english)
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=SEED, stratify=y
    )

    pipe = Pipeline([
        ("tfidf", TfidfVectorizer(
            ngram_range=(1, 2), min_df=2, max_features=20000,
            sublinear_tf=True, lowercase=True,
        )),
        ("clf", LogisticRegression(max_iter=2000, C=1.0, random_state=SEED)),
    ])
    pipe.fit(X_train, y_train)

    y_pred = pipe.predict(X_test)
    y_proba = pipe.predict_proba(X_test)[:, 1]
    auc = roc_auc_score(y_test, y_proba)
    print("  classification report (English=0, Naija=1):")
    print(classification_report(y_test, y_pred))
    print(f"  AUC: {auc:.4f}")

    save_path.parent.mkdir(parents=True, exist_ok=True)
    joblib.dump({"pipeline": pipe, "auc": float(auc)}, save_path)
    print(f"  saved to {save_path}")
    return {"auc": float(auc), "n_train": len(X_train), "n_test": len(X_test)}


def naija_score(text: str, model_path: Path = MODEL_PATH) -> float:
    """Return the trained classifier's predicted P(text is Naija) for a single review."""
    model = joblib.load(model_path)
    return float(model["pipeline"].predict_proba([text])[0, 1])


def naija_scores(texts: list[str], model_path: Path = MODEL_PATH) -> np.ndarray:
    """Batch version; same probability semantic."""
    model = joblib.load(model_path)
    return model["pipeline"].predict_proba(texts)[:, 1]


if __name__ == "__main__":
    train()