Source code for atomworks.ml.datasets.concat_dataset

"""Dataset concatenation and wrapper utilities."""

import copy
import logging
from functools import cached_property
from typing import Any

from torch.utils.data import ConcatDataset, Dataset

from .base import ExampleIDProtocol

logger = logging.getLogger("datasets")


[docs] class ConcatDatasetWithID(ConcatDataset): """Equivalent to :class:`torch.utils.data.ConcatDataset` but allows accessing examples by ID. Provides ID-based access across multiple datasets that implement :class:`ExampleIDProtocol`. """ datasets: list[ExampleIDProtocol] def __init__(self, datasets: list[ExampleIDProtocol]): """Initialize ConcatDatasetWithID. Args: datasets: List of datasets that implement ExampleIDProtocol. """ super().__init__(datasets) # Log the length of each dataset for i, dataset in enumerate(datasets): logger.info(f"Dataset {i} ({type(dataset)}): {len(dataset):,} examples") @cached_property def _can_convert_ids_and_idx(self) -> bool: """Check if all sub-datasets can convert between IDs and indices.""" has_id_to_idx = all(hasattr(sub_dataset, "id_to_idx") for sub_dataset in self.datasets) has_idx_to_id = all(hasattr(sub_dataset, "idx_to_id") for sub_dataset in self.datasets) return has_id_to_idx and has_idx_to_id and self._can_check_contains @cached_property def _can_check_contains(self) -> bool: """Check if all sub-datasets support contains operations.""" return all(hasattr(sub_dataset, "__contains__") for sub_dataset in self.datasets) def _raise_if_cannot_check_contains(self) -> None: """Raise error if dataset cannot check contains.""" if not self._can_check_contains: raise ValueError("This dataset cannot check if it contains an example ID.") def _raise_if_cannot_convert_ids_and_idx(self) -> None: """Raise error if dataset cannot convert IDs and indices.""" if not self._can_convert_ids_and_idx: raise ValueError("This dataset cannot convert example IDs to indices.") def _raise_if_idx_out_of_bounds(self, idx: int) -> None: """Raise error if index is out of bounds. Args: idx: The index to check. """ if idx < 0 or idx >= len(self): raise ValueError(f"Index {idx} out of bounds for dataset of length {len(self)}.") def __contains__(self, example_id: str) -> bool: """Check if the dataset contains the example ID. Args: example_id: The ID to check for. Returns: True if the ID exists in any sub-dataset. """ self._raise_if_cannot_check_contains() return any(example_id in sub_dataset for sub_dataset in self.datasets)
[docs] def id_to_idx(self, example_id: str) -> int: """Retrieves the index corresponding to the example ID. Args: example_id: The ID to convert. Returns: The corresponding index. Raises: ValueError: If the example ID is not found. Warning: Assumes that the example ID is unique within the dataset. If not, the first occurrence of the example ID is returned. """ # TODO: Generalize to list[str] self._raise_if_cannot_convert_ids_and_idx() offset = 0 for sub_dataset in self.datasets: if example_id in sub_dataset: return offset + sub_dataset.id_to_idx(example_id) offset += len(sub_dataset) raise ValueError(f"Example ID {example_id} not found in any sub-dataset.")
[docs] def idx_to_id(self, idx: int) -> str: """Retrieves the example ID corresponding to the index. Args: idx: The index to convert. Returns: The corresponding example ID. Raises: ValueError: If the index is out of bounds. """ # TODO: Generalize to list[int] self._raise_if_cannot_convert_ids_and_idx() self._raise_if_idx_out_of_bounds(idx) for sub_dataset in self.datasets: if idx < len(sub_dataset): return sub_dataset.idx_to_id(idx) idx -= len(sub_dataset) # This should never be reached raise ValueError(f"Index {idx} out of bounds for any sub-dataset.")
[docs] def get_dataset_by_idx(self, idx: int) -> Dataset: """Retrieves the dataset containing the index. Args: idx: The index to find. Returns: The sub-dataset containing the index. Raises: ValueError: If the index is out of bounds. """ self._raise_if_idx_out_of_bounds(idx) for sub_dataset in self.datasets: if idx < len(sub_dataset): return sub_dataset idx -= len(sub_dataset) # This should never be reached raise ValueError(f"Index {idx} out of bounds for any sub-dataset.")
[docs] def get_dataset_by_id(self, example_id: str) -> Dataset: """Retrieves the dataset containing the example ID. Args: example_id: The ID to find. Returns: The sub-dataset containing the ID. Warning: Assumes that the example ID is unique within the dataset. If not, the first occurrence of the example ID is returned. """ idx = self.id_to_idx(example_id) return self.get_dataset_by_idx(idx)
[docs] def get_row_and_index_by_example_id(dataset: ExampleIDProtocol, example_id: str) -> dict: """Retrieve a row and its index from a nested dataset structure by its example ID. Args: dataset: The dataset or concatenated dataset to search. Must have the `id_to_idx` method. example_id: The example ID to search for. Returns: Dictionary containing the row and global index corresponding to the example ID. """ assert hasattr(dataset, "id_to_idx"), "Dataset must have the `id_to_idx` method." idx = dataset.id_to_idx(example_id) _local_idx = copy.deepcopy(idx) while isinstance(dataset, ConcatDatasetWithID): dataset = dataset.get_dataset_by_idx(_local_idx) _local_idx = dataset.id_to_idx(example_id) idx_local = dataset.id_to_idx(example_id) row = dataset.metadata.get_row(idx_local) return {"row": row, "index": idx}
[docs] class FallbackDatasetWrapper(Dataset): """A wrapper around a dataset that allows for a fallback dataset to be used when an error occurs. Meant to be used with a FallbackSamplerWrapper. """ def __init__(self, dataset: Dataset, fallback_dataset: Dataset): """Initialize FallbackDatasetWrapper. Args: dataset: The primary dataset to retrieve data from. fallback_dataset: The fallback dataset to use when an error occurs. This may be the same as the primary dataset, or a different one. """ self.dataset = dataset self.fallback_dataset = fallback_dataset def __getitem__(self, idxs: tuple[int, ...]) -> Any: """Attempt to retrieve an item from the primary dataset, falling back to additional indices if errors occur. If all attempts fail, raises a RuntimeError containing all encountered exceptions. Args: idxs: Tuple of indices, where the first is for the primary dataset and the rest are for fallbacks. Returns: The retrieved item from the first successful dataset. Raises: KeyboardInterrupt: If interrupted. StopIteration: If iteration should stop. RuntimeError: If all attempts fail, with a list of all exceptions encountered. """ error_list = [] example_id_list = [] for i, idx in enumerate(idxs): dataset = self.dataset if i == 0 else self.fallback_dataset dataset_name = "Primary dataset" if i == 0 else f"Fallback {i}/{len(idxs)-1}" try: return dataset[idx] except (KeyboardInterrupt, StopIteration): raise except Exception as e: error_list.append(e) # Log the error example_id = f" ({dataset.idx_to_id(idx)})" if hasattr(dataset, "idx_to_id") else "" example_id_list.append(example_id) logger.error(f"({dataset_name}): Error ({e}) at index {idx}.{example_id}") # Log fallback attempt if not the last one if i < len(idxs) - 1: logger.warning(f"({dataset_name}): Trying fallback index {idxs[i+1]}.{example_id}") # All attempts failed logger.error( f"(Exceeded all {len(idxs)-1} fallbacks. Training will crash now. Errors: {error_list} for examples: {example_id_list})" ) raise RuntimeError(f"All attempts failed for indices {idxs}. See error_list for details.") from ExceptionGroup( "All fallback attempts failed", error_list ) def __len__(self): """Return the length of the primary dataset.""" return len(self.dataset)