Coverage for collectra/evaluator/base.py: 92.31%

221 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 09:56 +0000

1__all__ = ["Evaluator"] 

2 

3import csv 

4from dataclasses import dataclass 

5from pathlib import Path 

6 

7import yaml 

8from rich.table import Table 

9 

10from ..logger import get_logger 

11from ..types import ArtefactNode 

12from ..utils import change_dir 

13 

14logger = get_logger(__name__) 

15 

16 

17@dataclass 

18class FileEvaluationResult: 

19 """Results for a single file pair evaluation.""" 

20 

21 filename: str 

22 label_metrics: dict[str, dict] 

23 

24 

25@dataclass 

26class EvaluationReport: 

27 """Complete evaluation report across all files.""" 

28 

29 file_results: list[FileEvaluationResult] 

30 aggregate: dict 

31 threshold: float 

32 

33 def __post_init__(self): 

34 self.aggregate_tables = [] 

35 self.tables = [] 

36 for data_type, metrics in self.aggregate.items(): 

37 """Turn the aggregate dict into a rich Table for display.""" 

38 table = Table(title=f"Aggregate Evaluation Metrics for {data_type}") 

39 table.add_column("Metric", style="cyan", no_wrap=True) 

40 table.add_column("Value", justify="right", style="green") 

41 

42 # Add overall metrics 

43 overall = metrics.get("overall", {}) 

44 table.add_row("Precision (Overall)", f"{overall.get('precision', 0):.3f}") 

45 table.add_row("Recall (Overall)", f"{overall.get('recall', 0):.3f}") 

46 table.add_row("F1 Micro", f"{overall.get('f1_micro', 0):.3f}") 

47 table.add_row( 

48 "F1 File-Averaged", f"{overall.get('f1_file_averaged', 0):.3f}" 

49 ) 

50 table.add_row("Total Files", str(overall.get("total_files", 0))) 

51 self.aggregate_tables.append(table) 

52 # Create per-label table 

53 per_label = metrics.get("per_label", {}) 

54 if not per_label: 

55 continue 

56 table = Table(title=f"Per-Label Metrics for Artefact Type: {data_type}") 

57 table.add_column("Label", style="magenta") 

58 table.add_column("Precision", justify="right", style="green") 

59 table.add_column("Recall", justify="right", style="green") 

60 table.add_column("F1", justify="right", style="green") 

61 table.add_column("Mean Score", justify="right", style="green") 

62 table.add_column("Support", justify="right", style="blue") 

63 for label_name, metrics in per_label.items(): 

64 table.add_row( 

65 label_name, 

66 f"{metrics.get('precision', 0):.3f}", 

67 f"{metrics.get('recall', 0):.3f}", 

68 f"{metrics.get('f1', 0):.3f}", 

69 f"{metrics.get('mean_score', 0):.3f}", 

70 str(metrics.get("support", 0)), 

71 ) 

72 self.tables.append(table) 

73 

74 def save_csv(self, output: Path) -> None: 

75 """Save file- and label-level evaluation metrics as a tidy CSV.""" 

76 output = Path(output).expanduser() 

77 output.parent.mkdir(parents=True, exist_ok=True) 

78 fieldnames = [ 

79 "filename", 

80 "label", 

81 "artefact_type", 

82 "precision", 

83 "recall", 

84 "f1", 

85 "mean_score", 

86 "num_predicted", 

87 "num_gold", 

88 "true_positives", 

89 "false_positives", 

90 "false_negatives", 

91 ] 

92 with output.open("w", newline="") as stream: 

93 writer = csv.DictWriter(stream, fieldnames=fieldnames) 

94 writer.writeheader() 

95 for result in self.file_results: 

96 for label, metrics in result.label_metrics.items(): 

97 types = metrics.get("types", []) or [""] 

98 for artefact_type in types: 

99 writer.writerow( 

100 { 

101 "filename": result.filename, 

102 "label": label, 

103 "artefact_type": artefact_type, 

104 "precision": metrics.get("precision", 0), 

105 "recall": metrics.get("recall", 0), 

106 "f1": metrics.get("f1", 0), 

107 "mean_score": metrics.get("mean_score", 0), 

108 "num_predicted": metrics.get("num_predicted", 0), 

109 "num_gold": metrics.get("num_gold", 0), 

110 "true_positives": metrics.get("num_matched", 0), 

111 "false_positives": metrics.get( 

112 "num_false_positives", 0 

113 ), 

114 "false_negatives": metrics.get( 

115 "num_false_negatives", 0 

116 ), 

117 } 

118 ) 

119 

120 

121class EvaluationError(Exception): 

122 """Raised when evaluation cannot proceed (e.g., missing gold files).""" 

123 

124 pass 

125 

126 

127class Evaluator: 

128 """ 

129 End-to-end evaluation orchestrator for Collectra workflows. 

130 

131 Responsibilities: 

132 1. Match input files to gold standard files by filename 

133 2. Load and parse .grapto (or other ext) folder structures 

134 3. Delegate per-label evaluation to ArtefactNode.evaluate() 

135 4. Aggregate metrics across files and labels 

136 5. Export results in tabular format 

137 """ 

138 

139 def __init__( 

140 self, 

141 predicted_folder: Path, 

142 gold_folder: Path, 

143 ext: str, 

144 labels: set[str] | None = None, 

145 match_by_order: bool = False, 

146 ) -> None: 

147 self.ext = ext.lower().replace(".", "") 

148 self.labels = labels 

149 predicted_folder = Path(predicted_folder).expanduser().resolve() 

150 gold_folder = Path(gold_folder).expanduser().resolve() 

151 direct_pair = self._is_collectra_folder( 

152 predicted_folder 

153 ) and self._is_collectra_folder(gold_folder) 

154 if match_by_order or direct_pair: 

155 self.entries = self._pair_folders_by_order(predicted_folder, gold_folder) 

156 else: 

157 entries = self._discover_collectra_folders(predicted_folder) 

158 self.entries = self._discover_collectra_folders( 

159 gold_folder, gold=True, entries=entries 

160 ) 

161 self.results: list[FileEvaluationResult] = [] 

162 self.aggregate_metrics: dict = {} 

163 

164 def _is_collectra_folder(self, path: Path) -> bool: 

165 return path.is_dir() and path.suffix == f".{self.ext}" 

166 

167 def _pair_folders_by_order( 

168 self, predicted_folder: Path, gold_folder: Path 

169 ) -> dict[str, list[Path | None]]: 

170 """Pair independently sorted prediction and gold result folders.""" 

171 predicted_entries = self._discover_collectra_folders(predicted_folder) 

172 gold_entries = self._discover_collectra_folders(gold_folder, gold=True) 

173 predictions = sorted( 

174 (paths[1] for paths in predicted_entries.values() if paths[1]), 

175 key=lambda path: path.name, 

176 ) 

177 golds = sorted( 

178 (paths[0] for paths in gold_entries.values() if paths[0]), 

179 key=lambda path: path.name, 

180 ) 

181 return { 

182 f"pair-{index:06d}": [ 

183 golds[index] if index < len(golds) else None, 

184 predictions[index] if index < len(predictions) else None, 

185 ] 

186 for index in range(max(len(predictions), len(golds))) 

187 } 

188 

189 def __call__(self) -> None: 

190 self.evaluate() 

191 

192 def _discover_collectra_folders( 

193 self, 

194 folder: Path, 

195 entries: dict[str, list[Path | None]] | None = None, 

196 gold: bool = False, 

197 ) -> dict[str, list[Path | None]]: 

198 """ 

199 Find .{ext} directories (e.g., .grapto folders). 

200 

201 ``folder`` may be either one result directory or a parent containing 

202 multiple result directories. 

203 

204 Args: 

205 folder: Path to search for .{ext} directories 

206 entries: dictionary of tuples base_name: (gold_path, predicted_path) to extend if empty, or to have entries updated. 

207 gold: Whether to look for gold files (as opposed to predicted files) 

208 

209 Returns: 

210 dictionary mapping base_name to tuples (gold_path, predicted_path) for each discovered .{ext} directory 

211 """ 

212 if entries is None: 

213 entries = {} 

214 folder = Path(folder).expanduser().resolve() 

215 if not folder.is_dir(): 

216 raise FileNotFoundError(f"Evaluation path is not a directory: {folder}") 

217 

218 items = [folder] if folder.suffix == f".{self.ext}" else list(folder.iterdir()) 

219 for item in items: 

220 if not (item.is_dir() and item.suffix == f".{self.ext}"): 

221 continue 

222 base_name = item.stem 

223 if base_name not in entries: 

224 if gold: 

225 entries[base_name] = [item, None] 

226 else: 

227 entries[base_name] = [None, item] 

228 else: 

229 existing_gold, existing_predicted = entries[base_name] 

230 if gold: 

231 entries[base_name] = [item, existing_predicted] 

232 else: 

233 entries[base_name] = [existing_gold, item] 

234 return entries 

235 

236 def _validate_file_pairs( 

237 self, 

238 ) -> tuple[list[tuple[Path, Path]], list[Path], list[Path]]: 

239 """ 

240 Match input files to gold files and identify mismatches. 

241 

242 Returns: 

243 - matched_pairs: list of (input_path, gold_path, base_name) 

244 - missing_gold: input files without corresponding gold 

245 - extra_gold: gold files without corresponding input 

246 """ 

247 

248 matched_pairs: list[tuple[Path, Path]] = [] 

249 missing_golds: list[Path] = [] 

250 extra_golds: list[Path] = [] 

251 

252 for key, (gold_path, predicted_path) in self.entries.items(): 

253 missing_gold = gold_path is None 

254 missing_predicted = predicted_path is None 

255 if not missing_gold and not missing_predicted: 

256 matched_pairs.append((predicted_path, gold_path)) 

257 if missing_gold and missing_predicted: 

258 raise ValueError( 

259 f"Entry '{key}' has neither gold nor predicted file. This should not happen." 

260 ) 

261 if missing_gold and predicted_path is not None: 

262 missing_golds.append(predicted_path) 

263 if missing_predicted and gold_path is not None: 

264 extra_golds.append(gold_path) 

265 

266 return matched_pairs, list(missing_golds), list(extra_golds) 

267 

268 def _load_collectra_file(self, path: Path) -> dict[str, ArtefactNode]: 

269 """ 

270 Parse a collectra file data into ArtefactNodes organized by label. 

271 

272 Each label (e.g., "registration_number", "local_text") becomes 

273 a separate ArtefactNode containing its items (ImageCrops, Texts). 

274 

275 Returns: 

276 dict mapping label_name -> ArtefactNode 

277 """ 

278 labels = dict() 

279 with change_dir(path): 

280 with open("results.yaml", "r") as f: 

281 data = yaml.safe_load(f) 

282 data.pop("collectra_results_metadata", "") 

283 node_names = list(data.keys()) 

284 for name in node_names: 

285 node = ArtefactNode(name=name) 

286 node.process(key=node.name, value=path, skip_type_check=True) 

287 labels[name] = node 

288 return labels 

289 

290 def _create_missing_prediction_metrics(self, gold_node: ArtefactNode) -> dict: 

291 """ 

292 Generate metrics when predictions are entirely missing for a gold label. 

293 

294 Scenario: Gold has items for this label, but predictions have none. 

295 Result: 

296 - Precision = 0.0 (undefined 0/0, defaults to 0.0 for consistency) 

297 - Recall = 0.0 (missed everything) 

298 - F1 = 0.0 

299 - All gold items are false negatives 

300 

301 This mirrors the edge case handling in ArtefactNode.evaluate() 

302 when self.items is empty but gold_items is not. 

303 """ 

304 gold_ids = list(gold_node.items.keys()) 

305 

306 return { 

307 "types": list( 

308 set([type(item).__name__ for item in gold_node.items.values()]) 

309 ), 

310 "precision": 0.0, # No predictions = undefined (0/0), defaults to 0.0 

311 "recall": 0.0, # Missed all gold items 

312 "f1": 0.0, 

313 "mean_score": 0.0, 

314 "num_predicted": 0, 

315 "num_gold": len(gold_ids), 

316 "num_matched": 0, 

317 "num_false_positives": 0, 

318 "num_false_negatives": len(gold_ids), 

319 "matches": [], 

320 "unmatched_predicted": [], 

321 "unmatched_gold": gold_ids, 

322 } 

323 

324 def _create_extra_prediction_metrics(self, input_node: ArtefactNode) -> dict: 

325 """ 

326 Generate metrics when predictions exist for a label not in gold. 

327 

328 Scenario: Predictions have items for this label, but gold has none. 

329 Result: 

330 - Precision = 0.0 (all predictions are false positives) 

331 - Recall = 1.0 (nothing to miss, vacuously true) 

332 - F1 = 0.0 

333 - All predicted items are false positives 

334 

335 This mirrors the edge case handling in ArtefactNode.evaluate() 

336 when gold_items.items is empty but self.items is not. 

337 

338 Note: This typically indicates either: 

339 1. Model hallucinated a label class 

340 2. Mismatch in label naming conventions 

341 3. Gold annotation is incomplete 

342 Consider logging a warning when this occurs. 

343 """ 

344 predicted_ids = list(input_node.items.keys()) 

345 

346 return { 

347 "types": list( 

348 set([type(item).__name__ for item in input_node.items.values()]) 

349 ), 

350 "precision": 0.0, # All predictions are wrong 

351 "recall": 1.0, # Nothing to find = found everything 

352 "f1": 0.0, 

353 "mean_score": 0.0, 

354 "num_predicted": len(predicted_ids), 

355 "num_gold": 0, 

356 "num_matched": 0, 

357 "num_false_positives": len(predicted_ids), 

358 "num_false_negatives": 0, 

359 "matches": [], 

360 "unmatched_predicted": predicted_ids, 

361 "unmatched_gold": [], 

362 } 

363 

364 def _evaluate_file_pair( 

365 self, input_path: Path, gold_path: Path, threshold: float = 0.5 

366 ) -> FileEvaluationResult: 

367 """ 

368 Evaluate a single input file against its gold standard. 

369 

370 Process: 

371 1. Load both files into label -> ArtefactNode mappings 

372 2. For each label present in gold, evaluate predictions 

373 3. Handle labels present in only one file (missing predictions or extra labels) 

374 

375 Returns: 

376 FileEvaluationResult containing per-label metrics 

377 """ 

378 input_labels = self._load_collectra_file(input_path) 

379 gold_labels = self._load_collectra_file(gold_path) 

380 

381 file_result = FileEvaluationResult(filename=input_path.stem, label_metrics={}) 

382 

383 all_labels = { 

384 l 

385 for l in (set(input_labels.keys()) | set(gold_labels.keys())) 

386 if not l.endswith("_draft") and (self.labels is None or l in self.labels) 

387 } 

388 

389 for label_name in all_labels: 

390 input_node = input_labels.get(label_name) 

391 gold_node = gold_labels.get(label_name) 

392 

393 if input_node is None and gold_node is None: 

394 # This should not happen since label is in all_labels 

395 continue 

396 elif input_node is None: 

397 # Missing predictions for this label 

398 metrics = self._create_missing_prediction_metrics(gold_node) 

399 elif gold_node is None: 

400 # Extra predictions for non-existent gold label 

401 metrics = self._create_extra_prediction_metrics(input_node) 

402 else: 

403 # Both exist - use ArtefactNode.evaluate() 

404 metrics = input_node.evaluate(gold_node, threshold=threshold) 

405 

406 file_result.label_metrics[label_name] = metrics 

407 

408 return file_result 

409 

410 def _aggregate_results(self) -> dict: 

411 """ 

412 Compute aggregate metrics across all files. 

413 

414 Aggregation strategy: 

415 - Macro average: Average of per-file metrics (treats each file equally) 

416 - Micro average: Pool all TP/FP/FN then compute (treats each item equally) 

417 - Per-label aggregates: Metrics grouped by label class 

418 """ 

419 from collections import defaultdict 

420 

421 metrics = ["precision", "recall", "f1", "mean_score"] 

422 data_types_set = set() 

423 for result in self.results: 

424 for label_name, label_metrics in result.label_metrics.items(): 

425 data_types = label_metrics.get("types", []) 

426 for data_type in data_types: 

427 data_types_set.add(data_type) 

428 

429 data_types = list(data_types_set) 

430 

431 aggregated_results = dict() 

432 

433 for data_type in data_types: 

434 # Initialize accumulators 

435 label_pools = defaultdict( 

436 lambda: { 

437 "total_tp": 0, 

438 "total_fp": 0, 

439 "total_fn": 0, 

440 "total_score": 0.0, 

441 "total_matches": 0, 

442 } 

443 ) 

444 

445 file_f1_scores = [] 

446 

447 for file_result in self.results: 

448 file_tp = file_fp = file_fn = 0 

449 for label_name, metrics in file_result.label_metrics.items(): 

450 if data_type not in metrics.get("types", []): 

451 continue 

452 pool = label_pools[label_name] 

453 pool["total_tp"] += metrics["num_matched"] 

454 pool["total_fp"] += metrics["num_false_positives"] 

455 pool["total_fn"] += metrics["num_false_negatives"] 

456 pool["total_score"] += ( 

457 metrics["mean_score"] * metrics["num_matched"] 

458 ) 

459 pool["total_matches"] += metrics["num_matched"] 

460 pool["types"] = metrics.get("types", []) 

461 

462 file_tp += metrics["num_matched"] 

463 file_fp += metrics["num_false_positives"] 

464 file_fn += metrics["num_false_negatives"] 

465 

466 # Per-file F1 

467 file_precision = ( 

468 file_tp / (file_tp + file_fp) if (file_tp + file_fp) > 0 else 0 

469 ) 

470 file_recall = ( 

471 file_tp / (file_tp + file_fn) if (file_tp + file_fn) > 0 else 0 

472 ) 

473 file_f1 = ( 

474 2 * file_precision * file_recall / (file_precision + file_recall) 

475 if (file_precision + file_recall) > 0 

476 else 0 

477 ) 

478 # Issues 2 & 3: skip files with no items of this type (vacuous truth) 

479 if (file_tp + file_fp + file_fn) == 0: 

480 logger.warning( 

481 "Skipping vacuous file-level metrics for '%s' (no items of " 

482 "type '%s'). Excluded from file-averaged F1.", 

483 file_result.filename, 

484 data_type, 

485 ) 

486 # Issue 5: skip files with predictions but no gold items for this type 

487 elif (file_tp + file_fn) == 0: 

488 logger.warning( 

489 "File '%s' has predictions but no gold items for type '%s'. " 

490 "Excluding from file-averaged F1 to avoid inflating recall.", 

491 file_result.filename, 

492 data_type, 

493 ) 

494 else: 

495 file_f1_scores.append(file_f1) 

496 

497 # Compute per-label micro metrics 

498 per_label_metrics = {} 

499 for label_name, pool in label_pools.items(): 

500 tp, fp, fn = pool["total_tp"], pool["total_fp"], pool["total_fn"] 

501 precision = tp / (tp + fp) if (tp + fp) > 0 else 0 

502 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 

503 f1 = ( 

504 2 * precision * recall / (precision + recall) 

505 if (precision + recall) > 0 

506 else 0 

507 ) 

508 mean_score = ( 

509 pool["total_score"] / pool["total_matches"] 

510 if pool["total_matches"] > 0 

511 else 0 

512 ) 

513 

514 per_label_metrics[label_name] = { 

515 "precision": precision, 

516 "recall": recall, 

517 "f1": f1, 

518 "mean_score": mean_score, 

519 "support": pool["total_tp"] 

520 + pool["total_fn"], # Number of gold items 

521 } 

522 

523 # Overall metrics 

524 total_tp = sum(p["total_tp"] for p in label_pools.values()) 

525 total_fp = sum(p["total_fp"] for p in label_pools.values()) 

526 total_fn = sum(p["total_fn"] for p in label_pools.values()) 

527 

528 overall_precision = ( 

529 total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0 

530 ) 

531 overall_recall = ( 

532 total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0 

533 ) 

534 overall_f1_micro = ( 

535 2 

536 * overall_precision 

537 * overall_recall 

538 / (overall_precision + overall_recall) 

539 if (overall_precision + overall_recall) > 0 

540 else 0 

541 ) 

542 overall_f1_file_averaged = ( 

543 sum(file_f1_scores) / len(file_f1_scores) if file_f1_scores else 0 

544 ) 

545 aggregated_results[data_type] = { 

546 "overall": { 

547 "precision": overall_precision, 

548 "recall": overall_recall, 

549 "f1_micro": overall_f1_micro, 

550 "f1_file_averaged": overall_f1_file_averaged, 

551 "total_files": len(self.results), 

552 }, 

553 "per_label": per_label_metrics, 

554 } 

555 

556 return aggregated_results 

557 

558 def evaluate(self, threshold: float = 0.5) -> EvaluationReport: 

559 """ 

560 Execute full evaluation across all matched file pairs. 

561 

562 Raises: 

563 EvaluationError: If any input file lacks a corresponding gold file 

564 """ 

565 self.results = [] 

566 matched_pairs, missing_gold, extra_gold = self._validate_file_pairs() 

567 

568 # Fail fast if gold files are missing 

569 if missing_gold: 

570 raise EvaluationError(f"Missing gold standard files for: {missing_gold}") 

571 

572 # Warn about extra gold files (not an error) 

573 if extra_gold: 

574 logger.warning(f"Gold files without predictions: {extra_gold}") 

575 

576 # Evaluate each pair 

577 for input_path, gold_path in matched_pairs: 

578 file_result = self._evaluate_file_pair(input_path, gold_path, threshold) 

579 self.results.append(file_result) 

580 

581 # Aggregate across all files 

582 self.aggregate_metrics = self._aggregate_results() 

583 

584 return EvaluationReport( 

585 file_results=self.results, 

586 aggregate=self.aggregate_metrics, 

587 threshold=threshold, 

588 )