Spaces:
Running
Running
File size: 6,131 Bytes
33acf50 | 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 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | """Diagnose where the rule layer flips predictions and how often the flip is wrong.
Runs on the seeded quick IMDB subset. Compares the raw NB+LR ensemble (no rules)
to the full system (all rules), attributes each flip to the first rule layer that
fires in cascade order, and splits the result by review length. Writes
artifacts/rule_diagnosis.json.
"""
import os
import sys
import json
import numpy as np
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(os.path.dirname(THIS_DIR))
for _p in (PROJECT_ROOT, THIS_DIR):
if _p not in sys.path:
sys.path.insert(0, _p)
import evaluate as ev
LAYERS = ["simple_case", "neutral", "special_phrase", "safety",
"sarcasm", "idiom", "contradiction", "none"]
def first_layer(ensemble, text):
"""Return the first rule layer that fires for this text, in cascade order."""
simple = ensemble._handle_simple_cases(text)
if simple and simple[0]:
return "simple_case"
neutral = ensemble._detect_neutral_sentiment(text)
if neutral and neutral[0]:
return "neutral"
special = ensemble.clean_text(text)[2]
if special and special.get("special_phrase_detected"):
return "special_phrase"
if ensemble.safety_check(text) is True:
return "safety"
if ensemble._detect_sarcasm(text):
return "sarcasm"
if ensemble._detect_idioms(text):
return "idiom"
if ensemble._detect_contradiction(text):
return "contradiction"
return "none"
def mcnemar(b, c):
"""Continuity-corrected McNemar chi-square and p-value for discordant counts."""
from scipy.stats import chi2
if b + c == 0:
return 0.0, 1.0
stat = (abs(b - c) - 1) ** 2 / (b + c)
return float(stat), float(chi2.sf(stat, 1))
def raw_ensemble_preds(ensemble, texts):
"""Raw NB+LR weighted-probability argmax, the same definition as evaluate.py."""
processed = ev.preprocess_texts(ensemble, texts)
f_nb = ev.build_features(ensemble, "naive_bayes", processed)
f_lr = ev.build_features(ensemble, "logistic_regression", processed)
nb = ensemble.models["naive_bayes"].predict_proba(f_nb)
lr = ensemble.models["logistic_regression"].predict_proba(f_lr)
w_nb = ensemble.model_weights.get("naive_bayes", 0.6)
w_lr = ensemble.model_weights.get("logistic_regression", 0.4)
return np.argmax(w_nb * nb + w_lr * lr, axis=1)
def full_system_decisions(ensemble, texts):
"""Full-system label per text as Positive / Negative / Neutral."""
out = []
n = len(texts)
for i, t in enumerate(texts):
out.append(ensemble.predict(t)["sentiment"])
if (i + 1) % 250 == 0 or (i + 1) == n:
print(f" full-system {i + 1}/{n}", flush=True)
return out
def main():
max_per_class = 1000
ensemble = ev.load_ensemble()
texts, labels = ev.load_imdb_test(max_per_class=max_per_class, seed=42)
labels = np.asarray(labels)
n = len(texts)
print(f"quick subset: {n} reviews")
raw = raw_ensemble_preds(ensemble, texts)
decisions = full_system_decisions(ensemble, texts)
# Full-system binary label. A Neutral on binary data counts as wrong.
full = np.array([1 if d == "Positive" else (0 if d == "Negative" else 1 - labels[i])
for i, d in enumerate(decisions)])
raw_correct = (raw == labels)
full_correct = (full == labels)
raw_acc = float(raw_correct.mean())
full_acc = float(full_correct.mean())
helped = int((full_correct & ~raw_correct).sum())
hurt = int((~full_correct & raw_correct).sum())
stat, p = mcnemar(hurt, helped)
raw_dec = ["Positive" if r == 1 else "Negative" for r in raw]
per = {L: {"fires": 0, "flips": 0, "wrong_flips": 0, "right_flips": 0} for L in LAYERS}
for i, t in enumerate(texts):
layer = first_layer(ensemble, t)
per[layer]["fires"] += 1
if decisions[i] != raw_dec[i]:
per[layer]["flips"] += 1
if raw_correct[i] and not full_correct[i]:
per[layer]["wrong_flips"] += 1
elif (not raw_correct[i]) and full_correct[i]:
per[layer]["right_flips"] += 1
wc = np.array([len(t.split()) for t in texts])
short = wc < 50
buckets = {}
for name, mask in [("short(<50w)", short), ("long(>=50w)", ~short)]:
k = int(mask.sum())
buckets[name] = {
"n": k,
"raw_acc": round(float(raw_correct[mask].mean()), 4) if k else None,
"full_acc": round(float(full_correct[mask].mean()), 4) if k else None,
}
result = {
"subset": {"name": "imdb_quick_seeded", "seed": 42, "n": n,
"max_per_class": max_per_class},
"raw_vs_full": {"raw_acc": round(raw_acc, 4), "full_acc": round(full_acc, 4),
"delta": round(full_acc - raw_acc, 4),
"helped_full_right_raw_wrong": helped,
"hurt_full_wrong_raw_right": hurt,
"mcnemar_chi2": round(stat, 3), "mcnemar_p": float(f"{p:.3g}")},
"per_layer": per,
"by_length": buckets,
}
out_path = os.path.join(PROJECT_ROOT, "artifacts", "rule_diagnosis.json")
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(result, f, indent=2)
print("\nRAW vs FULL")
print(f" raw {raw_acc:.4f} full {full_acc:.4f} delta {full_acc - raw_acc:+.4f}")
print(f" helped {helped} hurt {hurt} McNemar chi2 {stat:.2f} p {p:.3g}")
print("\nPER-LAYER (first firing layer in cascade order)")
print(f" {'layer':16s} {'fires':>6s} {'flips':>6s} {'wrong':>6s} {'right':>6s}")
for L in LAYERS:
d = per[L]
print(f" {L:16s} {d['fires']:6d} {d['flips']:6d} {d['wrong_flips']:6d} {d['right_flips']:6d}")
print("\nBY LENGTH")
for name, d in buckets.items():
if d["raw_acc"] is not None:
print(f" {name:12s} n={d['n']:5d} raw {d['raw_acc']:.4f} full {d['full_acc']:.4f}")
print(f"\nsaved {out_path}")
if __name__ == "__main__":
main()
|