"""Multi-dataset sentiment benchmark. Scores each component on IMDB, SST-2, and Yelp. For every component we report accuracy, a Wilson 95% confidence interval, macro F1, and a confusion matrix. The full system can abstain (Neutral), so it is reported two ways: full coverage (abstentions resolved by the NB+LR vote) and selective (only the items it commits to, plus a coverage fraction). Usage: python benchmark.py # full sets python benchmark.py --quick 40 # tiny smoke, 40 items per dataset python benchmark.py --no-distilbert # skip the transformer baseline """ import os import sys import json import math import argparse import numpy as np from sklearn.metrics import f1_score, confusion_matrix import evaluate as ev PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(PROJECT_ROOT, "src", "training")) import benchmark_datasets as bd RESULTS_PATH = os.path.join(PROJECT_ROOT, "artifacts", "benchmark_multi.json") RAW_MODELS = ["naive_bayes", "logistic_regression", "linear_svc", "nbsvm"] def wilson_ci(acc, n, z=1.96): if n == 0: return [0.0, 0.0] denom = 1 + z * z / n center = (acc + z * z / (2 * n)) / denom half = z * math.sqrt(acc * (1 - acc) / n + z * z / (4 * n * n)) / denom return [round(center - half, 4), round(center + half, 4)] def _stats(name, y_true, y_pred): y_true = np.asarray(y_true) y_pred = np.asarray(y_pred) n = len(y_true) if n == 0: return {"component": name, "accuracy": 0.0, "acc_ci95": [0.0, 0.0], "macro_f1": 0.0, "confusion_matrix": [[0, 0], [0, 0]], "n": 0} acc = float((y_true == y_pred).mean()) return { "component": name, "accuracy": round(acc, 4), "acc_ci95": wilson_ci(acc, n), "macro_f1": round(float(f1_score(y_true, y_pred, average="macro")), 4), "confusion_matrix": confusion_matrix(y_true, y_pred, labels=[0, 1]).tolist(), "n": n, } def vader_preds(texts): from nltk.sentiment.vader import SentimentIntensityAnalyzer vader = SentimentIntensityAnalyzer() return [1 if vader.polarity_scores(t)["compound"] >= 0.05 else 0 for t in texts] def model_preds(ensemble, name, processed): feats = ev.build_features(ensemble, name, processed) return ensemble.models[name].predict(feats).tolist() def nb_lr_combined(ensemble, processed): nb = ensemble.models["naive_bayes"].predict_proba( ev.build_features(ensemble, "naive_bayes", processed)) lr = ensemble.models["logistic_regression"].predict_proba( ev.build_features(ensemble, "logistic_regression", processed)) w_nb = ensemble.model_weights.get("naive_bayes", 0.6) w_lr = ensemble.model_weights.get("logistic_regression", 0.4) return w_nb * nb + w_lr * lr, w_nb, w_lr def distilbert_preds(texts): from transformers import pipeline import torch if torch.cuda.is_available(): device = 0 elif getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available(): device = "mps" else: device = -1 clf = pipeline("sentiment-analysis", model="distilbert-base-uncased-finetuned-sst-2-english", device=device, truncation=True, max_length=512) preds = [] for i in range(0, len(texts), 64): batch = [t[:2000] for t in texts[i:i + 64]] preds.extend(1 if r["label"] == "POSITIVE" else 0 for r in clf(batch)) return preds def score_dataset(ensemble, texts, labels, distilbert_in_domain, no_distilbert): labels = [int(x) for x in labels] processed = ev.preprocess_texts(ensemble, texts) comps = {} maj = max(set(labels), key=labels.count) comps["majority_baseline"] = _stats("majority_baseline", labels, [maj] * len(labels)) comps["vader"] = _stats("vader", labels, vader_preds(texts)) for name in RAW_MODELS: comps[name] = _stats(name, labels, model_preds(ensemble, name, processed)) combined, w_nb, w_lr = nb_lr_combined(ensemble, processed) ens_pred = np.argmax(combined, axis=1).tolist() comps["ensemble_nb_lr"] = _stats("ensemble_nb_lr", labels, ens_pred) comps["ensemble_nb_lr"]["weights"] = {"naive_bayes": w_nb, "logistic_regression": w_lr} # Full system. Neutral predictions are abstentions. full_pred, sel_true, sel_pred, neutral = [], [], [], 0 for i, t in enumerate(texts): s = ensemble.predict(t)["sentiment"] if s == "Positive": p = 1 elif s == "Negative": p = 0 else: p = None if p is None: neutral += 1 full_pred.append(ens_pred[i]) # resolve abstention with the NB+LR vote else: full_pred.append(p) sel_true.append(labels[i]) sel_pred.append(p) fc = _stats("full_system_full_coverage", labels, full_pred) sel = _stats("full_system_selective", sel_true, sel_pred) sel["coverage"] = round((len(labels) - neutral) / len(labels), 4) if labels else 0.0 comps["full_system"] = {"full_coverage": fc, "selective": sel} if not no_distilbert: d = _stats("distilbert", labels, distilbert_preds(texts)) d["in_domain"] = bool(distilbert_in_domain) comps["distilbert"] = d return comps def main(): ap = argparse.ArgumentParser(description="Multi-dataset sentiment benchmark") ap.add_argument("--quick", type=int, default=0, help="tiny smoke: N items per dataset") ap.add_argument("--no-distilbert", action="store_true", help="skip the transformer baseline") args = ap.parse_args() ensemble = ev.load_ensemble() if args.quick: imdb = ev.load_imdb_test(max_per_class=max(1, args.quick // 2), seed=42) st, sl = bd.load_sst2() st, sl = st[:args.quick], sl[:args.quick] yelp = bd.load_yelp(args.quick, 42) else: imdb = ev.load_imdb_test() st, sl = bd.load_sst2() yelp = bd.load_yelp(2000, 42) datasets = { "imdb": (imdb[0], list(imdb[1]), "in", False), "sst2": (st, sl, "cross", True), "yelp": (yelp[0], yelp[1], "cross", False), } out = {} for dname, (texts, labels, domain, db_in) in datasets.items(): print(f"\n=== {dname} (n={len(texts)}, domain={domain}) ===", flush=True) comps = score_dataset(ensemble, texts, labels, db_in, args.no_distilbert) out[dname] = {"n": len(texts), "domain": domain, "components": comps} for cname, c in comps.items(): if cname == "full_system": print(f" {cname:20s} full_cov={c['full_coverage']['accuracy']} " f"selective={c['selective']['accuracy']} coverage={c['selective']['coverage']}") else: extra = " in_domain" if c.get("in_domain") else "" print(f" {cname:20s} acc={c['accuracy']} f1={c['macro_f1']}{extra}") os.makedirs(os.path.dirname(RESULTS_PATH), exist_ok=True) with open(RESULTS_PATH, "w") as f: json.dump(out, f, indent=2) print(f"\nsaved {RESULTS_PATH}") if __name__ == "__main__": main()