bibraeval 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bibraeval/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """BIBRA evaluation package."""
2
+
3
+ from .aggregator import Aggregator, IntermediateResults
4
+ from .comparator import Comparator
5
+ from .data_ingester import Record, RecordCollection
6
+ from .evaluator import Evaluator
7
+ from .field_metrics import Exact, FieldMetric, Levenshtein
8
+ from .metric_conf import FieldMetricConfig, FieldMetricMapping
9
+
10
+ __all__ = [
11
+ "Aggregator",
12
+ "Comparator",
13
+ "Evaluator",
14
+ "Exact",
15
+ "FieldMetric",
16
+ "FieldMetricConfig",
17
+ "FieldMetricMapping",
18
+ "IntermediateResults",
19
+ "Levenshtein",
20
+ "Record",
21
+ "RecordCollection",
22
+ ]
@@ -0,0 +1,218 @@
1
+ """Aggregation of field-level comparisons into macro-averaged metrics."""
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ import polars as pl
8
+
9
+ from bibraeval.comparator import Comparator
10
+
11
+ AggregationMode = Literal["doc-avg", "field-avg", "micro-avg"]
12
+
13
+ METRICS = ("f1", "prec", "rec")
14
+ _PRIMARY_KEYS = ("doc_id", "field_name")
15
+ _UNGROUPABLE = ("gt_value", "pred_value")
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class IntermediateResults:
20
+ """Per-group metrics and the grouping metadata used to produce them.
21
+
22
+ ``data`` contains grouping columns, precision/recall/F1, and support
23
+ counts. ``group_by`` records the grouping columns; ``mode`` identifies
24
+ document-, field-, or micro-averaging.
25
+ """
26
+
27
+ data: pl.DataFrame
28
+ group_by: tuple[str, ...]
29
+ mode: AggregationMode
30
+
31
+ @property
32
+ def strata(self) -> list[str]:
33
+ """Grouping columns other than the built-in document/field keys."""
34
+ return [col for col in self.group_by if col not in _PRIMARY_KEYS]
35
+
36
+ @property
37
+ def weighted_summary(self) -> bool:
38
+ """Whether field weights are applied when producing calling the
39
+ summarise_results method."""
40
+ return "field_name" in self.group_by
41
+
42
+
43
+ class Aggregator:
44
+ """Aggregate a comparator's field-level scores into precision/recall/F1.
45
+
46
+ Cell scores are computed at initialization. Add any custom strata columns to
47
+ ``comparator.comparison_matrix`` before constructing the aggregator.
48
+ """
49
+
50
+ def __init__(self, comparator: Comparator):
51
+ self.comparator = comparator
52
+ self.intermediate_results: IntermediateResults | None = None
53
+ self.comparison_matrix = self.comparator.compute_cell_agreement()
54
+ self.cells = self._compute_cell_scores(self.comparison_matrix)
55
+
56
+ @staticmethod
57
+ def _compute_cell_scores(cells: pl.DataFrame) -> pl.DataFrame:
58
+ """Mask cell agreement by presence for precision and recall."""
59
+ agreement = pl.col("cell_agreement")
60
+ return cells.with_columns(
61
+ pl.when(~pl.col("pred_present"))
62
+ .then(None)
63
+ .when(pl.col("gt_present"))
64
+ .then(agreement)
65
+ .otherwise(0.0)
66
+ .cast(pl.Float64)
67
+ .alias("prec_score"),
68
+ pl.when(~pl.col("gt_present"))
69
+ .then(None)
70
+ .when(pl.col("pred_present"))
71
+ .then(agreement)
72
+ .otherwise(0.0)
73
+ .cast(pl.Float64)
74
+ .alias("rec_score"),
75
+ )
76
+
77
+ def compute_intermediate_results(
78
+ self, group_by: str | Sequence[str] | None
79
+ ) -> IntermediateResults:
80
+ """Compute precision, recall and f1 per group.
81
+
82
+ For macro averaging `group_by` must contain exactly one of `doc_id` or
83
+ `field_name`; other columns may be included as strata.
84
+ When grouping by `field_name`, field weights are carried to the summary
85
+ step; otherwise they are applied as weighted means within each group.
86
+
87
+ Set ``group_by=None`` for micro-averaging. For a stratified micro
88
+ average, group only by the desired (additional) stratification columns.
89
+ Value columns cannot be used as grouping keys.
90
+
91
+ Raises:
92
+ ValueError: If a grouping column is missing, a value column is
93
+ requested, or both primary keys are included.
94
+ """
95
+ if group_by is None:
96
+ group_cols = ()
97
+ elif isinstance(group_by, str):
98
+ group_cols = (group_by,)
99
+ else:
100
+ group_cols = tuple(group_by)
101
+ self._validate_group_by(group_cols)
102
+
103
+ weighted_summary = "field_name" in group_cols
104
+ aggs: list[pl.Expr] = []
105
+ for name, score in (("prec", "prec_score"), ("rec", "rec_score")):
106
+ aggs.append(pl.col(score).count().alias(f"support_{name}"))
107
+ if weighted_summary:
108
+ aggs.append(pl.col(score).mean().alias(name))
109
+ else:
110
+ aggs.append(_weighted_mean(pl.col(score), pl.col("weight")).alias(name))
111
+ if weighted_summary:
112
+ aggs.append(pl.col("weight").first())
113
+
114
+ if group_cols:
115
+ data = self.cells.group_by(group_cols, maintain_order=True).agg(aggs)
116
+ else:
117
+ data = self.cells.select(aggs)
118
+
119
+ data = data.with_columns(_f1(pl.col("prec"), pl.col("rec")).alias("f1"))
120
+ if group_cols:
121
+ data = data.sort(group_cols)
122
+
123
+ self.intermediate_results = IntermediateResults(
124
+ data=data, group_by=group_cols, mode=_mode(group_cols)
125
+ )
126
+ return self.intermediate_results
127
+
128
+ def _validate_group_by(self, group_cols: tuple[str, ...]) -> None:
129
+ if all(col in group_cols for col in _PRIMARY_KEYS):
130
+ raise ValueError("group_by cannot include both 'doc_id' and 'field_name'.")
131
+ missing = [col for col in group_cols if col not in self.cells.columns]
132
+ if missing:
133
+ raise ValueError(f"group_by columns not found: {missing}")
134
+ invalid = [col for col in group_cols if col in _UNGROUPABLE]
135
+ if invalid:
136
+ raise ValueError(f"Cannot group by value columns: {invalid}")
137
+
138
+ def summarise_results(
139
+ self, intermediate: IntermediateResults | None = None
140
+ ) -> pl.DataFrame:
141
+ """Summarize intermediate results while preserving strata.
142
+
143
+ Macro modes average the intermediate results; micro mode returns its
144
+ pooled result without another aggregation. The long-form output has one
145
+ row per metric and stratum, with ``metric``, ``mode``, ``value``, and
146
+ ``support`` columns in addition to any strata columns.
147
+
148
+ Raises:
149
+ ValueError: If no intermediate results are available and none are
150
+ passed explicitly.
151
+ """
152
+ if intermediate is None:
153
+ intermediate = self.intermediate_results
154
+ if intermediate is None:
155
+ raise ValueError(
156
+ "No intermediate results; call compute_intermediate_results first."
157
+ )
158
+
159
+ strata = intermediate.strata
160
+ index = strata + (["weight"] if intermediate.weighted_summary else [])
161
+ long_index = (
162
+ [*index, "support_prec", "support_rec"]
163
+ if intermediate.mode in ["micro-avg", "field-avg"]
164
+ else index
165
+ )
166
+ long = intermediate.data.select(*long_index, *METRICS).unpivot(
167
+ on=list(METRICS), index=long_index, variable_name="metric"
168
+ )
169
+ value = (
170
+ _weighted_mean(pl.col("value"), pl.col("weight"))
171
+ if intermediate.weighted_summary
172
+ else pl.col("value").mean()
173
+ )
174
+ support = (
175
+ pl.when(pl.col("metric") == "prec")
176
+ .then(pl.col("support_prec"))
177
+ .when(pl.col("metric") == "rec")
178
+ .then(pl.col("support_rec"))
179
+ .otherwise(pl.max_horizontal("support_prec", "support_rec"))
180
+ .mean()
181
+ if intermediate.mode in ["micro-avg", "field-avg"]
182
+ else pl.col("value").count()
183
+ )
184
+ return (
185
+ long.group_by([*strata, "metric"])
186
+ .agg(value.alias("value"), support.alias("support"))
187
+ .with_columns(pl.lit(intermediate.mode).alias("mode"))
188
+ .select(*strata, "metric", "mode", "value", "support")
189
+ .sort([*strata, "metric"])
190
+ )
191
+
192
+
193
+ def _weighted_mean(score: pl.Expr, weight: pl.Expr) -> pl.Expr:
194
+ """Weighted mean over non-null scores; null if no weight remains."""
195
+ weight_sum = weight.filter(score.is_not_null()).sum()
196
+ return (
197
+ pl.when(weight_sum > 0)
198
+ .then((score * weight).sum() / weight_sum)
199
+ .otherwise(None)
200
+ )
201
+
202
+
203
+ def _f1(prec: pl.Expr, rec: pl.Expr) -> pl.Expr:
204
+ return (
205
+ pl.when(prec.is_null() & rec.is_null())
206
+ .then(None)
207
+ .when(prec.is_null() | rec.is_null() | ((prec + rec) == 0))
208
+ .then(0.0)
209
+ .otherwise(2 * prec * rec / (prec + rec))
210
+ )
211
+
212
+
213
+ def _mode(group_cols: tuple[str, ...]) -> AggregationMode:
214
+ if "doc_id" in group_cols:
215
+ return "doc-avg"
216
+ if "field_name" in group_cols:
217
+ return "field-avg"
218
+ return "micro-avg"
@@ -0,0 +1,178 @@
1
+ from typing import Any
2
+
3
+ import polars as pl
4
+
5
+ from bibraeval.data_ingester import MetadataT, RecordCollection
6
+ from bibraeval.field_metrics import Exact, FieldMetric
7
+ from bibraeval.metric_conf import FieldMetricMapping
8
+
9
+
10
+ class Comparator:
11
+ """Match two record collections and score their metadata fields.
12
+
13
+ The comparison matrix contains one row per document and metadata field.
14
+ Document ID sets must match unless ``drop_mismatched_doc_ids`` is enabled;
15
+ in that case only IDs present in both collections are compared.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ ground_truth: RecordCollection[MetadataT],
21
+ predictions: RecordCollection[MetadataT],
22
+ metric_schema: FieldMetricMapping | dict[str, Any] | None = None,
23
+ drop_mismatched_doc_ids: bool = False,
24
+ ):
25
+ """Create a comparator for validated record collections.
26
+
27
+ Args:
28
+ ground_truth: Reference records keyed by document ID.
29
+ predictions: Predicted records keyed by document ID.
30
+ metric_schema: Per-field metric configuration. Unlisted fields are
31
+ omitted from scoring when a schema is supplied.
32
+ drop_mismatched_doc_ids: If true, compare only IDs in both
33
+ collections; otherwise a mismatch raises ``ValueError``.
34
+ """
35
+ self.ground_truth = ground_truth
36
+ self.predictions = predictions
37
+ self.drop_mismatched_doc_ids = drop_mismatched_doc_ids
38
+ if isinstance(metric_schema, dict):
39
+ metric_schema = FieldMetricMapping.from_dict(metric_schema)
40
+ self.metric_schema = metric_schema
41
+
42
+ if not self.drop_mismatched_doc_ids:
43
+ self._check_doc_id_match()
44
+ self.comparison_matrix = self._fuse_records()
45
+
46
+ def _check_doc_id_match(self) -> bool:
47
+ """Check if two record collections contain the same doc_ids."""
48
+ gt_doc_ids = set(self.ground_truth.records)
49
+ pred_doc_ids = set(self.predictions.records)
50
+ if gt_doc_ids == pred_doc_ids:
51
+ return True
52
+
53
+ mismatches_gt_not_pred = gt_doc_ids - pred_doc_ids
54
+ mismatches_pred_not_gold = pred_doc_ids - gt_doc_ids
55
+ n_mismatches = len(gt_doc_ids ^ pred_doc_ids)
56
+ raise ValueError(
57
+ f"Record collections have {n_mismatches} mismatched doc_ids: "
58
+ f"ground_truth not in predictions: {mismatches_gt_not_pred}, "
59
+ f"predictions not in ground_truth: {mismatches_pred_not_gold}"
60
+ f"Use drop_mismatched_doc_ids=True to ignore mismatched doc_ids."
61
+ )
62
+
63
+ def _fuse_records(self) -> pl.DataFrame:
64
+ """Fuse records into one long Polars DataFrame by doc_id and field.
65
+
66
+ The ``gt_present`` and ``pred_present`` flags are false for ``None``,
67
+ empty strings, and empty lists. Other values, including ``0`` and
68
+ ``False``, count as present.
69
+
70
+ Columns returned:
71
+ - `doc_id`
72
+ - `field_name`
73
+ - `gt_present`
74
+ - `pred_present`
75
+ - `gt_value` (may be nested for list fields)
76
+ - `pred_value` (may be nested for list fields)
77
+ """
78
+ fused_data = []
79
+ for doc_id in sorted(self.ground_truth.records):
80
+ if self.drop_mismatched_doc_ids and doc_id not in self.predictions.records:
81
+ continue
82
+ gt_record = self.ground_truth.records[doc_id]
83
+ pred_record = self.predictions.records[doc_id]
84
+ gt_metadata = gt_record.metadata.model_dump(by_alias=True)
85
+ pred_metadata = pred_record.metadata.model_dump(by_alias=True)
86
+ field_names = sorted(gt_metadata.keys() | pred_metadata.keys())
87
+
88
+ for field_name in field_names:
89
+ gt_value = gt_metadata.get(field_name)
90
+ pred_value = pred_metadata.get(field_name)
91
+ fused_data.append(
92
+ {
93
+ "doc_id": doc_id,
94
+ "field_name": field_name,
95
+ # custom test for presence of value, exclusing empty
96
+ # strings and empty lists
97
+ "gt_present": self._is_present(gt_value),
98
+ "pred_present": self._is_present(pred_value),
99
+ "gt_value": gt_value,
100
+ "pred_value": pred_value,
101
+ }
102
+ )
103
+
104
+ return pl.DataFrame(
105
+ fused_data,
106
+ schema_overrides={"gt_value": pl.Object, "pred_value": pl.Object},
107
+ )
108
+
109
+ @staticmethod
110
+ def _is_present(value: object) -> bool:
111
+ return value is not None and value != "" and value != []
112
+
113
+ def compute_cell_agreement(
114
+ self,
115
+ comparison_matrix: pl.DataFrame | None = None,
116
+ metric: FieldMetric | None = None,
117
+ ) -> pl.DataFrame:
118
+ """Compute cell-level agreement between gold and predicted values.
119
+
120
+ With a `metric_schema`, only fields listed in the schema are scored, each
121
+ with its configured metric and weight. Otherwise all fields are scored
122
+ with `metric` (default `Exact()`) and weight 1.0.
123
+
124
+ Args:
125
+ comparison_matrix: Matrix to score; defaults to this comparator's
126
+ matrix. It must contain ``field_name``, ``gt_value``, and
127
+ ``pred_value`` columns. Additional columns may be present to
128
+ facilitate stratified analysis.
129
+ metric: Metric used when no metric schema was supplied.
130
+
131
+ Raises:
132
+ ValueError: If both ``metric`` and a configured metric schema are
133
+ supplied.
134
+ """
135
+ if comparison_matrix is None:
136
+ comparison_matrix = self.comparison_matrix
137
+
138
+ if self.metric_schema is not None:
139
+ if metric is not None:
140
+ raise ValueError("Pass either metric or metric_schema, not both.")
141
+ return self._compute_schema_agreement(comparison_matrix, self.metric_schema)
142
+
143
+ if metric is None:
144
+ metric = Exact()
145
+
146
+ scores = [
147
+ metric.score(gt_value, pred_value)
148
+ for gt_value, pred_value in comparison_matrix.select(
149
+ "gt_value", "pred_value"
150
+ ).iter_rows()
151
+ ]
152
+ return comparison_matrix.with_columns(
153
+ pl.Series("cell_agreement", scores, dtype=pl.Float64),
154
+ pl.lit(1.0, dtype=pl.Float64).alias("weight"),
155
+ )
156
+
157
+ def _compute_schema_agreement(
158
+ self, comparison_matrix: pl.DataFrame, schema: FieldMetricMapping
159
+ ) -> pl.DataFrame:
160
+ comparison_matrix = comparison_matrix.filter(
161
+ pl.col("field_name").is_in(list(schema.fields))
162
+ )
163
+ metrics = {name: schema.build_metric(name) for name in schema.fields}
164
+
165
+ scores = [
166
+ metrics[field_name].score(gt_value, pred_value)
167
+ for field_name, gt_value, pred_value in comparison_matrix.select(
168
+ "field_name", "gt_value", "pred_value"
169
+ ).iter_rows()
170
+ ]
171
+ weights = [
172
+ schema.fields[field_name].weight
173
+ for field_name in comparison_matrix["field_name"]
174
+ ]
175
+ return comparison_matrix.with_columns(
176
+ pl.Series("cell_agreement", scores, dtype=pl.Float64),
177
+ pl.Series("weight", weights, dtype=pl.Float64),
178
+ )
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, TypeVar
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ MetadataT = TypeVar("MetadataT", bound=BaseModel)
10
+
11
+
12
+ class Record[MetadataT: BaseModel](BaseModel):
13
+ """A record with a stable document identifier and validated metadata.
14
+
15
+ ``metadata`` is parsed with the caller-supplied Pydantic model; ``doc_id``
16
+ is kept separately so records can be matched independently of row order.
17
+ """
18
+
19
+ doc_id: str
20
+ metadata: MetadataT
21
+
22
+ @classmethod
23
+ def from_payload(
24
+ cls,
25
+ doc_id: str,
26
+ payload: dict[str, Any],
27
+ schema: type[MetadataT],
28
+ ) -> Record[MetadataT]:
29
+ """Validate a metadata payload and create a record for ``doc_id``.
30
+
31
+ Args:
32
+ doc_id: Stable identifier used to match this record across datasets.
33
+ payload: Metadata fields accepted by ``schema``.
34
+ schema: Pydantic model used to validate the metadata.
35
+ """
36
+ validated_metadata = schema.model_validate(payload)
37
+ return cls(doc_id=doc_id, metadata=validated_metadata)
38
+
39
+
40
+ class RecordCollection[MetadataT: BaseModel](BaseModel):
41
+ """A collection of records keyed by document identifier."""
42
+
43
+ records: dict[str, Record[MetadataT]] = Field(default_factory=dict)
44
+
45
+ @classmethod
46
+ def create_from_dir(
47
+ cls,
48
+ directory: str | Path,
49
+ schema: type[MetadataT],
50
+ ) -> RecordCollection[MetadataT]:
51
+ """Load ``*.json`` records from a directory, using each filename stem
52
+ as its document ID.
53
+
54
+ Files must contain JSON objects. Other files and nested directories are
55
+ ignored.
56
+ """
57
+ collection = cls()
58
+ for record_path in sorted(Path(directory).glob("*.json")):
59
+ with record_path.open(encoding="utf-8") as record_file:
60
+ payload = json.load(record_file)
61
+ if not isinstance(payload, dict):
62
+ msg = f"Record file {record_path} must contain a JSON object."
63
+ raise TypeError(msg)
64
+ collection.add_payload(
65
+ doc_id=record_path.stem,
66
+ payload=payload,
67
+ schema=schema,
68
+ )
69
+ return collection
70
+
71
+ @classmethod
72
+ def create_from_file(
73
+ cls,
74
+ file_path: str | Path,
75
+ schema: type[MetadataT],
76
+ ) -> RecordCollection[MetadataT]:
77
+ """Load JSONL records whose objects each contain a non-empty ``doc_id``.
78
+
79
+ Blank lines are ignored. Duplicate IDs replace the earlier record.
80
+ """
81
+ collection = cls()
82
+ with Path(file_path).open(encoding="utf-8") as jsonl_file:
83
+ for line_number, line in enumerate(jsonl_file, start=1):
84
+ if not line.strip():
85
+ continue
86
+ payload = json.loads(line)
87
+ if not isinstance(payload, dict):
88
+ msg = (
89
+ f"Line {line_number} in {file_path} must contain a JSON object."
90
+ )
91
+ raise TypeError(msg)
92
+ doc_id = payload.get("doc_id")
93
+ if not isinstance(doc_id, str) or not doc_id:
94
+ msg = f"Line {line_number} in {file_path} must contain a doc_id."
95
+ raise ValueError(msg)
96
+ raw_record = payload.get("record")
97
+ if not isinstance(raw_record, dict) or not raw_record:
98
+ msg = (
99
+ f"Line {line_number} in {file_path} "
100
+ "must contain a record object."
101
+ )
102
+ raise TypeError(msg)
103
+ collection.add_payload(
104
+ doc_id=doc_id,
105
+ payload=raw_record,
106
+ schema=schema,
107
+ )
108
+ return collection
109
+
110
+ def add_record(self, record: Record[MetadataT]) -> None:
111
+ """Store a record, replacing any existing record with the same ID."""
112
+ self.records[record.doc_id] = record
113
+
114
+ def get_record(self, doc_id: str) -> Record[MetadataT] | None:
115
+ """Return a record by ID, or ``None`` when the ID is absent."""
116
+ return self.records.get(doc_id)
117
+
118
+ def add_payload(
119
+ self,
120
+ doc_id: str,
121
+ payload: dict[str, Any],
122
+ schema: type[MetadataT],
123
+ ) -> Record[MetadataT]:
124
+ """Validate payload against metadata schema and store
125
+ payload, replacing an existing matching ID."""
126
+ record = Record.from_payload(doc_id=doc_id, payload=payload, schema=schema)
127
+ self.add_record(record)
128
+ return record
129
+
130
+ def as_list(self) -> list[Record[MetadataT]]:
131
+ """Return the stored records as a list in insertion order."""
132
+ return list(self.records.values())