Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ docs = [
]
test = ["pytest", "coverage", "pre-commit"]
benchmark = ["requests"]
dev = ["valor-lite[nlp, openai, mistral, benchmark, test, docs]"]
dev = ["valor-lite[nlp, openai, mistral, benchmark, test, docs]", "pyarrow-stubs"]

[tool.black]
line-length = 79
Expand Down
61 changes: 59 additions & 2 deletions src/valor_lite/cache/compute.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import heapq
import tempfile
from pathlib import Path
from typing import Callable
from typing import Callable, Generator

import pyarrow as pa
import pyarrow.compute as pc

from valor_lite.cache.ephemeral import MemoryCacheReader, MemoryCacheWriter
from valor_lite.cache.persistent import FileCacheReader, FileCacheWriter
Expand Down Expand Up @@ -50,7 +51,9 @@ def create_sort_key(
batch_iterators = []
batches = []
for batch_idx, batch_iter in enumerate(
intermediate_source.iterate_fragments(batch_size=batch_size)
intermediate_source.iterate_fragment_batch_iterators(
batch_size=batch_size
)
):
batch_iterators.append(batch_iter)
batches.append(next(batch_iterators[batch_idx], None))
Expand Down Expand Up @@ -152,3 +155,57 @@ def sort(
columns=columns,
table_sort_override=table_sort_override,
)


def paginate_index(
source: MemoryCacheReader | FileCacheReader,
column_key: str,
modifier: pc.Expression | None = None,
limit: int | None = None,
offset: int = 0,
) -> Generator[pa.Table, None, None]:
"""
Iterate through a paginated cache reader.

Note this function expects unqiue keys to be fragment-aligned.
"""
total = source.count_rows()
limit = limit if limit else total

# pagination broader than data scope
if offset == 0 and limit >= total:
for tbl in source.iterate_tables(filter=modifier):
yield tbl
return
elif offset >= total:
return

curr_idx = 0
for tbl in source.iterate_tables(filter=modifier):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the iterator return tables always in the same order? Is the order compatible with the ordering on column_key? (Or does that even matter, seems like it might not as long as it's always the same and keys don't span fragments?)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the iterator return tables always in the same order?

All iterators operate over the underlying cache fragment files in order (e.g. 0000, 0001, 0002, ...).

Is the order compatible with the ordering on column_key?

For the use case of paginating datums there is no interaction. For the primary ingestion cache, each fragment file is datum aligned and there is no rollover.

This fragment-aligned behavior is not the case for other columns and other ranked caches which is why this function lives in compute.py and not directly on the cache readers.

if tbl.num_rows == 0:
continue

# sort the unique keys as they may be out of order
unique_values = pc.unique(tbl[column_key]).sort() # type: ignore[reportAttributeAccessIssue]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docstring says unique keys should be fragment aligned and in ascending order. So why sort here?

Are datum_id keys fragment aligned? That's probably worth a comment saying so and mentioning why.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The datum_id column is fragment-aligned BUT is not necessarily ordered within the fragment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ive added a comment to better document this

n_unique = len(unique_values)
prev_idx = curr_idx
curr_idx += n_unique

# check for page overlap
if curr_idx <= offset:
continue
elif prev_idx >= (offset + limit):
return

# apply any pagination conditions
condition = pc.scalar(True)
if prev_idx < offset and curr_idx > offset:
condition &= (
pc.field(column_key) >= unique_values[offset - prev_idx]
)
if prev_idx < (offset + limit) and curr_idx > (offset + limit):
condition &= (
pc.field(column_key) < unique_values[offset + limit - prev_idx]
)

yield tbl.filter(condition)
2 changes: 1 addition & 1 deletion src/valor_lite/cache/ephemeral.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ def iterate_tables_with_arrays(
[tbl[col].to_numpy() for col in table_columns]
)

def iterate_fragments(
def iterate_fragment_batch_iterators(
self, batch_size: int
) -> Iterator[Iterator[pa.RecordBatch]]:
"""
Expand Down
33 changes: 20 additions & 13 deletions src/valor_lite/cache/persistent.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,23 @@ def _retrieve(config: dict, key: str):
compression=compression,
)

def iterate_ordered_fragments(
self, filter: pc.Expression | None = None
) -> Iterator[ds.Fragment]:
"""
Iterate through ordered cache fragments.
"""
dataset = ds.dataset(
source=self._path,
schema=self._schema,
format="parquet",
)
fragments = list(dataset.get_fragments(filter=filter))
for fragment in sorted(
fragments, key=lambda x: int(Path(x.path).stem)
):
yield fragment

def iterate_tables(
self,
columns: list[str] | None = None,
Expand All @@ -173,12 +190,7 @@ def iterate_tables(
-------
Iterator[pa.Table]
"""
dataset = ds.dataset(
source=self._path,
schema=self._schema,
format="parquet",
)
for fragment in dataset.get_fragments():
for fragment in self.iterate_ordered_fragments(filter=filter):
yield fragment.to_table(columns=columns, filter=filter)

def iterate_arrays(
Expand Down Expand Up @@ -243,7 +255,7 @@ def iterate_tables_with_arrays(
[tbl[col].to_numpy() for col in table_columns]
)

def iterate_fragments(
def iterate_fragment_batch_iterators(
self, batch_size: int
) -> Iterator[Iterator[pa.RecordBatch]]:
"""
Expand All @@ -258,12 +270,7 @@ def iterate_fragments(
-------
Iterator[Iterator[pa.RecordBatch]]
"""
dataset = ds.dataset(
source=self._path,
schema=self._schema,
format="parquet",
)
for fragment in dataset.get_fragments():
for fragment in self.iterate_ordered_fragments():
yield fragment.to_batches(batch_size=batch_size)


Expand Down
75 changes: 42 additions & 33 deletions src/valor_lite/classification/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,23 +528,6 @@ def iterate_values(self, datums: pc.Expression | None = None):
matches = tbl["match"].to_numpy()
yield ids, scores, winners, matches

def iterate_values_with_tables(self, datums: pc.Expression | None = None):
for tbl in self._reader.iterate_tables(filter=datums):
ids = np.column_stack(
[
tbl[col].to_numpy()
for col in [
"datum_id",
"gt_label_id",
"pd_label_id",
]
]
)
scores = tbl["pd_score"].to_numpy()
winners = tbl["pd_winner"].to_numpy()
matches = tbl["match"].to_numpy()
yield ids, scores, winners, matches, tbl

def compute_rocauc(self) -> dict[MetricType, list[Metric]]:
"""
Compute ROCAUC.
Expand Down Expand Up @@ -723,6 +706,8 @@ def compute_examples(
score_thresholds: list[float] = [0.0],
hardmax: bool = True,
datums: pc.Expression | None = None,
limit: int | None = None,
offset: int = 0,
) -> list[Metric]:
"""
Compute examples per datum.
Expand All @@ -737,6 +722,10 @@ def compute_examples(
Toggles whether a hardmax is applied to predictions.
datums : pyarrow.compute.Expression, optional
Option to filter datums by an expression.
limit : int, optional
Option to set a limit to the number of returned datum examples.
offset : int, default=0
Option to offset where examples are being created in the datum index.

Returns
-------
Expand All @@ -747,16 +736,29 @@ def compute_examples(
raise ValueError("At least one score threshold must be passed.")

metrics = []
for (
ids,
scores,
winners,
_,
tbl,
) in self.iterate_values_with_tables(datums=datums):
if ids.size == 0:
for tbl in compute.paginate_index(
source=self._reader,
column_key="datum_id",
modifier=datums,
limit=limit,
offset=offset,
):
if tbl.num_rows == 0:
continue

ids = np.column_stack(
[
tbl[col].to_numpy()
for col in [
"datum_id",
"gt_label_id",
"pd_label_id",
]
]
)
scores = tbl["pd_score"].to_numpy()
winners = tbl["pd_winner"].to_numpy()

# extract external identifiers
index_to_datum_id = create_mapping(
tbl, ids, 0, "datum_id", "datum_uid"
Expand Down Expand Up @@ -829,16 +831,23 @@ def compute_confusion_matrix_with_examples(
)
for score_idx, score_thresh in enumerate(score_thresholds)
}
for (
ids,
scores,
winners,
_,
tbl,
) in self.iterate_values_with_tables(datums=datums):
if ids.size == 0:
for tbl in self._reader.iterate_tables(filter=datums):
if tbl.num_rows == 0:
continue

ids = np.column_stack(
[
tbl[col].to_numpy()
for col in [
"datum_id",
"gt_label_id",
"pd_label_id",
]
]
)
scores = tbl["pd_score"].to_numpy()
winners = tbl["pd_winner"].to_numpy()

# extract external identifiers
index_to_datum_id = create_mapping(
tbl, ids, 0, "datum_id", "datum_uid"
Expand Down
27 changes: 17 additions & 10 deletions src/valor_lite/object_detection/evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,8 @@ def compute_examples(
iou_thresholds: list[float],
score_thresholds: list[float],
datums: pc.Expression | None = None,
limit: int | None = None,
offset: int = 0,
) -> list[Metric]:
"""
Computes examples at various thresholds.
Expand All @@ -614,6 +616,10 @@ def compute_examples(
A list of score thresholds to compute metrics over.
datums : pyarrow.compute.Expression, optional
Option to filter datums by an expression.
limit : int, optional
Option to set a limit to the number of returned datum examples.
offset : int, default=0
Option to offset where examples are being created in the datum index.

Returns
-------
Expand All @@ -626,11 +632,6 @@ def compute_examples(
raise ValueError("At least one score threshold must be passed.")

metrics = []
tbl_columns = [
"datum_uid",
"gt_uid",
"pd_uid",
]
numeric_columns = [
"datum_id",
"gt_id",
Expand All @@ -640,14 +641,20 @@ def compute_examples(
"iou",
"pd_score",
]
for tbl, pairs in self._detailed_reader.iterate_tables_with_arrays(
columns=tbl_columns + numeric_columns,
numeric_columns=numeric_columns,
filter=datums,
for tbl in compute.paginate_index(
source=self._detailed_reader,
column_key="datum_id",
modifier=datums,
limit=limit,
offset=offset,
):
if pairs.size == 0:
if tbl.num_rows == 0:
continue

pairs = np.column_stack(
[tbl[col].to_numpy() for col in numeric_columns]
)

index_to_datum_id = {}
index_to_groundtruth_id = {}
index_to_prediction_id = {}
Expand Down
62 changes: 62 additions & 0 deletions tests/classification/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,3 +681,65 @@ def test_examples_without_hardmax_animal_example(
assert m in expected_metrics
for m in expected_metrics:
assert m in actual_metrics


def test_examples_with_color_example_paginated(
loader: Loader,
classifications_color_example: list[Classification],
):

loader.add_data(classifications_color_example)
evaluator = loader.finalize()

actual_metrics = evaluator.compute_examples(
score_thresholds=[0.5],
limit=3,
offset=1,
)

actual_metrics = [m.to_dict() for m in actual_metrics]
expected_metrics = [
{
"type": "Examples",
"value": {
"datum_id": "uid1",
"true_positives": [],
"false_positives": ["blue"],
"false_negatives": ["white"],
},
"parameters": {
"score_threshold": 0.5,
"hardmax": True,
},
},
{
"type": "Examples",
"value": {
"datum_id": "uid2",
"true_positives": [],
"false_positives": [],
"false_negatives": ["red"],
},
"parameters": {
"score_threshold": 0.5,
"hardmax": True,
},
},
{
"type": "Examples",
"value": {
"datum_id": "uid3",
"true_positives": [],
"false_positives": ["white"],
"false_negatives": ["blue"],
},
"parameters": {
"score_threshold": 0.5,
"hardmax": True,
},
},
]
for m in actual_metrics:
assert m in expected_metrics
for m in expected_metrics:
assert m in actual_metrics
Loading