sensecatch / benchmark.py
berkinksk's picture
Add the SenseCatch app, models, and Docker setup
33acf50 verified
Raw
History Blame Contribute Delete
7.17 kB
"""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()