OneScience's picture
Upload folder using huggingface_hub
4516781 verified
Raw
History Blame Contribute Delete
31.7 kB
# Copyright 2024 Google LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""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 # pylint: disable=g-multiple-import, g-importing-member
import numpy as np
import pandas as pd
# Required columns: name, strand.
# Optional standardized columns: cell_type, assay, padding.
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.
"""
# Use Union due to https://github.com/patrick-kidger/jaxtyping/issues/73.
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 = (
# Unstructured data dict, analagous to anndata.AnnData.uns.
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, # Padding invalidates the interval.
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:
# Bring axis of interest to the front, reshape and aggregate, and reswap
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()
# Swap strands by idx.
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:
# Note that the interval needs to be stranded in order to perform
# this operation.
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.
"""
# Checks on the track data.
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. '
)
# Interleave arrays.
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
# Interleave metadata.
metadatas = []
for prefix, data in zip(name_prefixes, track_datas):
metadata = data.metadata.copy()
metadata['name'] = prefix + metadata['name']
metadatas.append(metadata)
# Assign a new index idx that, when sorted, will produce an interleave.
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)},
)