Coverage for collectra/types/base.py: 80.31%

325 statements  

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

1import uuid 

2from dataclasses import dataclass, field 

3from pathlib import Path 

4from typing import Any 

5 

6import numpy as np 

7import yaml 

8from rich import print 

9 

10from collectra.cli import command 

11 

12from ..commons import BaseEntity, Node, NodeStatus 

13from ..utils import ( 

14 change_dir, 

15 error_msg, 

16 load_class_from_string, 

17 traceback_error, 

18) 

19 

20__all__ = ["Artefact", "ArtefactNode"] 

21 

22from ..logger import get_logger 

23 

24logger = get_logger(__name__) 

25 

26 

27class ArtefactCommands: 

28 """CLI capabilities for a named pipeline artefact node. 

29 

30 Concrete artefact classes can return a subclass from ``cli_commands`` to 

31 expose commands applying to every matching item in that node. 

32 """ 

33 

34 def __init__(self, node: "ArtefactNode", ext: str) -> None: 

35 self.node = node 

36 self.ext = ext 

37 

38 @command 

39 def evaluate( 

40 self, 

41 predicted_folder: Path, 

42 gold_folder: Path, 

43 threshold: float = 0.5, 

44 match_by_order: bool = False, 

45 output: Path | None = None, 

46 ) -> None: 

47 """Evaluate this artefact against ground-truth Collectra results.""" 

48 from collectra.evaluator.base import Evaluator 

49 

50 report = Evaluator( 

51 predicted_folder, 

52 gold_folder, 

53 ext=self.ext, 

54 labels={self.node.name}, 

55 match_by_order=match_by_order, 

56 ).evaluate(threshold=threshold) 

57 for table in [*report.aggregate_tables, *report.tables]: 

58 print(table) 

59 if output is not None: 

60 report.save_csv(output) 

61 

62 

63@dataclass 

64class Artefact(BaseEntity): 

65 name: str 

66 id: str = field(default="") 

67 parents: list[str] = field(default_factory=list) 

68 partition: str = field(default="") 

69 embeddings: list[str] = field(default_factory=list) 

70 orientation: str = field(default="north") # e.g., "landscape" or "portrait" 

71 ensemble: list[str] = field(default_factory=list) 

72 source_file: Path | None = field( 

73 default=None, init=False, repr=False, compare=False 

74 ) 

75 

76 def set_parents(self, parents: list["Artefact"]) -> None: 

77 self.parents = [parent.id for parent in parents] 

78 

79 def serialize(self) -> dict: 

80 serialized = super().serialize() 

81 if not self.partition: 

82 serialized.pop("partition", None) 

83 if "parents" in serialized: 

84 if len(serialized["parents"]) == 0: 

85 serialized.pop("parents") 

86 elif len(serialized["parents"]) == 1: 

87 serialized["parents"] = serialized["parents"][0] 

88 return serialized 

89 

90 def evaluate(self, gold) -> float: 

91 raise NotImplementedError( 

92 "Eval method not implemented for base Artefact class." 

93 ) 

94 

95 def display(self, context) -> dict: 

96 """Describe this artefact using the display protocol (see collectra.display). 

97 

98 Override to return an image, text, or properties view, or supply a 

99 custom JavaScript module using ``context.renderer(module_path)``. 

100 The context resolves related artefacts and publishes previews without 

101 altering data. See docs/artefacts/display.rst for the renderer contract. 

102 """ 

103 return {"kind": "properties", "data": self.serialize()} 

104 

105 def _extract(self, path: Path) -> None: 

106 raise NotImplementedError( 

107 "Extract method not implemented for base Artefact class." 

108 ) 

109 

110 def extract(self, path: Path | str) -> None: 

111 path = Path(path) 

112 path.parent.mkdir(parents=True, exist_ok=True) 

113 self._extract(path) 

114 

115 @classmethod 

116 def cli_commands(cls, node: "ArtefactNode", ext: str) -> ArtefactCommands: 

117 """Return the CLI object representing this artefact type in ``node``.""" 

118 return ArtefactCommands(node, ext) 

119 

120 def _generate_id(self) -> str: 

121 # Generate a unique ID based on the name and other attributes 

122 return f"{self.name}-{uuid.uuid4()}" 

123 

124 def __post_init__(self) -> None: 

125 super().__init__(self.name) # Initialize BaseEntity 

126 if not self.id: 

127 self.id = self._generate_id() 

128 

129 def attributes_to_ignore(self) -> set: 

130 attributes = super().attributes_to_ignore() 

131 attributes.add("name") 

132 attributes.add("partition") 

133 attributes.add("source_file") 

134 return attributes 

135 

136 @classmethod 

137 def all_attributes(cls): 

138 return set( 

139 [ 

140 attribute 

141 for attribute in dir(cls) 

142 if not attribute.startswith("_") and not callable(attribute) 

143 ] 

144 ) 

145 

146 

147@dataclass 

148class ArtefactNode(Node): 

149 

150 items: dict[str, Artefact] = field(default_factory=dict) 

151 ensemble_items: dict[str, Artefact] = field(default_factory=dict) 

152 types: set[type] = field(default_factory=set) 

153 skip_type_check: bool = False 

154 ensemble: bool = False 

155 

156 def __post_init__(self) -> None: 

157 super().__post_init__() 

158 self.status = NodeStatus.READY if self.items else NodeStatus.NOT_READY 

159 

160 def add_item(self, item: Artefact) -> None: 

161 try: 

162 self.items[item.id] = item 

163 self.status = NodeStatus.READY 

164 except Exception as e: 

165 raise RuntimeError(f"Error adding item to ArtefactNode: {str(e)}") 

166 

167 def get_item(self, item_id: str) -> Artefact | None: 

168 return self.items.get(item_id, None) 

169 

170 def add_type(self, type_: type) -> None: 

171 self.types.add(type_) 

172 

173 def check_type(self, type_: type) -> bool: 

174 for t in self.types: 

175 if issubclass(type_, t): 

176 return True 

177 return False 

178 

179 def __str__(self) -> str: 

180 types_str = "\n".join([t.get_class_path() for t in self.types]) 

181 return f"{self.name}\n{types_str}" 

182 

183 def evaluate( 

184 self, gold_items: "ArtefactNode", threshold: float = 0.5 

185 ) -> dict[str, int | float]: 

186 """ 

187 Evaluate predicted items against gold standard items. 

188 

189 This implements the Hungarian algorithm (optimal bipartite matching) that: 

190 1. Validates type compatibility between predicted and gold items 

191 2. Computes pairwise scores between all predicted and gold items 

192 3. Finds optimal assignment that maximizes total matching score 

193 4. Filters matches below threshold 

194 5. Computes aggregate metrics (precision, recall, F1, mean score) 

195 

196 Args: 

197 gold_items: ArtefactNode containing ground truth items 

198 threshold: Minimum score for a valid match (default 0.5) 

199 - For ImageCrop: IoU threshold 

200 - For Text: similarity threshold 

201 

202 Returns: 

203 dict: { 

204 "precision": float, # TP / (TP + FP) 

205 "recall": float, # TP / (TP + FN) 

206 "f1": float, # 2 * (P * R) / (P + R) 

207 "mean_score": float, # Average score of matched pairs 

208 "num_predicted": int, # Total predicted items 

209 "num_gold": int, # Total gold items 

210 "num_matched": int, # Number of successful matches (TP) 

211 "num_false_positives": int, # Predicted items not matched 

212 "num_false_negatives": int, # Gold items not matched 

213 "matches": [ # List of matched pairs 

214 { 

215 "predicted_id": str, 

216 "gold_id": str, 

217 "score": float 

218 }, 

219 ... 

220 ], 

221 "unmatched_predicted": [str], # IDs of unmatched predictions 

222 "unmatched_gold": [str] # IDs of unmatched gold items 

223 } 

224 

225 Raises: 

226 TypeError: If predicted and gold items are of incompatible types 

227 """ 

228 

229 if threshold > 1.0 or threshold < 0.0: 

230 raise ValueError(f"Invalid threshold provided: {threshold}") 

231 

232 # Use gold items for type detection when self is empty, so labels with 

233 # no predictions still appear in aggregated metrics (e.g. empty [] in YAML) 

234 _type_source = self.items if self.items else gold_items.items 

235 metrics = { 

236 "types": list(set([type(item).__name__ for item in _type_source.values()])), 

237 "precision": 0.0, 

238 "recall": 0.0, 

239 "f1": 0.0, 

240 "mean_score": 0.0, 

241 "num_predicted": 0, 

242 "num_gold": 0, 

243 "num_matched": 0, 

244 "num_false_positives": 0, 

245 "num_false_negatives": 0, 

246 "matches": [], 

247 "unmatched_predicted": [], 

248 "unmatched_gold": [], 

249 } 

250 

251 # Edge case: Empty inputs with explicit semantic definitions 

252 if len(self.items) == 0 and len(gold_items.items) == 0: 

253 # Vacuous truth: nothing to predict, nothing to find 

254 metrics.update({"precision": 1.0, "recall": 1.0, "f1": 1.0}) 

255 return metrics 

256 

257 if len(self.items) == 0: 

258 # No predictions: precision undefined (0/0), defaults to 0.0 for consistency 

259 metrics.update( 

260 { 

261 "precision": 0.0, 

262 "num_gold": len(gold_items.items), 

263 "num_false_negatives": len(gold_items.items), 

264 "unmatched_gold": list(gold_items.items.keys()), 

265 } 

266 ) 

267 return metrics 

268 

269 if len(gold_items.items) == 0: 

270 # No gold items: zero precision (all FPs), perfect recall (nothing to miss) 

271 metrics.update( 

272 { 

273 "recall": 1.0, 

274 "num_predicted": len(self.items), 

275 "num_false_positives": len(self.items), 

276 "unmatched_predicted": list(self.items.keys()), 

277 } 

278 ) 

279 return metrics 

280 

281 # Step 0: Validate type compatibility upfront 

282 predicted_types = set([type(value).__name__ for value in self.items.values()]) 

283 gold_types = set([type(value).__name__ for value in gold_items.items.values()]) 

284 compatible = predicted_types == gold_types 

285 if not compatible: 

286 raise TypeError( 

287 f"Cannot evaluate {predicted_types} predictions against " 

288 f"{gold_types} gold items. Types must match." 

289 ) 

290 

291 # Step 1: Build score matrix 

292 # Shape: (num_predicted, num_gold) 

293 predicted_ids = list(self.items.keys()) 

294 gold_ids = list(gold_items.items.keys()) 

295 n_pred = len(self.items.keys()) 

296 n_true = len(gold_items.items.keys()) 

297 score_matrix = np.zeros((n_pred, n_true)) 

298 

299 if n_true > n_pred: 

300 padding = np.full([n_true - n_pred, n_true], 1e-9) 

301 score_matrix = np.vstack([score_matrix, padding]) 

302 elif n_true < n_pred: 

303 padding = np.full([n_pred, n_pred - n_true], 1e-9) 

304 score_matrix = np.hstack([score_matrix, padding]) 

305 

306 for pred_idx, pred_id in enumerate(predicted_ids): 

307 pred_item = self.items[pred_id] 

308 for gold_idx, gold_id in enumerate(gold_ids): 

309 try: 

310 gold_item = gold_items.items[gold_id] 

311 score = pred_item.evaluate(gold_item) 

312 score_matrix[pred_idx, gold_idx] = score 

313 except NotImplementedError as e: 

314 logger.debug(f"Skipping evaluation for {pred_id} vs {gold_id}: {e}") 

315 

316 # Step 2: Hungarian algorithm for optimal bipartite matching 

317 # Convert to cost matrix (minimize cost = maximize score) 

318 # scipy.optimize.linear_sum_assignment minimizes total cost 

319 # If cost_matrix has a shape of 1, skip this step 

320 if score_matrix.size != 1: 

321 from scipy.optimize import linear_sum_assignment 

322 

323 cost_matrix = 1.0 - score_matrix 

324 

325 # Note: When multiple predicted items have identical scores against the same 

326 # gold item, the assignment is deterministic but arbitrary (based on internal 

327 # algorithm traversal order). All such assignments are equally optimal. 

328 try: 

329 pred_indices, gold_indices = linear_sum_assignment(cost_matrix) 

330 except Exception as e: 

331 raise RuntimeError(f"Error during optimal assignment: {str(e)}") 

332 else: 

333 pred_indices = np.array([0]) 

334 gold_indices = np.array([0]) 

335 

336 # Step 3: Filter matches by threshold and record results 

337 matches = [] 

338 matched_predicted = set() 

339 matched_gold = set() 

340 

341 for pred_idx, gold_idx in zip(pred_indices, gold_indices): 

342 if pred_idx >= n_pred or gold_idx >= n_true: 

343 continue 

344 score = score_matrix[pred_idx, gold_idx] 

345 # Only accept matches above the threshold 

346 if score >= threshold: 

347 predicted_id = predicted_ids[pred_idx] 

348 gold_id = gold_ids[gold_idx] 

349 matches.append( 

350 { 

351 "predicted_id": predicted_id, 

352 "gold_id": gold_id, 

353 "score": float(score), 

354 } 

355 ) 

356 matched_predicted.add(predicted_id) 

357 matched_gold.add(gold_id) 

358 

359 # Step 4: Identify unmatched items 

360 unmatched_predicted = [ 

361 predicted_id 

362 for predicted_id in predicted_ids 

363 if predicted_id not in matched_predicted 

364 ] 

365 

366 unmatched_gold = [ 

367 gold_id for gold_id in gold_ids if gold_id not in matched_gold 

368 ] 

369 

370 # Step 5: Compute metrics 

371 num_true_positives = len(matches) 

372 num_false_positives = len(unmatched_predicted) 

373 num_false_negatives = len(unmatched_gold) 

374 

375 precision = ( 

376 num_true_positives / (num_true_positives + num_false_positives) 

377 if (num_true_positives + num_false_positives) > 0 

378 else 0.0 

379 ) 

380 

381 recall = ( 

382 num_true_positives / (num_true_positives + num_false_negatives) 

383 if (num_true_positives + num_false_negatives) > 0 

384 else 0.0 

385 ) 

386 

387 f1 = ( 

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

389 if (precision + recall) > 0 

390 else 0.0 

391 ) 

392 

393 mean_score = ( 

394 sum(match["score"] for match in matches) / len(matches) if matches else 0.0 

395 ) 

396 

397 # Step 6: Return comprehensive evaluation results 

398 metrics.update( 

399 { 

400 "precision": precision, 

401 "recall": recall, 

402 "f1": f1, 

403 "mean_score": mean_score, 

404 "num_predicted": len(self.items), 

405 "num_gold": len(gold_items.items), 

406 "num_matched": num_true_positives, 

407 "num_false_positives": num_false_positives, 

408 "num_false_negatives": num_false_negatives, 

409 "matches": matches, 

410 "unmatched_predicted": unmatched_predicted, 

411 "unmatched_gold": unmatched_gold, 

412 } 

413 ) 

414 return metrics 

415 

416 def _create_instance( 

417 self, cls_: type, *, source_file: Path | None = None, **item 

418 ) -> None: 

419 try: 

420 instance = cls_(**item) 

421 if not instance: 

422 raise ValueError(f"Failed to load {item} with {cls_}") 

423 instance.source_file = source_file 

424 self.add_item(instance) 

425 except Exception as e: 

426 logger.error( 

427 "Failed to create instance of %s: %s", cls_.__name__, e, exc_info=True 

428 ) 

429 

430 def _create_ensemble_instances(self, base_path: Path, cls_: type, **item) -> None: 

431 try: 

432 item["id"] = f"{base_path.parent}::{item['id']}" 

433 if "parents" in item: 

434 for parent_idx, _ in enumerate(item["parents"]): 

435 item["parents"][ 

436 parent_idx 

437 ] = f"{base_path.parent}::{item['parents'][parent_idx]}" 

438 instance = cls_(**item) 

439 if not instance: 

440 raise ValueError(f"Failed to load {item} with {cls_}") 

441 self.ensemble_items[instance.id] = instance 

442 self.status = NodeStatus.READY 

443 except Exception as e: 

444 raise RuntimeError(f"Error creating ensemble instances: {str(e)}") 

445 

446 def _create_instances(self, **item) -> None: 

447 for cls_ in self.types: 

448 self._create_instance(cls_, **item) 

449 

450 def process( 

451 self, 

452 key: str, 

453 value: str | Path | None = None, 

454 **kwargs, 

455 ) -> None: 

456 skip_type_check: bool = kwargs.get("skip_type_check", False) 

457 ensemble: bool = kwargs.get("ensemble", False) 

458 value = value if value else kwargs.get("file", None) 

459 if value and Path(value).exists() and Path(value).is_dir(): 

460 value = Path(value) 

461 with change_dir(value): 

462 try: 

463 result_file = Path("results.yaml") 

464 with open(result_file, "r") as f: 

465 results: dict = yaml.safe_load(f) 

466 partition = results.pop( 

467 "collectra_results_metadata", dict() 

468 ).get("partition", None) 

469 if key not in results: 

470 raise ValueError( 

471 f"[red]{key}[/red] could not be found in {value}" 

472 ) 

473 data = results[key] 

474 if not data: 

475 # Key exists but empty — pipeline processed it, found nothing 

476 self.status = NodeStatus.READY 

477 return 

478 data = data if isinstance(data, list) else [data] 

479 for item in data: 

480 primitive_type = False 

481 try: 

482 primitive_type = not isinstance(item, dict) or not ( 

483 "type" in item 

484 and ( 

485 "path" in item 

486 or "data" in item 

487 or item["type"].endswith(".Link") 

488 ) 

489 ) 

490 if primitive_type: 

491 raise ValueError( 

492 f"Item must be a complex type with 'type' and ('path' or 'data')" 

493 ) 

494 cls_ = load_class_from_string(item.pop("type")) 

495 if not ( 

496 skip_type_check or ensemble 

497 ) and not self.check_type(cls_): 

498 raise TypeError( 

499 f"{cls_} is not a subclass or not defined in {self.types}" 

500 ) 

501 item["name"] = key 

502 if ( 

503 "data" not in item 

504 and "path" in item 

505 and "path" not in cls_.__dataclass_fields__ 

506 ): 

507 item["data"] = item.pop("path") 

508 if "parents" in item and not isinstance( 

509 item["parents"], list 

510 ): 

511 item["parents"] = [item["parents"]] 

512 if ( 

513 partition is not None 

514 and "partition" in cls_.all_attributes() 

515 ): 

516 item["partition"] = partition 

517 if ensemble: 

518 self._create_ensemble_instances(value, cls_, **item) 

519 else: 

520 self._create_instance( 

521 cls_, source_file=value, **item 

522 ) 

523 except Exception as e: 

524 if not primitive_type: 

525 logger.error( 

526 "Error processing item in %s: %s", 

527 self.name, 

528 e, 

529 exc_info=True, 

530 ) 

531 else: 

532 self._create_instances(name=key, data=str(item)) 

533 

534 except Exception as e: 

535 logger.error( 

536 "Error loading data for %s from %s: %s", 

537 key, 

538 value, 

539 e, 

540 exc_info=True, 

541 ) 

542 elif key: 

543 self._create_instances(name=key, data=value) 

544 

545 @staticmethod 

546 def batch_process( 

547 item_file: Path, 

548 artefact_nodes: list["ArtefactNode"], 

549 *, 

550 include_unlabelled: bool = False, 

551 ) -> list[Artefact]: 

552 names = [artefact_node.name for artefact_node in artefact_nodes] 

553 with change_dir(item_file): 

554 try: 

555 data: list[Artefact] = list() 

556 result_file = Path("results.yaml") 

557 if not result_file.exists(): 

558 print(error_msg(f"Invalid file: {Path.cwd()}. Ignoring...")) 

559 return data 

560 with open(result_file, "r") as f: 

561 file_data: dict = yaml.safe_load(f) 

562 partition = file_data.get("collectra_results_metadata", dict()).get( 

563 "partition", None 

564 ) 

565 all_names_not_found = all(name not in file_data for name in names) 

566 # Object detection can opt into base images with no annotations 

567 # as negative samples. Classification must not treat an 

568 # unlabelled image as a training example. 

569 if all_names_not_found and include_unlabelled: 

570 print( 

571 f"[yellow]No matching labels found in [blue]{item_file}[/blue] " 

572 f"for names: {', '.join(names)}. Using its base image as an " 

573 "unlabelled detection sample." 

574 ) 

575 found_base = False 

576 for name, values in file_data.items(): 

577 values = values if isinstance(values, list) else [values] 

578 for item in values: 

579 if ( 

580 not isinstance(item, dict) 

581 or "type" not in item 

582 and ("data" not in item or "path" not in item) 

583 ): 

584 continue 

585 cls_str = item.pop("type", "") 

586 if cls_str == "collectra.Image": 

587 cls_ = load_class_from_string(cls_str) 

588 try: 

589 item["name"] = name 

590 item["data"] = ( 

591 item.pop("path") 

592 if "path" in item 

593 else item["data"] 

594 ) 

595 if partition is not None: 

596 item["partition"] = partition 

597 instance = cls_(**item) 

598 if not instance: 

599 raise Warning( 

600 f"Failed to load {item} with {cls_}" 

601 ) 

602 found_base = True 

603 data.append(instance) 

604 break 

605 except Exception as e: 

606 traceback_error( 

607 e, 

608 f"Failed to load data item {name} from {item.get('data', '')}", 

609 verbose=True, 

610 ) 

611 if found_base: 

612 break 

613 elif all_names_not_found: 

614 return data 

615 for i, name in enumerate(names): 

616 if name not in file_data: 

617 continue 

618 value = file_data[name] 

619 if not value: 

620 raise Warning( 

621 f"Artefact seems to be empty for {name} in {item_file}. Provided: {value}" 

622 ) 

623 value = value if isinstance(value, list) else [value] 

624 for item in value: 

625 if not isinstance(item, dict) or not ( 

626 "type" in item 

627 and ( 

628 "data" in item 

629 or "path" in item 

630 or item["type"].endswith(".Link") 

631 ) 

632 ): 

633 continue 

634 cls_ = load_class_from_string(item.pop("type")) 

635 match = False 

636 for type_ in artefact_nodes[i].types: 

637 if issubclass(cls_, type_) or cls_ == type_: 

638 match = True 

639 break 

640 if not match: 

641 continue 

642 item["name"] = name 

643 if "path" in item and "path" not in cls_.__dataclass_fields__: 

644 item["data"] = item.pop("path") 

645 if partition is not None: 

646 item["partition"] = partition 

647 try: 

648 instance = cls_(**item) 

649 if instance: 

650 instance.source_file = item_file 

651 data.append(instance) 

652 except Exception as e: 

653 traceback_error( 

654 e, 

655 f"Failed to load data item {name} from {item.get('data', '')}: {e}", 

656 ) 

657 return data 

658 except Exception as e: 

659 traceback_error(e, verbose=True) 

660 return list() 

661 

662 

663def load_artefact(item: dict[str, Any]): 

664 artefact_class = load_class_from_string(item.pop("type")) 

665 

666 return artefact_class(**item)