| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| """Track data container analogous to AnnData.""" |
|
|
| from collections.abc import Sequence |
| import copy |
| import dataclasses |
| import enum |
| from typing import Any, Union |
|
|
| from flax_model.alphagenome._sdk import typing |
| from flax_model.alphagenome._sdk.data import genome |
| from flax_model.alphagenome._sdk.data import ontology |
| from jaxtyping import Bool, Float32, Int32 |
| import numpy as np |
| import pandas as pd |
|
|
| |
| |
| TrackMetadata = pd.DataFrame |
| PositionalIndex = slice | genome.Interval | int |
| TrackIndex = np.ndarray | Sequence[int] | Sequence[str] | slice | int | str |
| Index = PositionalIndex | tuple[PositionalIndex, TrackIndex] | TrackIndex |
|
|
|
|
| @enum.unique |
| class AggregationType(enum.Enum): |
| """Aggregation types for downsampling/upsampling track resolutions. |
| |
| SUM: Sum pooling, where values within a bin are summed. This is recommended |
| for continuous tracks where the total value within a bin is meaningful (e.g. |
| read counts, coverage). |
| MAX: Max pooling, where the maximum value within a bin is selected. This is |
| recommended for binary tracks (e.g., gene masks, regions of interest). |
| """ |
|
|
| SUM = 'sum' |
| MAX = 'max' |
|
|
|
|
| @typing.jaxtyped |
| @dataclasses.dataclass(frozen=True) |
| class TrackData: |
| """Container for storing track values and metadata. |
| |
| `TrackData` stores multiple genomic tracks at the same resolution, stacked |
| into an ND matrix of shape (positional_bins, num_tracks). It also contains |
| metadata information as a pandas DataFrame with `num_tracks` rows. |
| |
| Metadata DataFrame has two main required columns: |
| |
| * name: The name of the track. |
| * strand: The strand of the track ('+', '-', or '.'). |
| |
| Other columns are optional. |
| |
| Valid shapes of `TrackData.values` are: |
| |
| * [num_tracks] |
| * [positional_bins, num_tracks] |
| * [positional_bins, positional_bins, num_tracks] |
| * ... |
| |
| `TrackData` can store both model predictions and raw data. It can |
| optionally hold information about the `genome.Interval` from which the data |
| were derived and `.uns` for storing additional unstructured data. |
| |
| In addition to being a container, `TrackData` provides functionality for |
| common aggregation and slicing operations. |
| |
| Attributes: |
| values: A numpy array of floats or integers representing the track values. |
| Positional axes have the same length. Example valid shapes are: |
| [num_tracks], [positional_bins, num_tracks], and [positional_bins, |
| positional_bins, num_tracks]. |
| metadata: A pandas DataFrame containing metadata for each track. The |
| DataFrame must have at least two columns: 'name' and 'strand'. |
| resolution: The resolution of the track data in base pairs. |
| interval: An optional `Interval` object representing the genomic region. |
| uns: An optional dictionary to store additional unstructured data. |
| |
| Raises: |
| ValueError: If the number of tracks in `values` does not match the number |
| of rows in `metadata`, or if `metadata` contains duplicate (name, strand) |
| pairs, or if the positional axes have different lengths, or if the |
| interval width does not match the expected width. |
| """ |
|
|
| |
| values: Union[ |
| Float32[np.ndarray, '*positional_bins num_tracks'], |
| Int32[np.ndarray, '*positional_bins num_tracks'], |
| Bool[np.ndarray, '*positional_bins num_tracks'], |
| ] |
| metadata: TrackMetadata |
| resolution: int = 1 |
| interval: genome.Interval | None = None |
| uns: dict[str, Any] | None = ( |
| |
| None |
| ) |
|
|
| def __post_init__(self): |
| """Validates the consistency of the data.""" |
| if self.values.shape[-1] != len(self.metadata): |
| raise ValueError( |
| f'value number of tracks {self.values.shape[-1]} and ' |
| f'metadata {len(self.metadata)} do not match.' |
| ) |
|
|
| if self.positional_axes: |
| if len(set(np.array(self.values.shape)[self.positional_axes])) != 1: |
| raise ValueError('All positional axes must have the same length.') |
|
|
| if self.interval and self.interval.width != self.width: |
| raise ValueError( |
| f'Interval width must match expected width. {self.interval.width=},' |
| f' {self.width=}' |
| ) |
|
|
| if not {'name', 'strand'}.issubset(self.metadata.columns): |
| raise ValueError('Metadata must contain columns "name" and "strand".') |
|
|
| if self.metadata[['name', 'strand']].duplicated().any(): |
| raise ValueError( |
| 'Metadata contain duplicated values for (name, strand) tuples.' |
| ) |
|
|
| @property |
| def positional_axes(self) -> list[int]: |
| """Returns a list of the positional axes.""" |
| return list(range(self.values.ndim - 1)) |
|
|
| @property |
| def num_tracks(self) -> int: |
| """Returns the number of tracks.""" |
| return self.values.shape[-1] |
|
|
| @property |
| def width(self) -> int: |
| """Returns the interval width covered by the tracks.""" |
| if self.positional_axes: |
| return self.values.shape[0] * self.resolution |
| else: |
| return 0 |
|
|
| @property |
| def names(self) -> np.ndarray: |
| """Returns an array of track names (not necessarily unique).""" |
| return self.metadata['name'].values |
|
|
| @property |
| def strands(self) -> np.ndarray: |
| """Returns an array of track strands.""" |
| return self.metadata['strand'].values |
|
|
| @property |
| def ontology_terms(self) -> Sequence[ontology.OntologyTerm | None] | None: |
| """Returns a list of ontology terms (if available).""" |
| if 'ontology_curie' in self.metadata.columns: |
| return [ |
| ontology.from_curie(curie) if curie is not None else None |
| for curie in self.metadata['ontology_curie'].values |
| ] |
| else: |
| return None |
|
|
| def copy(self) -> 'TrackData': |
| """Returns a deep copy of the `TrackData` object.""" |
| if self.interval: |
| interval = self.interval.copy() |
| else: |
| interval = None |
| return TrackData( |
| self.values.copy(), |
| resolution=self.resolution, |
| metadata=self.metadata.copy(), |
| interval=interval, |
| uns=copy.deepcopy(self.uns), |
| ) |
|
|
| def bin_index(self, relative_position: int) -> int: |
| """Returns the bin index for a relative position. |
| |
| Args: |
| relative_position: The relative position within the interval. |
| |
| Returns: |
| The corresponding bin index. |
| """ |
| return relative_position // self.resolution |
|
|
| def slice_by_positions(self, start: int, end: int) -> 'TrackData': |
| """Slices the track data along the positional axes. |
| |
| The slicing follows Python slicing conventions (0 indexed, and includes |
| elements up to end-1). |
| |
| Args: |
| start: The 1-bp resolution start position for slicing. |
| end: The 1-bp resolution end position for slicing. |
| |
| Returns: |
| A new `TrackData` object with the sliced values. |
| |
| Raises: |
| ValueError: If (end - start) is greater than the width, or if (end - |
| start) is not divisible by the resolution. |
| """ |
| if (end - start) > self.width: |
| raise ValueError( |
| 'When slicing track data, (end - start) must be less than or ' |
| 'equal to width.' |
| ) |
|
|
| if (end - start) % self.resolution != 0: |
| raise ValueError( |
| f'end - start needs to be to be divisible by {self.resolution=}' |
| ) |
|
|
| sl = slice(self.bin_index(start), self.bin_index(end)) |
| slice_list = [slice(None)] * self.values.ndim |
| for i in self.positional_axes: |
| slice_list[i] = sl |
|
|
| interval = self.interval |
| if interval: |
| interval = genome.Interval( |
| interval.chromosome, |
| interval.start + start, |
| interval.start + end, |
| strand=interval.strand, |
| name=interval.name, |
| info=interval.info, |
| ) |
|
|
| return TrackData( |
| self.values[tuple(slice_list)], |
| resolution=self.resolution, |
| metadata=self.metadata, |
| interval=interval, |
| uns=self.uns, |
| ) |
|
|
| def slice_by_interval( |
| self, interval: genome.Interval, match_resolution: bool = False |
| ) -> 'TrackData': |
| """Slices the track data using a `genome.Interval`. |
| |
| Args: |
| interval: The interval to slice to. |
| match_resolution: If True, the interval will first be extended to make |
| sure the width is divisible by resolution. |
| |
| Returns: |
| A new `TrackData` object sliced to the interval. |
| |
| Raises: |
| ValueError: If `.interval` is not specified or if the specified interval |
| is not fully contained within the current interval. |
| """ |
| if self.interval is None: |
| raise ValueError( |
| '.interval is needs to be specified for slice_by_interval.' |
| ) |
| if not self.interval.contains(interval): |
| raise ValueError( |
| f'Interval {self.interval=} does not fully contain {interval=}.' |
| ) |
| start = interval.start - self.interval.start |
| end = interval.end - self.interval.start |
|
|
| if match_resolution and self.resolution != 1: |
| start = int(np.floor(start / self.resolution) * self.resolution) |
| end = int(np.ceil(end / self.resolution) * self.resolution) |
| return self.slice_by_positions(start, end) |
|
|
| def pad(self, start_pad: int, end_pad: int) -> 'TrackData': |
| """Pads the track data along positional axes. |
| |
| Args: |
| start_pad: The amount of padding to add at the beginning. |
| end_pad: The amount of padding to add at the end. |
| |
| Returns: |
| A new `TrackData` object with padded values. |
| |
| Raises: |
| ValueError: If `start_pad` or `end_pad` is not divisible by the |
| resolution. |
| """ |
| if start_pad == 0 and end_pad == 0: |
| return self |
| if start_pad % self.resolution != 0: |
| raise ValueError(f'start_pad needs to be divisible by {self.resolution}') |
| if end_pad % self.resolution != 0: |
| raise ValueError(f'end_pad needs to be divisible by {self.resolution}') |
|
|
| pad = [(0, 0)] * self.values.ndim |
| for axis in self.positional_axes: |
| pad[axis] = (start_pad // self.resolution, end_pad // self.resolution) |
|
|
| return TrackData( |
| np.pad(self.values, tuple(pad)), |
| resolution=self.resolution, |
| metadata=self.metadata, |
| interval=None, |
| uns=self.uns, |
| ) |
|
|
| def resize(self, width: int) -> 'TrackData': |
| """Resizes the track data by cropping or padding with a fixed center. |
| |
| Args: |
| width: The desired width in base pairs. |
| |
| Returns: |
| A new `TrackData` object with resized values. |
| |
| Raises: |
| ValueError: If `width` is not divisible by the resolution. |
| """ |
| if width == self.width: |
| return self |
| elif width > self.width: |
| if width % self.resolution != 0: |
| raise ValueError(f'width needs to be divisible by {self.resolution}') |
| pad_amount = (width - self.width) // self.resolution |
| pad_start = (pad_amount // 2 + pad_amount % 2) * self.resolution |
| pad_end = (pad_amount // 2) * self.resolution |
| return self.pad(pad_start, pad_end) |
| else: |
| crop_amount = (self.width - width) // self.resolution |
| start = (crop_amount // 2 + crop_amount % 2) * self.resolution |
| return self.slice_by_positions(start, start + width) |
|
|
| def upsample( |
| self, |
| resolution: int, |
| aggregation_type: AggregationType = AggregationType.SUM, |
| ) -> 'TrackData': |
| """Upsamples the track data to a higher resolution. |
| |
| Args: |
| resolution: The desired resolution in base pairs. |
| aggregation_type: The aggregation method to use for pooling the values. |
| |
| Returns: |
| A new `TrackData` object with upsampled values. |
| |
| Raises: |
| ValueError: If `resolution` is not lower than the current resolution |
| or not divisible by the current resolution. |
| """ |
| if resolution == self.resolution: |
| return self |
| if resolution > self.resolution: |
| raise ValueError(f'Resolution must be lower than {self.resolution}') |
| repeat = self.resolution // resolution |
| if self.resolution % resolution != 0: |
| raise ValueError(f'Resolution not divisible by {resolution}') |
|
|
| values = self.values |
| for axis in self.positional_axes: |
| values = np.repeat(values, repeat, axis=axis) |
| match aggregation_type: |
| case AggregationType.SUM: |
| values = values / repeat |
| case AggregationType.MAX: |
| pass |
| return TrackData( |
| values, |
| resolution=resolution, |
| metadata=self.metadata, |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
| def downsample( |
| self, |
| resolution: int, |
| aggregation_type: AggregationType = AggregationType.SUM, |
| ) -> 'TrackData': |
| """Downsamples the track data to a lower resolution. |
| |
| Args: |
| resolution: The desired resolution in base pairs. |
| aggregation_type: The aggregation method to use for pooling the values. |
| |
| Returns: |
| A new `TrackData` object with downsampled values. |
| |
| Raises: |
| ValueError: If `resolution` is not greater than the current resolution |
| or not divisible by the current resolution. |
| """ |
| if resolution == self.resolution: |
| return self |
| if resolution < self.resolution: |
| raise ValueError(f'Resolution must be greater than {self.resolution}') |
| if resolution % self.resolution != 0: |
| raise ValueError(f'Resolution not divisible by {resolution}') |
| pool_width = resolution // self.resolution |
|
|
| values = self.values |
| for axis in self.positional_axes: |
| |
| values = np.swapaxes(values, 0, axis) |
| shape = list(values.shape) |
| reshaped_values = values.reshape( |
| [shape[0] // pool_width, pool_width] + shape[1:] |
| ) |
| match aggregation_type: |
| case AggregationType.SUM: |
| values = reshaped_values.sum(axis=1) |
| case AggregationType.MAX: |
| values = reshaped_values.max(axis=1) |
| values = np.swapaxes(values, 0, axis) |
|
|
| return TrackData( |
| values, |
| resolution=resolution, |
| metadata=self.metadata, |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
| def change_resolution( |
| self, |
| resolution: int, |
| aggregation_type: AggregationType = AggregationType.SUM, |
| ) -> 'TrackData': |
| """Changes the resolution of the track data. |
| |
| Args: |
| resolution: The desired resolution in base pairs. |
| aggregation_type: The aggregation method to use for pooling the values. |
| |
| Returns: |
| A new `TrackData` object with the new resolution. |
| """ |
| if resolution >= self.resolution: |
| return self.downsample(resolution, aggregation_type) |
| else: |
| return self.upsample(resolution, aggregation_type) |
|
|
| def filter_tracks(self, mask: np.ndarray | list[bool]) -> 'TrackData': |
| """Filters tracks by a boolean mask. |
| |
| Args: |
| mask: A boolean mask to select tracks. |
| |
| Returns: |
| A new `TrackData` object with the filtered tracks. |
| """ |
| return TrackData( |
| self.values[..., mask], |
| resolution=self.resolution, |
| metadata=self.metadata.iloc[mask], |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
| def filter_to_positive_strand(self) -> 'TrackData': |
| """Filters tracks to the positive DNA strand.""" |
| return self.filter_tracks(self.strands == genome.STRAND_POSITIVE) |
|
|
| def filter_to_negative_strand(self) -> 'TrackData': |
| """Filters tracks to the negative DNA strand.""" |
| return self.filter_tracks(self.strands == genome.STRAND_NEGATIVE) |
|
|
| def filter_to_nonnegative_strand(self) -> 'TrackData': |
| """Filters tracks to the non-negative DNA strands (positive and unstranded).""" |
| return self.filter_tracks(self.strands != genome.STRAND_NEGATIVE) |
|
|
| def filter_to_nonpositive_strand(self) -> 'TrackData': |
| """Filters tracks to the non-positive DNA strands (negative and unstranded).""" |
| return self.filter_tracks(self.strands != genome.STRAND_POSITIVE) |
|
|
| def filter_to_stranded(self) -> 'TrackData': |
| """Filters tracks to stranded tracks (excluding unstranded).""" |
| return self.filter_tracks(self.strands != genome.STRAND_UNSTRANDED) |
|
|
| def filter_to_unstranded(self) -> 'TrackData': |
| """Filters tracks to unstranded tracks.""" |
| return self.filter_tracks(self.strands == genome.STRAND_UNSTRANDED) |
|
|
| def select_tracks_by_index( |
| self, idx: np.ndarray | Sequence[int] |
| ) -> 'TrackData': |
| """Selects tracks by numerical index. |
| |
| Args: |
| idx: A list or array of numerical indices to select tracks. |
| |
| Returns: |
| A new `TrackData` object with the selected tracks. |
| """ |
| return TrackData( |
| self.values[..., idx], |
| resolution=self.resolution, |
| metadata=self.metadata.iloc[idx], |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
| def select_tracks_by_name( |
| self, names: np.ndarray | Sequence[str] |
| ) -> 'TrackData': |
| """Selects tracks by name. |
| |
| Args: |
| names: A list or array of track names to select. |
| |
| Returns: |
| A new `TrackData` object with the selected tracks. |
| """ |
| track_idx = pd.Series(np.arange(self.num_tracks), index=self.names) |
| return self.select_tracks_by_index(track_idx.loc[names].values) |
|
|
| def __getitem__(self, index: Index) -> 'TrackData': |
| """Retrieves a subset of TrackData using positional and/or track indices. |
| |
| This method allows slicing `TrackData` similar to numpy arrays or pandas |
| DataFrames. The index can be a single value or a tuple. |
| |
| Args: |
| index: A single index or a tuple of indices. If a single index, it's |
| treated as a positional index if the `TrackData` has positional axes, |
| otherwise as a track index. If a tuple, the first element specifies the |
| positional slice, and the second element specifies the track index. |
| Positional indices can be `int`, `slice`, or `genome.Interval`, and this |
| slice is applied to all positional axes of the `values` array. Track |
| indices can be `int`, `str` (track name), `slice`, `Sequence[int]`, or |
| `Sequence[str]` (track names). |
| |
| Returns: |
| A new `TrackData` object containing the selected subset. |
| |
| Raises: |
| IndexError: If a slice step is not 1 for positional indexing. |
| IndexError: If an unsupported index type is provided. |
| """ |
| if isinstance(index, tuple): |
| position_index, track_index = index |
| elif self.positional_axes: |
| position_index, track_index = index, None |
| else: |
| position_index, track_index = None, index |
| if isinstance(track_index, genome.Interval): |
| raise IndexError( |
| 'Track indexing by interval is supported only when there are' |
| ' positional axes.' |
| ) |
|
|
| tdata = self |
| match position_index: |
| case None: |
| pass |
| case int(): |
| tdata = tdata.slice_by_positions(position_index, position_index + 1) |
| case slice(): |
| if position_index.step is not None and position_index.step != 1: |
| raise IndexError('Slice step must be 1 for positional indexing.') |
| if position_index != slice(None): |
| tdata = tdata.slice_by_positions( |
| position_index.start, position_index.stop |
| ) |
| case genome.Interval(): |
| tdata = tdata.slice_by_interval(position_index) |
| case _: |
| raise IndexError( |
| f'Unsupported positional index type: {type(position_index)}' |
| ) |
|
|
| match track_index: |
| case None: |
| pass |
| case str(): |
| tdata = tdata.select_tracks_by_name([track_index]) |
| case int(): |
| tdata = tdata.select_tracks_by_index([track_index]) |
| case slice(): |
| if track_index != slice(None): |
| indices = np.arange(tdata.num_tracks)[track_index] |
| tdata = tdata.select_tracks_by_index(indices) |
| case np.ndarray() if np.issubdtype(track_index.dtype, np.character): |
| tdata = tdata.select_tracks_by_name(track_index) |
| case np.ndarray(): |
| tdata = tdata.select_tracks_by_index(track_index) |
| case Sequence(): |
| track_index_arr = np.asarray(track_index) |
| if np.issubdtype(track_index_arr.dtype, np.character): |
| tdata = tdata.select_tracks_by_name(track_index_arr) |
| else: |
| tdata = tdata.select_tracks_by_index(track_index_arr) |
| case _: |
| raise IndexError(f'Unsupported track index type: {type(track_index)}') |
| return tdata |
|
|
| def groupby(self, column: str) -> dict[str, 'TrackData']: |
| """Splits tracks into groups based on a metadata column. |
| |
| This method splits the tracks in the `TrackData` object into separate |
| `TrackData` objects based on the unique values in the specified metadata |
| column. It returns a dictionary where the keys are the unique values in |
| the column, and the values are new `TrackData` objects containing the |
| tracks corresponding to each key. |
| |
| Args: |
| column: The name of the metadata column to split by. |
| |
| Returns: |
| A dictionary mapping unique values in the column to `TrackData` objects |
| containing the corresponding tracks. |
| """ |
| output = {} |
| for key in self.metadata[column].unique(): |
| mask = (self.metadata[column] == key).values |
| output[key] = self.filter_tracks(mask) |
| return output |
|
|
| def _reverse_complement_idx(self) -> np.ndarray: |
| """Gets indices for reverse complementing the tracks. |
| |
| Returns: |
| An array of indices that reorders the tracks to achieve reverse |
| complementation. |
| |
| Raises: |
| ValueError: If not all stranded tracks have both '+' and '-' strands, |
| or if the number of '+' and '-' stranded tracks is not equal. |
| """ |
| df_strands = pd.DataFrame({ |
| 'name': self.names, |
| 'strand': self.strands, |
| 'old_idx': np.arange(self.num_tracks), |
| }) |
| df_strands = df_strands[df_strands.strand != genome.STRAND_UNSTRANDED] |
| df_strands.sort_values(['strand', 'name'], inplace=True) |
| if np.all(df_strands.groupby('name').size() != 2): |
| raise ValueError('Not all stranded tracks have both + and - strand.') |
| if (df_strands.strand == genome.STRAND_POSITIVE).sum() != ( |
| df_strands.strand == genome.STRAND_NEGATIVE |
| ).sum(): |
| raise ValueError( |
| 'We need to have the exact same number of + and - stranded tracks' |
| ) |
| new_idx = df_strands.old_idx.values.reshape((2, -1))[::-1].ravel() |
| |
| idx = np.arange(self.num_tracks) |
| idx[df_strands.old_idx.values] = new_idx |
| return idx |
|
|
| def reverse_complement(self) -> 'TrackData': |
| """Reverse complements the track data and interval if present. |
| |
| Returns: |
| A new `TrackData` object with reverse complemented tracks. |
| """ |
| if self.interval: |
| |
| |
| interval = self.interval.swap_strand() |
| else: |
| interval = None |
|
|
| idx = self._reverse_complement_idx() |
| slices = [slice(None)] * self.values.ndim |
| slices[-1] = idx |
| for axis in self.positional_axes: |
| slices[axis] = slice(None, None, -1) |
|
|
| return TrackData( |
| self.values[tuple(slices)], |
| resolution=self.resolution, |
| metadata=self.metadata.iloc[idx], |
| interval=interval, |
| uns=self.uns, |
| ) |
|
|
| def _check_track_data_compatibility(self, other: 'TrackData') -> None: |
| """Checks if two `TrackData` objects are compatible for sum/diff. |
| |
| Args: |
| other: The other `TrackData` object to compare. |
| |
| Raises: |
| TypeError: If `other` is not a `TrackData` object. |
| ValueError: If the intervals, resolutions, shapes, or metadata |
| shapes don't match between the two objects. |
| """ |
| if not isinstance(other, TrackData): |
| raise TypeError( |
| f'Unsupported type "{type(other)}". Must be a TrackData object' |
| ) |
| if self.interval != other.interval: |
| raise ValueError('Intervals must match for the two TrackData objects.') |
| if self.resolution != other.resolution: |
| raise ValueError('Resolutions must match for the two TrackData objects.') |
| if self.values.shape != other.values.shape: |
| raise ValueError('Shapes must match for the two TrackData objects.') |
| if self.metadata.shape != other.metadata.shape: |
| raise ValueError( |
| 'Metadata shapes must match for the two TrackData objects.' |
| ) |
|
|
| def __add__(self, other: 'TrackData') -> 'TrackData': |
| """Adds the values of two `TrackData` objects. |
| |
| Args: |
| other: The `TrackData` object to add. |
| |
| Returns: |
| A new `TrackData` object with the summed values. |
| |
| Raises: |
| ValueError: If the objects are not compatible (see |
| `_check_track_data_compatibility`). |
| TypeError: If `other` is not a `TrackData` object. |
| """ |
| self._check_track_data_compatibility(other) |
| new_values = self.values + other.values |
| return TrackData( |
| values=new_values, |
| metadata=self.metadata, |
| resolution=self.resolution, |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
| def __sub__(self, other: 'TrackData') -> 'TrackData': |
| """Subtracts the values of two `TrackData` objects. |
| |
| Args: |
| other: The `TrackData` object to subtract. |
| |
| Returns: |
| A new `TrackData` object with the difference of the values. |
| |
| Raises: |
| ValueError: If the objects are not compatible (see |
| `_check_track_data_compatibility`). |
| TypeError: If `other` is not a `TrackData` object. |
| """ |
| self._check_track_data_compatibility(other) |
| new_values = self.values - other.values |
| return TrackData( |
| values=new_values, |
| metadata=self.metadata, |
| resolution=self.resolution, |
| interval=self.interval, |
| uns=self.uns, |
| ) |
|
|
|
|
| def concat( |
| track_datas: Sequence[TrackData], |
| extra_metadata_name_and_keys: ( |
| tuple[str, Sequence[str | int | float]] | None |
| ) = None, |
| ) -> TrackData: |
| """Concatenates multiple `TrackData` objects along the track dimension. |
| |
| This function combines multiple `TrackData` objects into a single object |
| by concatenating their values and metadata. The resulting `TrackData` |
| object will have the same resolution and interval as the input objects. |
| |
| Args: |
| track_datas: A sequence of `TrackData` objects to concatenate. All objects |
| must have the same resolution, interval, and width. |
| extra_metadata_name_and_keys: An optional tuple specifying a new metadata |
| column to add. The first element is the column name, and the second is a |
| sequence of values to populate the column. |
| |
| Returns: |
| A new `TrackData` object containing the concatenated data. |
| |
| Raises: |
| ValueError: If the input `TrackData` objects have different resolutions, |
| intervals, or widths, or if the length of |
| `extra_metadata_name_and_keys[1]` does not match the length of |
| `track_datas`. |
| """ |
| if len(set(x.resolution for x in track_datas)) != 1: |
| raise ValueError('Track data contain multiple resolutions') |
| if len(set(str(x.interval) for x in track_datas)) != 1: |
| raise ValueError('Track data contain multiple intervals') |
| if len(set(x.width for x in track_datas)) != 1: |
| raise ValueError('Track data are of different width') |
| if extra_metadata_name_and_keys: |
| if len(extra_metadata_name_and_keys[1]) != len(track_datas): |
| raise ValueError( |
| 'Second element of new_metadata_name_and_keys must be the same' |
| ' length as track_datas' |
| ) |
|
|
| concatenated_metadata = ( |
| pd.concat( |
| [x.metadata for x in track_datas], |
| keys=extra_metadata_name_and_keys[1] |
| if extra_metadata_name_and_keys |
| else None, |
| names=[extra_metadata_name_and_keys[0]] |
| if extra_metadata_name_and_keys |
| else None, |
| ) |
| .reset_index(level=0, drop=extra_metadata_name_and_keys is None) |
| .reset_index(drop=True) |
| ) |
| return TrackData( |
| np.concatenate( |
| [x.values for x in track_datas], axis=track_datas[0].values.ndim - 1 |
| ), |
| resolution=track_datas[0].resolution, |
| metadata=concatenated_metadata, |
| interval=track_datas[0].interval, |
| uns=None, |
| ) |
|
|
|
|
| def interleave( |
| track_datas: Sequence[TrackData], name_prefixes: Sequence[str] |
| ) -> TrackData: |
| """Interleaves multiple `TrackData` objects by alternating rows. |
| |
| This function combines multiple `TrackData` objects into a single object |
| by interleaving their rows and metadata. This interleaves operation alternates |
| between the trackdatas, like shuffling cards, i.e., 'abcd' interleaved with |
| 'efgh' would be "aebfcgdh". The resulting `TrackData` object will have the |
| same resolution and interval as the input objects, but the number of tracks |
| will be the sum of the tracks in the input objects. |
| |
| Args: |
| track_datas: A sequence of `TrackData` objects to interleave. All objects |
| must have the same shape, resolution, and interval. The order in this list |
| will determine the interleaving order. |
| name_prefixes: A sequence of name prefixes to add to the track names in the |
| metadata to ensure uniqueness of (name, strand) pairs. |
| |
| Returns: |
| A new `TrackData` object containing the interleaved data. |
| |
| Raises: |
| ValueError: If the input `TrackData` objects have different shapes, |
| resolutions, or intervals. |
| """ |
| |
| shapes = set(data.values.shape for data in track_datas) |
| if len(shapes) != 1: |
| raise ValueError( |
| 'Cannot interleave track data which have different shapes. ' |
| f'Detected shapes: {shapes}' |
| ) |
|
|
| if any(data.resolution != track_datas[0].resolution for data in track_datas): |
| raise ValueError( |
| 'Cannot interleave track data which have different resolutions. ' |
| f'Detected shapes: {shapes}' |
| ) |
|
|
| if any(data.interval != track_datas[0].interval for data in track_datas): |
| raise ValueError( |
| 'Cannot interleave track data which have different intervals. ' |
| ) |
|
|
| |
| shape = list(track_datas[0].values.shape) |
| shape[-1] = shape[-1] * len(track_datas) |
| interleaved_data = np.empty(tuple(shape), dtype=track_datas[0].values.dtype) |
|
|
| for i, data in enumerate(track_datas): |
| interleaved_data[..., i :: len(track_datas)] = data.values |
|
|
| |
| metadatas = [] |
| for prefix, data in zip(name_prefixes, track_datas): |
| metadata = data.metadata.copy() |
| metadata['name'] = prefix + metadata['name'] |
| metadatas.append(metadata) |
|
|
| |
| interleaved_metadata = ( |
| pd.concat([ |
| metadata.assign( |
| idx=np.arange( |
| stop=(len(metadata) * len(track_datas)), |
| step=len(track_datas), |
| ) |
| + i |
| ) |
| for i, metadata in enumerate(metadatas) |
| ]) |
| .sort_values('idx') |
| .drop('idx', axis=1) |
| .reset_index(drop=True) |
| ) |
|
|
| return TrackData( |
| values=interleaved_data, |
| metadata=interleaved_metadata, |
| resolution=track_datas[0].resolution, |
| interval=track_datas[0].interval, |
| uns={'num_interleaved_trackdatas': len(track_datas)}, |
| ) |
|
|