"""Exact-current vector-field validation and transition-drift utilities."""
from __future__ import annotations
import math
import re
from collections.abc import Collection, Mapping
from dataclasses import dataclass
from numbers import Integral, Real
from typing import TYPE_CHECKING, Any, cast
import numpy as np
from scipy import sparse
if TYPE_CHECKING:
import anndata
_VECTOR_KEY_PATTERN = re.compile(r"^(?P<field>.+_umap)_(?P<dimension>[123])d$")
def is_vector_field_declaration_key(value: object) -> bool:
"""Return whether an AnnData ``obsm`` key exactly declares a vector field."""
return isinstance(value, str) and _VECTOR_KEY_PATTERN.fullmatch(value) is not None
@dataclass(frozen=True)
class ValidatedVectorFields:
"""Validated vector arrays and their exact source keys."""
fields: dict[str, dict[int, np.ndarray]]
source_keys: dict[str, dict[int, str]]
default_field: str | None
def _require_nonempty_string(value: object, *, label: str) -> str:
if not isinstance(value, str):
raise TypeError(f"{label} must be a native non-empty string.")
if not value:
raise ValueError(f"{label} must be a native non-empty string.")
return value
def _dense_array(values: object) -> np.ndarray:
if sparse.issparse(values):
return np.asarray(cast(sparse.spmatrix, values).toarray())
return np.asarray(values)
def _finite_float32_matrix(
values: object,
*,
label: str,
n_rows: int | None,
declared_dimension: int | None,
) -> tuple[np.ndarray, int]:
if values is None:
raise ValueError(f"{label} must not be None.")
array = _dense_array(values)
if array.dtype.kind not in {"i", "u", "f"}:
raise TypeError(f"{label} must contain real numeric values.")
if array.ndim != 2:
raise ValueError(f"{label} must be a 2D array, got shape {array.shape}.")
if array.shape[0] <= 0:
raise ValueError(f"{label} must contain at least one row.")
if n_rows is not None and array.shape[0] != n_rows:
raise ValueError(f"{label} must have exactly {n_rows} rows, got {array.shape[0]}.")
if declared_dimension is None:
dimension = int(array.shape[1])
if dimension not in (1, 2, 3):
raise ValueError(f"{label} must have exactly 1, 2, or 3 columns, got {array.shape[1]}.")
else:
dimension = declared_dimension
if array.shape[1] != dimension:
raise ValueError(
f"{label} must have exactly {dimension} columns, got {array.shape[1]}."
)
if not np.isfinite(array).all():
raise ValueError(f"{label} must contain only finite values.")
with np.errstate(over="ignore", invalid="ignore"):
float32_array = array.astype(np.float32, copy=False)
if not np.isfinite(float32_array).all():
raise ValueError(f"{label} contains values outside the finite float32 range.")
return float32_array, dimension
def validate_vector_fields(
values: Mapping[Any, Any] | None,
*,
n_cells: int,
available_dimensions: Collection[int],
vector_field_default: object = None,
) -> ValidatedVectorFields:
"""Validate the complete mapping used by highest-dimension field metadata."""
if values is not None and not isinstance(values, Mapping):
raise TypeError("vector_fields must be a mapping of vector field keys to arrays.")
if vector_field_default is not None:
declared_default = _require_nonempty_string(
vector_field_default,
label="vector_field_default",
)
else:
declared_default = None
if not values:
if declared_default is not None:
raise ValueError("vector_field_default was provided but no vector fields exist.")
return ValidatedVectorFields({}, {}, None)
allowed_dimensions = set(available_dimensions)
fields: dict[str, dict[int, np.ndarray]] = {}
source_keys: dict[str, dict[int, str]] = {}
for raw_key, raw_values in values.items():
key = _require_nonempty_string(
raw_key,
label="vector field key",
)
match = _VECTOR_KEY_PATTERN.fullmatch(key)
if match is None:
raise ValueError(
f"vector field key {key!r} must exactly match '<field>_umap_<1|2|3>d'."
)
field_id = match.group("field")
dimension_text = match.group("dimension")
declared_dimension = int(dimension_text)
array, dimension = _finite_float32_matrix(
raw_values,
label=f"Vector field {key!r}",
n_rows=n_cells,
declared_dimension=declared_dimension,
)
if dimension not in allowed_dimensions:
raise ValueError(f"Vector field {key!r} requires a matching {dimension}D embedding.")
if dimension in fields.setdefault(field_id, {}):
other_key = source_keys[field_id][dimension]
raise ValueError(
f"Vector field keys {other_key!r} and {key!r} both declare "
f"the same {dimension}D field {field_id!r}."
)
fields[field_id][dimension] = array
source_keys.setdefault(field_id, {})[dimension] = key
ordered_fields = {
field_id: {dimension: fields[field_id][dimension] for dimension in sorted(fields[field_id])}
for field_id in sorted(fields)
}
ordered_source_keys = {
field_id: {
dimension: source_keys[field_id][dimension]
for dimension in sorted(source_keys[field_id])
}
for field_id in sorted(source_keys)
}
if declared_default is None:
if len(ordered_fields) > 1:
raise ValueError(
"vector_field_default is required when more than one vector field is declared."
)
default_field = next(iter(ordered_fields))
else:
if declared_default not in ordered_fields:
raise ValueError(
f"vector_field_default {declared_default!r} does not match an "
f"available field: {list(ordered_fields)}."
)
default_field = declared_default
return ValidatedVectorFields(
fields=ordered_fields,
source_keys=ordered_source_keys,
default_field=default_field,
)
def scale_vector_field(
vectors: np.ndarray,
*,
scale_factor: object,
label: str,
) -> np.ndarray:
"""Scale vectors using one required finite positive scale."""
if isinstance(scale_factor, bool) or not isinstance(scale_factor, Real):
raise ValueError(f"{label} requires a finite positive scale factor.")
numeric_scale = float(scale_factor)
if not math.isfinite(numeric_scale) or numeric_scale <= 0:
raise ValueError(f"{label} requires a finite positive scale factor.")
scaled = vectors.astype(np.float64) * numeric_scale
if not np.isfinite(scaled).all():
raise ValueError(f"{label} scaling produced non-finite values.")
with np.errstate(over="ignore", invalid="ignore"):
float32_scaled = scaled.astype(np.float32)
if not np.isfinite(float32_scaled).all():
raise ValueError(f"{label} scaling produced values outside the finite float32 range.")
return float32_scaled
def _transition_matrix(
values: np.ndarray | sparse.spmatrix,
*,
n_cells: int,
) -> np.ndarray | sparse.csr_matrix:
if sparse.issparse(values):
matrix = cast(sparse.spmatrix, values).tocsr()
if matrix.dtype.kind not in {"i", "u", "f"}:
raise TypeError("transition_matrix must contain real numeric values.")
if matrix.shape != (n_cells, n_cells):
raise ValueError(
f"transition_matrix must have shape ({n_cells}, {n_cells}), got {matrix.shape}."
)
if not np.isfinite(matrix.data).all():
raise ValueError("transition_matrix must contain only finite values.")
if np.any(matrix.data < 0):
raise ValueError("transition_matrix values must be non-negative.")
return matrix.astype(np.float64)
matrix = np.asarray(values)
if matrix.dtype.kind not in {"i", "u", "f"}:
raise TypeError("transition_matrix must contain real numeric values.")
if matrix.ndim != 2 or matrix.shape != (n_cells, n_cells):
raise ValueError(
f"transition_matrix must have shape ({n_cells}, {n_cells}), got {matrix.shape}."
)
if not np.isfinite(matrix).all():
raise ValueError("transition_matrix must contain only finite values.")
if np.any(matrix < 0):
raise ValueError("transition_matrix values must be non-negative.")
return matrix.astype(np.float64, copy=False)
[docs]
def compute_transition_drift(
transition_matrix: np.ndarray | sparse.spmatrix,
embedding: np.ndarray,
*,
normalize_rows: bool,
) -> np.ndarray:
"""Compute exact per-cell transition drift in embedding space."""
if type(normalize_rows) is not bool:
raise TypeError("normalize_rows must be exactly True or False.")
embedding_array, _dimension = _finite_float32_matrix(
embedding,
label="embedding",
n_rows=None,
declared_dimension=None,
)
n_cells = int(embedding_array.shape[0])
matrix = _transition_matrix(transition_matrix, n_cells=n_cells)
working_embedding = embedding_array.astype(np.float64)
product = np.asarray(matrix @ working_embedding, dtype=np.float64)
if normalize_rows:
row_sums = np.asarray(matrix.sum(axis=1), dtype=np.float64).reshape(-1)
if not np.isfinite(row_sums).all() or np.any(row_sums == 0):
raise ValueError(
"Every transition_matrix row must have a nonzero finite row sum "
"when normalize_rows=True."
)
product = product / row_sums[:, None]
drift = product - working_embedding
drift_array, _dimension = _finite_float32_matrix(
drift,
label="computed transition drift",
n_rows=n_cells,
declared_dimension=int(embedding_array.shape[1]),
)
return drift_array
def _umap_embeddings(
adata: anndata.AnnData,
) -> dict[int, tuple[str, np.ndarray]]:
embeddings: dict[int, tuple[str, np.ndarray]] = {}
n_cells = int(adata.n_obs)
for dimension in (1, 2, 3):
key = f"X_umap_{dimension}d"
if key not in adata.obsm:
continue
array, _resolved_dimension = _finite_float32_matrix(
adata.obsm[key],
label=f"Embedding {key!r}",
n_rows=n_cells,
declared_dimension=dimension,
)
embeddings[dimension] = (key, array)
if not embeddings:
raise ValueError(
"AnnData must contain one or more exact UMAP embedding keys: "
"'X_umap_1d', 'X_umap_2d', or 'X_umap_3d'."
)
return embeddings
[docs]
def add_transition_drift_to_obsm(
adata: anndata.AnnData,
transition_matrix: np.ndarray | sparse.spmatrix,
*,
basis: str = "umap",
field_prefix: str = "T_fwd",
dim: int | None = None,
normalize_rows: bool,
overwrite: bool = False,
) -> str:
"""Compute transition drift and store one sanctioned UMAP vector key."""
basis = _require_nonempty_string(basis, label="basis")
if basis != "umap":
raise ValueError("basis must be exactly 'umap'.")
field_prefix = _require_nonempty_string(
field_prefix,
label="field_prefix",
)
if type(overwrite) is not bool:
raise TypeError("overwrite must be exactly True or False.")
embeddings = _umap_embeddings(adata)
if dim is None:
if len(embeddings) != 1:
raise ValueError("dim must be explicit when more than one UMAP embedding exists.")
dimension = next(iter(embeddings))
else:
if isinstance(dim, bool) or not isinstance(dim, Integral):
raise TypeError("dim must be an integer dimension.")
dimension = int(dim)
if dimension not in (1, 2, 3):
raise ValueError("dim must be exactly 1, 2, or 3.")
if dimension not in embeddings:
raise ValueError(f"No matching {dimension}D UMAP embedding exists.")
embedding = embeddings[dimension][1]
drift = compute_transition_drift(
transition_matrix,
embedding,
normalize_rows=normalize_rows,
)
field_id = f"{field_prefix}_umap"
output_key = f"{field_id}_{dimension}d"
if output_key in adata.obsm and not overwrite:
raise KeyError(
f"adata.obsm already contains key {output_key!r}; set overwrite=True to replace it."
)
validate_vector_fields(
{output_key: drift},
n_cells=int(adata.n_obs),
available_dimensions=embeddings,
)
adata.obsm[output_key] = drift
return output_key