Claude commited on
Commit
a3b1d3c
·
1 Parent(s): d2b284c

Add single-outlet evaluation script and sample hybrid output

Browse files

Adds eval_single_output.py for computing ROUGE-L, METEOR, FACTScore
precision/recall, and error rate on a single pre-computed output against
gold labels. Includes Newfoundland Independent hybrid output for testing.

https://claude.ai/code/session_01FzSqaEMj4tQogQY87L8fzA

eval_single_output.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Evaluate a single pre-computed output against gold labels.
3
+
4
+ Usage:
5
+ python eval_single_output.py --input evaluation_dataset.json --output hybrid_output.json --outlet "Newfoundland Independent"
6
+
7
+ Where hybrid_output.json contains the raw_output from the hybrid run.
8
+ """
9
+
10
+ import json
11
+ import argparse
12
+ import logging
13
+ from metrics import MetricsCalculator
14
+
15
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def evaluate_output(gold_item: dict, generated: dict) -> dict:
20
+ """Compute all metrics for a single outlet given gold and generated data."""
21
+ metrics_calc = MetricsCalculator()
22
+
23
+ # Construct text blocks (same logic as run_benchmark.py evaluate_single)
24
+ gen_text = " ".join(filter(None, [
25
+ generated.get('bias_category_description'),
26
+ generated.get('overall_summary'),
27
+ generated.get('analysis'),
28
+ generated.get('history'),
29
+ generated.get('ownership'),
30
+ ])).strip()
31
+
32
+ gold_text_full = "\n\n".join(filter(None, [
33
+ gold_item.get('bias_category_description', ''),
34
+ gold_item.get('overall_summary', ''),
35
+ gold_item.get('history', ''),
36
+ gold_item.get('analysis', ''),
37
+ gold_item.get('ownership', ''),
38
+ ]))
39
+
40
+ gold_summary = " ".join(filter(None, [
41
+ gold_item.get('bias_category_description', ''),
42
+ gold_item.get('overall_summary', ''),
43
+ gold_item.get('analysis', ''),
44
+ gold_item.get('history', ''),
45
+ gold_item.get('ownership', ''),
46
+ ]))
47
+
48
+ # A. Text Overlap
49
+ logger.info("Computing ROUGE-L and METEOR...")
50
+ text_m = metrics_calc.calculate_text_metrics([gold_summary], [gen_text])
51
+
52
+ # B. FactScore Precision: Generated -> Gold
53
+ logger.info("Computing FACTScore Precision...")
54
+ fs_precision = metrics_calc.check_fact_recall(gold_text_full, gen_text)
55
+
56
+ # C. Fact Recall: Gold -> Generated
57
+ logger.info("Computing FACTScore Recall...")
58
+ fs_recall = metrics_calc.check_gold_fact_recall(gold_text_full, gen_text)
59
+
60
+ # D. Fact Check Detection
61
+ logger.info("Computing Fact Check Detection...")
62
+ gold_fc = gold_item.get('failed_fact_checks', [])
63
+ gen_fc_list = generated.get('failed_fact_checks', [])
64
+ fc_m = MetricsCalculator.evaluate_fact_checks(gold_fc, gen_text, gen_fc_list)
65
+
66
+ result = {
67
+ "name": gold_item['name'],
68
+ "source_url": gold_item.get('source_url', ''),
69
+ "gold_bias": gold_item.get('bias_score', 0),
70
+ "pred_bias": generated.get('bias_score'),
71
+ "gold_factuality": gold_item.get('factual_score', 0),
72
+ "pred_factuality": generated.get('factual_score'),
73
+ "rougeL": text_m['rougeL'],
74
+ "meteor": text_m['meteor'],
75
+ "factscore_precision": fs_precision.get('factscore', 0.0),
76
+ "error_rate": fs_precision.get('error_rate', 0.0),
77
+ "factscore_recall": fs_recall.get('gold_recall', 0.0),
78
+ "fact_check_hit": fc_m,
79
+ }
80
+
81
+ return result
82
+
83
+
84
+ def main():
85
+ parser = argparse.ArgumentParser(description="Evaluate a single pre-computed output")
86
+ parser.add_argument("--input", type=str, default="evaluation_dataset.json",
87
+ help="Path to evaluation dataset JSON")
88
+ parser.add_argument("--output", type=str, required=True,
89
+ help="Path to JSON file with the generated output (raw_output)")
90
+ parser.add_argument("--outlet", type=str, required=True,
91
+ help="Name of the outlet to evaluate")
92
+ args = parser.parse_args()
93
+
94
+ # Load gold dataset
95
+ with open(args.input) as f:
96
+ dataset = json.load(f)
97
+
98
+ # Find gold item
99
+ gold_item = None
100
+ for item in dataset:
101
+ if item['name'].lower() == args.outlet.lower():
102
+ gold_item = item
103
+ break
104
+
105
+ if not gold_item:
106
+ logger.error(f"Outlet '{args.outlet}' not found in {args.input}")
107
+ return
108
+
109
+ # Load generated output
110
+ with open(args.output) as f:
111
+ generated = json.load(f)
112
+
113
+ # If the file has a 'raw_output' key (from benchmark results), use that
114
+ if 'raw_output' in generated:
115
+ generated = generated['raw_output']
116
+ # If wrapped in 'output' key (from hybrid runner results)
117
+ if 'output' in generated:
118
+ generated = generated['output']
119
+
120
+ logger.info(f"Evaluating: {gold_item['name']}")
121
+ logger.info(f" Gold bias: {gold_item.get('bias_score')}, Pred bias: {generated.get('bias_score')}")
122
+ logger.info(f" Gold factuality: {gold_item.get('factual_score')}, Pred factuality: {generated.get('factual_score')}")
123
+
124
+ result = evaluate_output(gold_item, generated)
125
+
126
+ # Print results
127
+ print("\n" + "=" * 60)
128
+ print(f"EVALUATION RESULTS: {result['name']}")
129
+ print("=" * 60)
130
+ print(f" Bias: gold={result['gold_bias']}, pred={result['pred_bias']}")
131
+ print(f" Factuality: gold={result['gold_factuality']}, pred={result['pred_factuality']}")
132
+ print(f" ROUGE-L: {result['rougeL']:.4f}")
133
+ print(f" METEOR: {result['meteor']:.4f}")
134
+ print(f" FACTScore Prec: {result['factscore_precision']:.4f}")
135
+ print(f" Error Rate: {result['error_rate']:.4f}")
136
+ print(f" FACTScore Recall: {result['factscore_recall']:.4f}")
137
+ print(f" Fact Check Hit: {result['fact_check_hit']}")
138
+ print("=" * 60)
139
+
140
+ # Save full result
141
+ out_path = args.output.replace('.json', '_evaluated.json')
142
+ with open(out_path, 'w') as f:
143
+ json.dump(result, f, indent=2, default=str)
144
+ print(f"\nFull result saved to: {out_path}")
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
newfoundland_hybrid_output.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "output": {
3
+ "mbfc_url": null,
4
+ "name": "Newfoundland Independent",
5
+ "source_url": "https://theindependent.ca/",
6
+ "bias_rating": "LEFT-CENTER",
7
+ "bias_score": -4.5,
8
+ "factual_reporting": "MOSTLY FACTUAL",
9
+ "factual_score": 2.9,
10
+ "credibility_rating": "MEDIUM CREDIBILITY",
11
+ "country": "Canada",
12
+ "country_freedom_rating": "Free",
13
+ "media_type": "Website",
14
+ "traffic_popularity": "Unknown",
15
+ "bias_category_description": "Left-Center \u2014 leans progressive and advocacy-focused, emphasizing community voices, labour, anti-racism and climate coverage while maintaining journalistic reporting elements.",
16
+ "overall_summary": "Overall, we rate Newfoundland Independent as Left-Center based on its sustained progressive editorial posture, frequent emphasis on critics and community advocates, and use of emotive framing. We also rate them Mostly Factual based on generally accurate reporting, named primary local sources, no found failed fact-checks, but moderate sourcing/linking and limited site transparency.",
17
+ "history": "Founded in 2011; Founder: Unknown; Original name: N/A. The Independent emerged from a predecessor St. John\u2019s print newspaper (2003\u20132008), re-launched online in early 2011 as a not-for-profit-minded media collective under editors including Justin Brake and Rhea Rollmann, gained recognition for investigative/community reporting (notably Muskrat Falls coverage and related press-freedom litigation), and has received regional journalism awards. Current headquarters: Unknown from supplied evidence.",
18
+ "ownership": "Owner/parent company: Unknown. Funding model: Unknown (site references 'reader-supported' in a newsletter snippet but no formal funding disclosures were provided in the evidence). Headquarters location: Unknown based on the supplied materials.",
19
+ "analysis": "The outlet's coverage and opinion pieces show a consistent left-of-center editorial direction: articles foreground anti-racism, labour and community voices, climate science and skepticism of fossil-fuel narratives, and advocacy for expanded social supports (examples: pieces on homelessness and NDP leader comments, Black History Month coverage, fisheries/climate reporting). Straight news items include government statements and official quotes but selection and framing disproportionately center critics, advocates and policy deficits rather than equivalent conservative advocacy (pipeline examples: Article 1 on PC budget engagement frames critics' views; Article 3 highlights NDP leader criticism; Article 5 frames government treatment of Black History Month as 'disappointing'). Sourcing quality is moderate \u2014 many articles name primary local sources (government departments, ministers, community leaders) but links to original releases/reports are rarely provided and some attributions are vague ('critics say', 'they say'), which reduces transparency and verifiability. The pipeline found no discrete failed fact-checks across searches of major fact-check databases (PolitiFact, Reuters Fact Check, AP Fact Check, FactCheck.org, Snopes, FullFact, LeadStories). Pseudoscience detection returned no promotion of fringe science and reporting aligns with mainstream scientific consensus where applicable. Overall: clear progressive editorial viewpoint with generally factual reporting, moderate sourcing practices, and limited site-level transparency.",
20
+ "failed_fact_checks": [],
21
+ "last_updated": "2026-03-18"
22
+ }
23
+ }