FakeNews-XAI / evaluation /run_evaluation.py
Marius16's picture
Testes + refactor
c4c6759
Raw
History Blame Contribute Delete
21.7 kB
"""Full TCS evaluation: manual benchmark + PolitiFact + pipeline comparison.
Usage:
python evaluation/run_evaluation.py # all steps
python evaluation/run_evaluation.py --benchmark-only # only manual benchmark
python evaluation/run_evaluation.py --no-wikidata # skip Wikidata lookups
python evaluation/run_evaluation.py --pipeline llm # use LLM extractor
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
import warnings
from datetime import date, datetime
from pathlib import Path
from typing import Optional
_PROJECT_ROOT = Path(__file__).parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s %(name)-35s %(levelname)-5s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("run_evaluation")
EVAL_DIR = Path(__file__).parent
BENCHMARK_FILE = EVAL_DIR / "benchmark_articles.json"
RESULTS_DIR = EVAL_DIR / "results"
FIGURES_DIR = EVAL_DIR / "figures"
FAKE_THRESHOLD = 0.55
# section: pipeline helpers
def _resolve_spacy_model(requested: Optional[str]) -> Optional[str]:
import spacy
installed = spacy.util.get_installed_models()
if not installed:
return None
if requested and requested in installed:
return requested
for preferred in ("en_core_web_trf", "en_core_web_lg", "en_core_web_sm"):
if preferred in installed:
return preferred
return installed[0]
def _build_orchestrator(pipeline: str, use_wikidata: bool, model_name: Optional[str], use_web_search: bool = False):
from backend.pipeline.orchestrator import PipelineOrchestrator
resolved_model = _resolve_spacy_model(model_name) if pipeline == "spacy" else model_name
if pipeline == "spacy" and resolved_model is None:
logger.error("No spaCy model installed. Run: python -m spacy download en_core_web_sm")
sys.exit(1)
return PipelineOrchestrator(
use_wikidata=use_wikidata,
use_web_search=use_web_search,
extractor_name=pipeline,
model_name=resolved_model,
persistent_store=None,
)
def _run_article(orch, article) -> dict:
t0 = time.monotonic()
try:
result = orch.run(article)
elapsed_ms = (time.monotonic() - t0) * 1000
return {
"ok": True,
"tcs": round(result.score, 4),
"tcs_label": result.label,
"n_claims": result.n_temporal_claims,
"n_inconsistencies": result.n_inconsistencies,
"coherence_factor": round(result.coherence_factor, 4),
"inconsistencies": [
{
"type": inc.inconsistency_type.value,
"severity": inc.severity.value,
"description": inc.description,
}
for inc in result.inconsistencies
],
"processing_time_ms": round(elapsed_ms, 1),
}
except Exception as exc:
logger.error(f"Pipeline error: {exc}", exc_info=True)
return {
"ok": False,
"tcs": 0.0,
"tcs_label": "error",
"n_claims": 0,
"n_inconsistencies": 0,
"coherence_factor": 0.0,
"inconsistencies": [],
"processing_time_ms": 0.0,
}
def _classify_outcome(expected_fake: bool, predicted_fake: bool) -> str:
if expected_fake and predicted_fake:
return "TP"
if not expected_fake and not predicted_fake:
return "TN"
if not expected_fake and predicted_fake:
return "FP"
return "FN"
def _compute_metrics(rows: list[dict]) -> dict:
tp = sum(1 for r in rows if r["outcome"] == "TP")
tn = sum(1 for r in rows if r["outcome"] == "TN")
fp = sum(1 for r in rows if r["outcome"] == "FP")
fn = sum(1 for r in rows if r["outcome"] == "FN")
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
accuracy = (tp + tn) / len(rows) if rows else 0.0
true_scores = [r["tcs"] for r in rows if not r["expected_fake"] and r["outcome"] != "ERROR"]
fake_scores = [r["tcs"] for r in rows if r["expected_fake"] and r["outcome"] != "ERROR"]
return {
"total": len(rows),
"tp": tp, "tn": tn, "fp": fp, "fn": fn,
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
"accuracy": round(accuracy, 4),
"avg_tcs_true": round(sum(true_scores) / len(true_scores), 4) if true_scores else None,
"avg_tcs_fake": round(sum(fake_scores) / len(fake_scores), 4) if fake_scores else None,
}
# section: benchmark manual
def run_benchmark(
orch,
threshold: float,
pipeline_name: str,
) -> dict:
print("\n" + "=" * 70)
print(" STEP 1 — Manual Benchmark (100 articles)")
print("=" * 70)
if not BENCHMARK_FILE.exists():
print(f" [ERROR] Benchmark file not found: {BENCHMARK_FILE}")
return {}
with open(BENCHMARK_FILE, encoding="utf-8") as f:
entries = json.load(f)
print(f" Loaded {len(entries)} articles from {BENCHMARK_FILE.name}")
from backend.pipeline.graph.models import Article
rows = []
for i, entry in enumerate(entries):
pub_date: Optional[datetime] = None
if entry.get("publication_date"):
try:
pub_date = datetime.strptime(entry["publication_date"], "%Y-%m-%d")
except ValueError:
pass
article = Article(
text=entry["text"],
title=entry.get("title", f"Article {i+1}"),
publication_date=pub_date,
source=entry.get("source", "benchmark"),
)
print(f" [{i+1:2d}/{len(entries)}] {article.title[:60]}", end=" ... ", flush=True)
res = _run_article(orch, article)
print(f"TCS={res['tcs']:.3f}")
expected_fake: bool = entry.get("expected_fake", False)
predicted_fake: bool = res["n_claims"] > 0 and res["tcs"] < threshold
outcome = _classify_outcome(expected_fake, predicted_fake) if res["ok"] else "ERROR"
rows.append({
"idx": i + 1,
"title": article.title,
"source": entry.get("source", ""),
"expected_fake": expected_fake,
"inconsistency_types": entry.get("inconsistency_types", []),
"notes": entry.get("notes", ""),
"predicted_fake": predicted_fake,
"outcome": outcome,
"tcs": res["tcs"],
"tcs_label": res["tcs_label"],
"n_claims": res["n_claims"],
"n_inconsistencies": res["n_inconsistencies"],
"coherence_factor": res["coherence_factor"],
"known_inconsistencies": entry.get("known_inconsistencies", []),
"detected_inconsistencies": res["inconsistencies"],
"processing_time_ms": res["processing_time_ms"],
})
metrics = _compute_metrics(rows)
_print_benchmark_table(rows, threshold)
_print_metrics(metrics, threshold, label="Manual Benchmark")
return {"rows": rows, "metrics": metrics}
def _print_benchmark_table(rows: list[dict], threshold: float) -> None:
w = 44
sep = "-" * (w + 56)
print(f"\n {'#':>2} {'Title':<{w}} {'TCS':>6} {'Exp':>4} {'Pred':>5} {'Out':>4} {'Inc':>4}")
print(f" {sep}")
for r in rows:
marker = " " if r["outcome"] in ("TP", "TN") else "*"
exp_s = "FAKE" if r["expected_fake"] else "TRUE"
pred_s = "FAKE" if r["predicted_fake"] else "TRUE"
print(
f"{marker} {r['idx']:>2} {r['title'][:w]:<{w}} {r['tcs']:>6.3f}"
f" {exp_s:>4} {pred_s:>5} {r['outcome']:>4} {r['n_inconsistencies']:>4}"
)
print(f" {sep}")
print(" * = misclassified\n")
def _print_metrics(metrics: dict, threshold: float, label: str = "") -> None:
tag = f" [{label}]" if label else " "
print(f"{tag} threshold TCS < {threshold:.2f} = FAKE")
print(f" TP={metrics['tp']} TN={metrics['tn']} FP={metrics['fp']} FN={metrics['fn']}")
print(f" Precision : {metrics['precision']:.4f}")
print(f" Recall : {metrics['recall']:.4f}")
print(f" F1 : {metrics['f1']:.4f}")
print(f" Accuracy : {metrics['accuracy']:.4f}")
if metrics.get("avg_tcs_true") is not None:
print(f" Avg TCS (TRUE) : {metrics['avg_tcs_true']:.4f}")
if metrics.get("avg_tcs_fake") is not None:
print(f" Avg TCS (FAKE) : {metrics['avg_tcs_fake']:.4f}")
print()
# section: FakeNewsNet evaluation
def run_fakenewsnet(orch, threshold: float, max_articles: int = 25) -> dict:
print("\n" + "=" * 70)
print(" STEP 2 — FakeNewsNet Evaluation (PolitiFact)")
print("=" * 70)
try:
from backend.input.dataset import load_fakenewsnet
except Exception as exc:
print(f" [WARNING] Could not import FakeNewsNet loader: {exc}")
return {}
results: dict[str, dict] = {}
for label in ("fake", "real"):
articles = load_fakenewsnet(source="politifact", label=label, max_articles=max_articles)
if not articles:
print(f" [WARNING] FakeNewsNet politifact/{label} not found — skipping.")
results[label] = {}
continue
print(f"\n politifact/{label}: {len(articles)} articles")
scores = []
for i, article in enumerate(articles):
print(f" [{i+1:2d}/{len(articles)}] {article.title[:55]}", end=" ... ", flush=True)
res = _run_article(orch, article)
print(f"TCS={res['tcs']:.3f}")
scores.append(res["tcs"])
avg = sum(scores) / len(scores) if scores else 0.0
results[label] = {
"n": len(scores),
"avg_tcs": round(avg, 4),
"min_tcs": round(min(scores), 4) if scores else None,
"max_tcs": round(max(scores), 4) if scores else None,
"scores": scores,
}
print(f" Avg TCS [{label}]: {avg:.4f}")
if results.get("fake") and results.get("real"):
gap = (results["real"].get("avg_tcs", 0) or 0) - (results["fake"].get("avg_tcs", 0) or 0)
print(f"\n TCS gap (real − fake): {gap:+.4f}")
results["gap_real_minus_fake"] = round(gap, 4)
return results
# section: pipeline comparison
def run_pipeline_comparison(threshold: float, use_wikidata: bool, max_articles: int = 20) -> dict:
print("\n" + "=" * 70)
print(" STEP 5 — Pipeline A (spaCy) vs Pipeline B (LLM) Comparison")
print("=" * 70)
if not BENCHMARK_FILE.exists():
print(f" [ERROR] Benchmark file not found: {BENCHMARK_FILE}")
return {}
with open(BENCHMARK_FILE, encoding="utf-8") as f:
entries = json.load(f)
entries = entries[:max_articles]
print(f" Using first {len(entries)} benchmark articles")
from backend.pipeline.graph.models import Article
try:
orch_a = _build_orchestrator("spacy", use_wikidata, None)
except SystemExit:
print(" [WARNING] spaCy not available — skipping pipeline comparison.")
return {}
try:
orch_b = _build_orchestrator("llm", use_wikidata, None)
has_llm = True
except Exception as exc:
print(f" [WARNING] LLM pipeline unavailable ({exc}) — showing spaCy only.")
has_llm = False
rows = []
for i, entry in enumerate(entries):
pub_date: Optional[datetime] = None
if entry.get("publication_date"):
try:
pub_date = datetime.strptime(entry["publication_date"], "%Y-%m-%d")
except ValueError:
pass
article = Article(
text=entry["text"],
title=entry.get("title", f"Article {i+1}"),
publication_date=pub_date,
source=entry.get("source", "benchmark"),
)
print(f" [{i+1:2d}/{len(entries)}] {article.title[:50]}", end=" ... ", flush=True)
res_a = _run_article(orch_a, article)
res_b = _run_article(orch_b, article) if has_llm else {"tcs": None, "tcs_label": "N/A", "processing_time_ms": 0.0}
delta = (res_a["tcs"] - res_b["tcs"]) if (has_llm and res_b["tcs"] is not None) else None
pred_a = res_a["tcs"] < threshold
pred_b = (res_b["tcs"] < threshold) if (has_llm and res_b["tcs"] is not None) else None
agree = (pred_a == pred_b) if pred_b is not None else None
print(f"A={res_a['tcs']:.3f} B={res_b['tcs'] if res_b['tcs'] is not None else 'N/A'}")
expected_fake: bool = entry.get("expected_fake", False)
rows.append({
"idx": i + 1,
"title": article.title,
"expected_fake": expected_fake,
"tcs_a": res_a["tcs"],
"label_a": res_a["tcs_label"],
"time_a_ms": res_a["processing_time_ms"],
"predicted_a": pred_a,
"outcome_a": _classify_outcome(expected_fake, pred_a),
"tcs_b": res_b["tcs"],
"label_b": res_b["tcs_label"],
"time_b_ms": res_b["processing_time_ms"],
"predicted_b": pred_b,
"outcome_b": _classify_outcome(expected_fake, pred_b) if pred_b is not None else "N/A",
"delta": delta,
"agree": agree,
})
_print_comparison_table(rows, has_llm)
metrics_a = _compute_metrics([
{**r, "tcs": r["tcs_a"], "predicted_fake": r["predicted_a"], "outcome": r["outcome_a"]}
for r in rows
])
metrics_b = _compute_metrics([
{**r, "tcs": r["tcs_b"] or 0.0, "predicted_fake": r["predicted_b"] or False, "outcome": r["outcome_b"]}
for r in rows if r["predicted_b"] is not None
]) if has_llm else {}
print(" Pipeline A (spaCy):")
_print_metrics(metrics_a, threshold)
if has_llm and metrics_b:
print(" Pipeline B (LLM):")
_print_metrics(metrics_b, threshold)
agreement_rate = sum(1 for r in rows if r["agree"]) / len(rows) if rows else 0.0
print(f" Agreement rate A vs B: {agreement_rate:.2%}")
return {
"rows": rows,
"metrics_a": metrics_a,
"metrics_b": metrics_b,
"agreement_rate": round(agreement_rate, 4),
"has_llm": has_llm,
}
def _print_comparison_table(rows: list[dict], has_llm: bool) -> None:
w = 40
print(f"\n {'#':>2} {'Title':<{w}} {'TCS-A':>6} {'TCS-B':>6} {'Delta':>7} {'Agree':>6} {'Exp':>4}")
print(" " + "-" * (w + 52))
for r in rows:
tcs_b_str = f"{r['tcs_b']:.3f}" if r["tcs_b"] is not None else " N/A "
delta_str = f"{r['delta']:+.3f}" if r["delta"] is not None else " N/A"
agree_str = ("Y" if r["agree"] else "N") if r["agree"] is not None else "-"
exp_str = "FAKE" if r["expected_fake"] else "TRUE"
print(
f" {r['idx']:>2} {r['title'][:w]:<{w}} {r['tcs_a']:>6.3f}"
f" {tcs_b_str:>6} {delta_str:>7} {agree_str:>6} {exp_str:>4}"
)
print(" " + "-" * (w + 52) + "\n")
# section: boxplot
def generate_boxplot(
benchmark_rows: list[dict],
fnn_data: dict,
threshold: float,
) -> Optional[Path]:
print("\n" + "=" * 70)
print(" STEP 4 — Generating TCS Boxplot")
print("=" * 70)
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
print(" [WARNING] matplotlib not installed — skipping boxplot.")
return None
groups: list[tuple[str, list[float]]] = []
if benchmark_rows:
true_scores = [r["tcs"] for r in benchmark_rows if not r["expected_fake"] and r["outcome"] != "ERROR"]
fake_scores = [r["tcs"] for r in benchmark_rows if r["expected_fake"] and r["outcome"] != "ERROR"]
if true_scores:
groups.append(("Benchmark\nTRUE", true_scores))
if fake_scores:
groups.append(("Benchmark\nFAKE", fake_scores))
if fnn_data.get("real", {}).get("scores"):
groups.append(("FakeNewsNet\nreal", fnn_data["real"]["scores"]))
if fnn_data.get("fake", {}).get("scores"):
groups.append(("FakeNewsNet\nfake", fnn_data["fake"]["scores"]))
if not groups:
print(" [WARNING] No data available for boxplot.")
return None
labels = [g[0] for g in groups]
data = [g[1] for g in groups]
colors = []
for lbl in labels:
if "TRUE" in lbl or "real" in lbl:
colors.append("#4caf50")
elif "FAKE" in lbl or "fake" in lbl:
colors.append("#f44336")
else:
colors.append("#9e9e9e")
fig, ax = plt.subplots(figsize=(max(10, len(groups) * 1.4), 6))
bp = ax.boxplot(data, patch_artist=True, notch=False, vert=True)
for patch, color in zip(bp["boxes"], colors):
patch.set_facecolor(color)
patch.set_alpha(0.7)
ax.axhline(y=threshold, color="red", linestyle="--", linewidth=1.2, label=f"Threshold ({threshold})")
ax.set_xticks(range(1, len(labels) + 1))
ax.set_xticklabels(labels, fontsize=8)
ax.set_ylabel("TCS Score")
ax.set_title("TCS Distribution per Dataset / Label")
ax.set_ylim(-0.05, 1.05)
ax.legend(loc="upper right")
ax.grid(axis="y", linestyle=":", alpha=0.5)
FIGURES_DIR.mkdir(parents=True, exist_ok=True)
today = date.today().strftime("%Y-%m-%d")
out_path = FIGURES_DIR / f"tcs_boxplot_{today}.png"
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f" Boxplot saved: {out_path}")
return out_path
# section: save results
def save_full_results(
benchmark: dict,
fnn: dict,
comparison: dict,
boxplot_path: Optional[Path],
pipeline: str,
threshold: float,
use_wikidata: bool,
) -> Path:
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
today = date.today().strftime("%Y-%m-%d")
out_path = RESULTS_DIR / f"full_eval_{today}.json"
payload = {
"generated_at": datetime.now().isoformat(),
"pipeline": pipeline,
"threshold": threshold,
"use_wikidata": use_wikidata,
"benchmark": {
"metrics": benchmark.get("metrics", {}),
"articles": benchmark.get("rows", []),
},
"politifact": {},
"fakenewsnet": fnn,
"pipeline_comparison": {
k: v for k, v in comparison.items() if k != "rows"
},
"pipeline_comparison_rows": comparison.get("rows", []),
"boxplot": str(boxplot_path) if boxplot_path else None,
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False, default=str)
return out_path
# section: main
def main() -> None:
parser = argparse.ArgumentParser(description="Full TCS Evaluation Suite")
parser.add_argument("--benchmark-only", action="store_true",
help="Run only the manual benchmark (skip FakeNewsNet and pipeline comparison)")
parser.add_argument("--no-wikidata", action="store_true",
help="Skip Wikidata verification (faster offline runs)")
parser.add_argument("--threshold", type=float, default=FAKE_THRESHOLD,
help=f"TCS threshold below which an article is predicted FAKE (default: {FAKE_THRESHOLD})")
parser.add_argument("--pipeline", choices=["spacy", "llm"], default="spacy",
help="Extractor pipeline to use (default: spacy)")
parser.add_argument("--model", type=str, default=None,
help="Explicit spaCy model name or LLM model name")
parser.add_argument("--web-search", action="store_true",
help="Enable Wikipedia web search fallback in C3b")
args = parser.parse_args()
use_wikidata = not args.no_wikidata
print(f"\n{'='*70}")
print(" TCS FULL EVALUATION")
print(f"{'='*70}")
print(f" Pipeline : {args.pipeline}")
print(f" Threshold : {args.threshold}")
print(f" Wikidata : {use_wikidata}")
print(f" Web Search: {args.web_search}")
print(f" Mode : {'benchmark-only' if args.benchmark_only else 'full'}")
print(f"{'='*70}")
orch = _build_orchestrator(args.pipeline, use_wikidata, args.model, use_web_search=args.web_search)
benchmark_result = run_benchmark(orch, args.threshold, args.pipeline)
fnn_result: dict = {}
comparison_result: dict = {}
boxplot_path: Optional[Path] = None
if not args.benchmark_only:
fnn_result = run_fakenewsnet(orch, args.threshold, max_articles=25)
boxplot_path = generate_boxplot(
benchmark_result.get("rows", []),
fnn_result,
args.threshold,
)
comparison_result = run_pipeline_comparison(
threshold=args.threshold,
use_wikidata=use_wikidata,
max_articles=20,
)
else:
boxplot_path = generate_boxplot(
benchmark_result.get("rows", []),
{},
args.threshold,
)
out_path = save_full_results(
benchmark=benchmark_result,
fnn=fnn_result,
comparison=comparison_result,
boxplot_path=boxplot_path,
pipeline=args.pipeline,
threshold=args.threshold,
use_wikidata=use_wikidata,
)
print("\n" + "=" * 70)
print(" EVALUATION COMPLETE")
print(f" Results : {out_path}")
if boxplot_path:
print(f" Boxplot : {boxplot_path}")
print("=" * 70 + "\n")
if __name__ == "__main__":
main()