File size: 19,349 Bytes
3e62986 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 |
"""Table module for atom/residue/chain tables in Structure.
Tables are intended to be lightweight collections of columns, loosely based
on a pandas dataframe, for use in the Structure class.
"""
import abc
from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, Sequence
import dataclasses
import functools
import graphlib
import typing
from typing import Any, Protocol, Self, TypeAlias, TypeVar, overload
from flax_model.alphafold3.cpp import string_array
import numpy as np
TableEntry: TypeAlias = str | int | float | None
FilterPredicate: TypeAlias = (
TableEntry
| Iterable[Any] # Workaround for b/326384670. Tighten once fixed.
| Callable[[Any], bool] # Workaround for b/326384670. Tighten once fixed.
| Callable[[np.ndarray], bool]
)
class RowLookup(Protocol):
def get_row_by_key(
self,
key: int,
column_name_map: Mapping[str, str] | None = None,
) -> Mapping[str, Any]:
...
@dataclasses.dataclass(frozen=True, kw_only=True)
class Table:
"""Parent class for structure tables.
A table is a collection of columns of equal length, where one column is the
key. The key uniquely identifies each row in the table.
A table can refer to other tables by including a foreign key column, whose
values are key values from the other table's key column. These column can have
arbitrary names and are treated like any other integer-valued column.
See the `Database` class in this module for utilities for handing sets of
tables that are related via foreign keys.
NB: This does not correspond to an mmCIF table.
"""
key: np.ndarray
def __post_init__(self):
for col_name in self.columns:
if (col_len := self.get_column(col_name).shape[-1]) != self.size:
raise ValueError(
f'All columns should have length {self.size} but got "{col_name}"'
f' with length {col_len}.'
)
self.get_column(col_name).flags.writeable = False # Make col immutable.
if self.key.size and self.key.min() < 0:
raise ValueError(
'Key values must be non-negative. Got negative values:'
f' {set(self.key[self.key < 0])}'
)
self.key.flags.writeable = False # Make key immutable.
def __getstate__(self) -> dict[str, Any]:
"""Returns members with cached properties removed for pickling."""
cached_props = {
k
for k, v in self.__class__.__dict__.items()
if isinstance(v, functools.cached_property)
}
return {k: v for k, v in self.__dict__.items() if k not in cached_props}
@functools.cached_property
def index_by_key(self) -> np.ndarray:
"""Mapping from key values to their index in the column arrays.
i.e.: self.key[index_by_key[k]] == k
"""
if not self.key.size:
return np.array([], dtype=np.int64)
else:
index_by_key = np.zeros(np.max(self.key) + 1, dtype=np.int64)
index_by_key[self.key] = np.arange(self.size)
return index_by_key
@functools.cached_property
def columns(self) -> tuple[str, ...]:
"""The names of the columns in the table, including the key column."""
return tuple(field.name for field in dataclasses.fields(self))
@functools.cached_property
def items(self) -> Mapping[str, np.ndarray]:
"""Returns the mapping from column names to column values."""
return {col: getattr(self, col) for col in self.columns}
@functools.cached_property
def size(self) -> int:
"""The number of rows in the table."""
return self.key.shape[-1]
def __len__(self) -> int:
return self.size
def get_column(self, column_name: str) -> np.ndarray:
"""Gets a column by name."""
# Performance optimisation: use the cached columns, instead of getattr.
return self.items[column_name]
def apply_array(self, arr: np.ndarray) -> Self:
"""Returns a sliced table using a key (!= index) array or a boolean mask."""
if arr.dtype == bool and np.all(arr):
return self # Shortcut: No-op, so just return.
return self.copy_and_update(**{
column_name: self.apply_array_to_column(column_name, arr)
for column_name in self.columns
})
def apply_index(self, index_arr: np.ndarray) -> Self:
"""Returns a sliced table using an index (!= key) array."""
if index_arr.dtype == bool:
raise ValueError('The index array must not be a boolean mask.')
return self.copy_and_update(
**{col: self.get_column(col)[..., index_arr] for col in self.columns}
)
def apply_array_to_column(
self,
column_name: str,
arr: np.ndarray,
) -> np.ndarray:
"""Returns a sliced column array using a key array or a boolean mask."""
if arr.dtype == bool:
return self.get_column(column_name)[..., arr]
else:
return self.get_column(column_name)[..., self.index_by_key[arr]]
def get_value_by_index(self, column_name: str, index: int) -> Any:
return self.get_column(column_name)[index]
def get_value_by_key(
self,
column_name: str,
key: int | np.integer,
) -> TableEntry:
"""Gets the value of a column at the row with specified key value."""
return self.get_value_by_index(column_name, self.index_by_key[key])
@overload
def __getitem__(self, key: str) -> np.ndarray:
...
@overload
def __getitem__(self, key: np.ndarray) -> 'Table':
...
@overload
def __getitem__(self, key: tuple[str, int | np.integer]) -> TableEntry:
...
@overload
def __getitem__(self, key: tuple[str, np.ndarray]) -> np.ndarray:
...
def __getitem__(self, key):
match key:
case str():
return self.get_column(key)
case np.ndarray() as key_arr_or_mask:
return self.apply_array(key_arr_or_mask)
case str() as col, int() | np.integer() as key_val:
return self.get_value_by_key(col, key_val)
case str() as col, np.ndarray() as key_arr_or_mask:
return self.apply_array_to_column(col, key_arr_or_mask)
case _:
if isinstance(key, tuple):
err_msg = f'{key}, type: tuple({[type(v) for v in key]})'
else:
err_msg = f'{key}, type: {type(key)}'
raise KeyError(err_msg)
def get_row_by_key(
self,
key: int,
column_name_map: Mapping[str, str] | None = None,
) -> dict[str, Any]:
"""Gets the row with specified key value."""
return self.get_row_by_index(
self.index_by_key[key], column_name_map=column_name_map
)
def get_row_by_index(
self,
index: int,
column_name_map: Mapping[str, str] | None = None,
) -> dict[str, Any]:
"""Gets the row at the specified index."""
if column_name_map is not None:
return {
renamed_col: self.get_value_by_index(col, index)
for renamed_col, col in column_name_map.items()
}
else:
return {col: self.get_value_by_index(col, index) for col in self.columns}
def iterrows(
self,
*,
row_keys: np.ndarray | None = None,
column_name_map: Mapping[str, str] | None = None,
**table_by_foreign_key_col: RowLookup,
) -> Iterator[Mapping[str, Any]]:
"""Yields rows from the table.
This can be used to easily convert a table to a Pandas dataframe:
```py
df = pd.DataFrame(table.iterrows())
```
Args:
row_keys: An optional array of keys of rows to yield. If None, all rows
will be yielded.
column_name_map: An optional mapping from desired keys in the row dicts to
the names of the columns they correspond to.
**table_by_foreign_key_col: An optional mapping from column names in this
table, which are expected to be columns of foreign keys, to the table
that the foreign keys point into. If provided, then the yielded rows
will include data from the foreign tables at the appropriate key.
"""
if row_keys is not None:
row_indices = self.index_by_key[row_keys]
else:
row_indices = range(self.size)
for i in row_indices:
row = self.get_row_by_index(i, column_name_map=column_name_map)
for key_col, table in table_by_foreign_key_col.items():
foreign_key = self[key_col][i]
foreign_row = table.get_row_by_key(foreign_key)
row.update(foreign_row)
yield row
def with_column_names(
self, column_name_map: Mapping[str, str]
) -> 'RenamedTableView':
"""Returns a view of this table with mapped column names."""
return RenamedTableView(self, column_name_map=column_name_map)
def make_filter_mask(
self,
mask: np.ndarray | None = None,
*,
apply_per_element: bool = False,
**predicate_by_col: FilterPredicate,
) -> np.ndarray | None:
"""Returns a boolean array of rows to keep, or None if all can be kept.
Args:
mask: See `Table.filter`.
apply_per_element: See `Table.filter`.
**predicate_by_col: See `Table.filter`.
Returns:
Either a boolean NumPy array of length `(self.size,)` denoting which rows
should be kept according to the input mask and predicates, or None. None
implies there is no filtering required, and is used where possible
instead of an all-True array to save time and space.
"""
if mask is None:
if not predicate_by_col:
return None
else:
mask = np.ones((self.size,), dtype=bool)
else:
if mask.shape != (self.size,):
raise ValueError(
f'mask must have shape ({self.size},). Got: {mask.shape}.'
)
if mask.dtype != bool:
raise ValueError(f'mask must have dtype bool. Got: {mask.dtype}.')
for col, predicate in predicate_by_col.items():
if self[col].ndim > 1:
raise ValueError(
f'Cannot filter by column {col} with more than 1 dimension.'
)
callable_predicates = []
if not callable(predicate):
if isinstance(predicate, Iterable) and not isinstance(predicate, str):
target_vals = predicate
else:
target_vals = [predicate]
for target_val in target_vals:
callable_predicates.append(lambda x, target=target_val: x == target)
else:
callable_predicates.append(predicate)
field_mask = np.zeros_like(mask)
for callable_predicate in callable_predicates:
if not apply_per_element:
callable_predicate = typing.cast(
Callable[[np.ndarray], bool], callable_predicate
)
predicate_result = callable_predicate(self.get_column(col))
else:
predicate_result = np.array(
[callable_predicate(elem) for elem in self.get_column(col)]
)
np.logical_or(field_mask, predicate_result, out=field_mask)
np.logical_and(mask, field_mask, out=mask) # Update in-place.
return mask
def filter(
self,
mask: np.ndarray | None = None,
*,
apply_per_element: bool = False,
invert: bool = False,
**predicate_by_col: FilterPredicate,
) -> Self:
"""Filters the table using mask and/or predicates and returns a new table.
Predicates can be either:
1. A constant value, e.g. `'CA'`. In this case then only rows that match
this value for the given column are retained.
2. A (non-string) iterable e.g. `('A', 'B')`. In this
case then rows are retained if they match any of the provided values for
the given column.
3. A boolean function e.g. `lambda b_fac: b_fac < 100.0`.
In this case then only rows that evaluate to `True` are retained. By
default this function's parameter is expected to be an array, unless
`apply_per_element=True`.
Args:
mask: An optional boolean NumPy array with length equal to the table size.
If provided then this will be combined with the other predicates so that
a row is included if it is masked-in *and* matches all the predicates.
apply_per_element: Whether apply predicates to each element in the column
individually, or to pass the whole column array to the predicate.
invert: If True then the returned table will contain exactly those rows
that would be removed if this was `False`.
**predicate_by_col: A mapping from column name to a predicate. Filtered
columns must be 1D arrays. If multiple columns are provided as keyword
arguments then each predicate is applied and the results are combined
using a boolean AND operation, so an atom is only retained if it passes
all predicates.
Returns:
A new table with the desired rows retained (or filtered out if
`invert=True`).
Raises:
ValueError: If mask is provided and is not a bool array with shape
`(num_atoms,)`.
"""
filter_mask = self.make_filter_mask(
mask, apply_per_element=apply_per_element, **predicate_by_col
)
if filter_mask is None:
# No mask or predicate was specified, so we can return early.
if not invert:
return self
else:
return self[np.array((), dtype=np.int64)]
else:
return self[~filter_mask if invert else filter_mask]
def _validate_keys_are_column_names(self, keys: Collection[str]) -> None:
"""Raises an error if any of the keys are not column names."""
if mismatches := set(keys) - set(self.columns):
raise ValueError(f'Invalid column names: {sorted(mismatches)}.')
def copy_and_update(self, **new_column_by_column_name: np.ndarray) -> Self:
"""Returns a copy of this table with the specified changes applied.
Args:
**new_column_by_column_name: New values for the specified columns.
Raises:
ValueError: If a specified column name is not a column in this table.
"""
self._validate_keys_are_column_names(new_column_by_column_name)
return dataclasses.replace(self, **new_column_by_column_name)
def copy_and_remap(
self, **mapping_by_col: Mapping[TableEntry, TableEntry]
) -> Self:
"""Returns a copy of the table with the specified columns remapped.
Args:
**mapping_by_col: Each kwarg key should be the name of one of this table's
columns, and each value should be a mapping. The values in the column
will be looked up in the mapping and replaced with the result if one is
found.
Raises:
ValueError: If a specified column name is not a column in this table.
"""
self._validate_keys_are_column_names(mapping_by_col)
if not self.size:
return self
remapped_cols = {}
for column_name, mapping in mapping_by_col.items():
col_arr = self.get_column(column_name)
if col_arr.dtype == object:
remapped = string_array.remap(col_arr, mapping)
else:
remapped = np.vectorize(lambda x: mapping.get(x, x))(col_arr) # pylint: disable=cell-var-from-loop
remapped_cols[column_name] = remapped
return self.copy_and_update(**remapped_cols)
class RenamedTableView:
"""View of a table with renamed column names."""
def __init__(self, table: Table, column_name_map: Mapping[str, str]):
self._table = table
self._column_name_map = column_name_map
def get_row_by_key(
self,
key: int,
column_name_map: Mapping[str, str] | None = None,
) -> Mapping[str, Any]:
del column_name_map
return self._table.get_row_by_key(
key, column_name_map=self._column_name_map
)
_DatabaseT = TypeVar('_DatabaseT', bound='Database')
class Database(abc.ABC):
"""Relational database base class."""
@property
@abc.abstractmethod
def tables(self) -> Collection[str]:
"""The names of the tables in this database."""
@abc.abstractmethod
def get_table(self, table_name: str) -> Table:
"""Gets the table with the given name."""
@property
@abc.abstractmethod
def foreign_keys(self) -> Mapping[str, Collection[tuple[str, str]]]:
"""Describes the relationship between keys in the database.
Returns:
A map from table names to pairs of `(column_name, foreign_table_name)`
where `column_name` is a column containing foreign keys in the table named
by the key, and the `foreign_table_name` is the name of the table that
those foreign keys refer to.
"""
@abc.abstractmethod
def copy_and_update(
self: _DatabaseT,
**new_field_by_field_name: ...,
) -> _DatabaseT:
"""Returns a copy of this database with the specified changes applied."""
def table_dependency_order(db: Database) -> Iterable[str]:
"""Yields the names of the tables in the database in dependency order.
This order guarantees that a table appears after all other tables that
it refers to using foreign keys. Specifically A < B implies that A contains
no column that refers to B.key as a foreign key.
Args:
db: The database that defines the table names and foreign keys.
"""
connections: dict[str, set[str]] = {}
for table_name in db.tables:
connection_set = set()
for _, foreign_table in db.foreign_keys.get(table_name, ()):
connection_set.add(foreign_table)
connections[table_name] = connection_set
yield from graphlib.TopologicalSorter(connections).static_order()
def concat_databases(dbs: Sequence[_DatabaseT]) -> _DatabaseT:
"""Concatenates the tables across a sequence of databases.
Args:
dbs: A non-empty sequence of database instances of the same type.
Returns:
A new database containing the concatenated tables from the input databases.
Raises:
ValueError: If `dbs` is empty or `dbs` contains different Database
types.
"""
if not dbs:
raise ValueError('Need at least one value to concatenate.')
distinct_db_types = {type(db) for db in dbs}
if len(distinct_db_types) > 1:
raise ValueError(
f'All `dbs` must be of the same type, got: {distinct_db_types}'
)
first_db, *other_dbs = dbs
concatted_tables: dict[str, Table] = {}
key_offsets: dict[str, list[int]] = {}
for table_name in table_dependency_order(first_db):
first_table = first_db.get_table(table_name)
columns: dict[str, list[np.ndarray]] = {
column_name: [first_table.get_column(column_name)]
for column_name in first_table.columns
}
key_offsets[table_name] = [
first_table.key.max() + 1 if first_table.size else 0
]
for prev_index, db in enumerate(other_dbs):
table = db.get_table(table_name)
for col_name in table.columns:
columns[col_name].append(table.get_column(col_name))
key_offset = key_offsets[table_name][prev_index]
offset_key = table.key + key_offset
columns['key'][-1] = offset_key
if table.size:
key_offsets[table_name].append(offset_key.max() + 1)
else:
key_offsets[table_name].append(key_offsets[table_name][prev_index])
for fkey_col_name, foreign_table_name in first_db.foreign_keys.get(
table_name, []
):
fkey_columns = columns[fkey_col_name]
fkey_columns[-1] = (
fkey_columns[-1] + key_offsets[foreign_table_name][prev_index]
)
concatted_columns = {
column_name: np.concatenate(values, axis=-1)
for column_name, values in columns.items()
}
concatted_tables[table_name] = (type(first_table))(**concatted_columns)
return first_db.copy_and_update(**concatted_tables)
|