Source code for vampire.anno.tl._align

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import numpy as np
    import anndata as ad

import logging

logger = logging.getLogger(__name__)

# =============================================================================
# Function call-graph overview
# =============================================================================
#
# [PUBLIC] sample_msa  — sample-level progressive MSA
#   Input:  adata.uns["motif_array"], adata.uns["orientation_array"],
#           adata.varp["motif_distance"], adata.var["motif_length"]
#   Output: adata.uns["sample_msa_motif_array"],
#           adata.uns["sample_msa_orientation_array"],
#           adata.uns["sample_msa_consensus"],
#           adata.uns["sample_msa_consensus_orientation"]
#
#   ├─ Deduplication: collapse identical (motif, orientation) sequences
#   ├─ _build_sub_matrix(score, penalty) → (sub_matrix, rc_sub_matrix)
#   │   ├─ sub_matrix  : forward-forward substitution scores
#   │   └─ rc_sub_matrix: forward-rc substitution scores (from rc_motif_distance)
#   │   └─ score = (avg_len - dist) * match_score + dist * mismatch_penalty
#   ├─ Orientation encoding (before _msa_core):
#   │   Motif IDs are offset by +n_motifs when orientation == "-".
#   │   This lets the generic NW engine pick the correct matrix cell:
#   │     forward  vs forward  → sub_matrix[fw, fw]
#   │     forward  vs rc       → rc_sub_matrix[fw, fw]
#   │     rc       vs rc       → sub_matrix[fw, fw]  (same relative strand)
#   │   The substitution matrix is extended to 2n × 2n blocks:
#   │         0..n-1   n..2n-1
#   │       ┌────────┬────────┐
#   │   0..n│  sub   │ rc_sub │
#   │       ├────────┼────────┤
#   │ n..2n │rc_subᵀ │  sub   │
#   │       └────────┴────────┘
#   ├─ _msa_core(adjusted_sequences, extended_sub_matrix, ...) → (aligned, consensus)
#   │   ├─ _nw_score(seq_a, seq_b, ...) → float
#   │   ├─ UPGMA tree from pairwise distance matrix
#   │   ├─ Bottom-up merge:
#   │   │   ├─ _profile_consensus(profile) → consensus_seq
#   │   │   ├─ _nw(cons_a, cons_b, ...) → (aligned_a, aligned_b)
#   │   │   └─ _merge_profiles(prof_a, prof_b, aln_a, aln_b) → merged
#   │   └─ Iterative refinement (if refine=True):
#   │       └─ Re-align each raw sequence vs consensus via _nw(...)
#   └─ Restore: map alignment back to all original samples
#       ├─ Motif IDs  : mod n_motifs to strip the orientation offset
#       └─ orientation_array: rebuilt with gaps inserted, original strand kept
#
# [PUBLIC] motif_msa  — motif-level MSA or pairwise reference alignment
#   Input:  adata.var["motif"]  (DNA sequences)
#   Output: adata.uns["motif_msa"]  (alignment + consensus / variants)
#
#   ├─ If reference is None  →  MSA mode (reuse _msa_core)
#   │   ├─ Map ACGT → string indices (tokens)
#   │   ├─ Build 4×4 DNA substitution matrix
#   │   ├─ _msa_core(tokens, sub_matrix, ...) → (aligned_tokens, consensus)
#   │   │   └─ Same engine as sample_msa (see above)
#   │   └─ Map tokens → ACGT strings
#   │
#   └─ If reference given  →  Pairwise mode
#       ├─ parasail.nw_trace_striped_16(seq, ref_seq, ...) → traceback
#       └─ _pairwise_alignment_to_variants(ref_aln, seq_aln, ref, id) → variant records
#           ├─ Match        → skip
#           ├─ Substitution → type="sub", pos, ref, alt
#           ├─ Insertion    → type="ins", pos, seq
#           └─ Deletion     → type="del", pos, ref, length
#
# =============================================================================


[docs] def sample_msa( adata: ad.AnnData, *, match_score: int = 2, mismatch_penalty: int = -3, gap_open_penalty: int = -5, gap_extend_penalty: int = -1, refine: bool = True, max_refine_iter: int = 3, store_key: str = "aligned", ) -> ad.AnnData: """ Multiple sequence alignment of motif arrays across samples. Uses a guide-tree progressive alignment strategy: build a UPGMA tree from pairwise distances, then merge profiles bottom-up. Optionally refines the MSA via iterative consensus realignment. Duplicate sequences are automatically deduplicated before alignment and mapped back afterwards. Parameters ---------- adata : ad.AnnData Annotated data with ``motif_array`` and ``orientation_array`` in ``uns``, and ``motif_distance`` in ``varp``. match_score : int, default=2 Reward coefficient for matching motifs. The substitution score is ``(avg_len - distance) * match_score + distance * mismatch_penalty``, where ``avg_len`` is the average motif length of the two motifs. mismatch_penalty : int, default=-3 Penalty coefficient for mismatched motifs. gap_open_penalty : int, default=-5 Penalty for opening a gap. gap_extend_penalty : int, default=-1 Penalty for extending a gap. refine : bool, default=True Whether to perform iterative consensus-based refinement after the initial progressive alignment. max_refine_iter : int, default=3 Maximum number of refinement iterations. Ignored when ``refine=False``. store_key : str, default="aligned" Key prefix for storing results in ``adata.uns``. Stores ``{store_key}_motif_array``, ``{store_key}_orientation_array``, ``{store_key}_consensus``, and ``{store_key}_consensus_orientation``. Returns ------- ad.AnnData The updated AnnData with alignment results in ``uns``. Examples -------- >>> import vampire as vp >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.sample_msa(adata) """ from copy import deepcopy import numpy as np from ..pp._markdup import markdup if match_score <= 0: raise ValueError("match_score should be positive.") if mismatch_penalty >= 0: raise ValueError("mismatch_penalty should be negative.") if gap_open_penalty >= 0: raise ValueError("gap_open_penalty should be negative.") if gap_extend_penalty >= 0: raise ValueError("gap_extend_penalty should be negative.") if refine and max_refine_iter <= 0: raise ValueError("max_refine_iter must be positive when refine=True.") if "motif_array" not in adata.uns: raise KeyError("adata.uns['motif_array'] not found.") if "orientation_array" not in adata.uns: raise KeyError("adata.uns['orientation_array'] not found.") if "motif_distance" not in adata.varp: raise KeyError("adata.varp['motif_distance'] not found.") if "motif_length" not in adata.var: raise KeyError("adata.var['motif_length'] not found.") sequences: dict[str, list[str]] = adata.uns["motif_array"] orientations: dict[str, list[str]] = adata.uns["orientation_array"] all_names: list[str] = list(sequences.keys()) n_total: int = len(all_names) if n_total == 0: return adata if n_total == 1: adata.uns[f"{store_key}_motif_array"] = deepcopy(sequences) adata.uns[f"{store_key}_orientation_array"] = deepcopy(orientations) adata.obs["unique_group"] = 0 return adata # Deduplication: identical (motif, orientation) sequences are collapsed if "unique_group" not in adata.obs.columns: logger.warning( "unique_group not found in adata.obs. " "vp.anno.pp.markdup() has not been run. Running it automatically." ) adata = markdup(adata) name_to_group: dict[str, int] = adata.obs["unique_group"].to_dict() group_to_names: dict[int, list[str]] = {} unique_names: list[str] = [] seen_groups: set[int] = set() for name, group in name_to_group.items(): group_to_names.setdefault(group, []).append(name) if group not in seen_groups: seen_groups.add(group) unique_names.append(name) n_unique: int = len(unique_names) # Build rep_to_names for map-back after MSA (rep key = first sample name) rep_to_names = { name: group_to_names[name_to_group[name]] for name in unique_names } logger.info( "Aligning %d samples: %d unique sequences (%d duplicates removed).", n_total, n_unique, n_total - n_unique, ) # Build substitution matrices (forward-forward and forward-rc) sub_matrix, rc_sub_matrix = _build_sub_matrix( adata, match_score, mismatch_penalty ) # Extend substitution matrix to encode orientation in motif IDs: # 0..n-1 → forward motifs # n..2n-1 → reverse-complement motifs n_motifs = len(adata.var) extended_sub_matrix = np.zeros((2 * n_motifs, 2 * n_motifs), dtype=int) extended_sub_matrix[:n_motifs, :n_motifs] = sub_matrix extended_sub_matrix[:n_motifs, n_motifs:] = rc_sub_matrix extended_sub_matrix[n_motifs:, :n_motifs] = rc_sub_matrix.T extended_sub_matrix[n_motifs:, n_motifs:] = sub_matrix # Adjust sequences so that reverse-oriented motifs get offset IDs unique_sequences: dict[str, list[str]] = {} for name in unique_names: seq = sequences[name] ori = orientations[name] adjusted = [ str(int(m) + n_motifs) if o == "-" else m for m, o in zip(seq, ori) ] unique_sequences[name] = adjusted # Run generic MSA engine on orientation-aware sequences result_motifs, result_consensus = _msa_core( unique_sequences, extended_sub_matrix, gap_open_penalty, gap_extend_penalty, refine=refine, max_refine_iter=max_refine_iter, ) # Map alignment results back to all original samples (including duplicates) final_motifs: dict[str, list[str]] = {} final_oris: dict[str, list[str]] = {} for rep_name in unique_names: rep_motif_adj = result_motifs[rep_name] # Map extended IDs back to original IDs rep_motif: list[str] = [ "-" if m == "-" else str(int(m) % n_motifs) for m in rep_motif_adj ] # Map orientation (preserving original strand info) rep_ori: list[str] = [] ori_idx = 0 ori_src = orientations[rep_name] for m in rep_motif_adj: if m == "-": rep_ori.append("-") else: rep_ori.append(ori_src[ori_idx]) ori_idx += 1 for orig_name in rep_to_names[rep_name]: final_motifs[orig_name] = rep_motif final_oris[orig_name] = rep_ori # Build consensus with orientation consensus_motifs: list[str] = [] consensus_oris: list[str] = [] for m in result_consensus: if m == "-": consensus_motifs.append("-") consensus_oris.append("-") else: mid = int(m) consensus_motifs.append(str(mid % n_motifs)) consensus_oris.append("-" if mid >= n_motifs else "+") adata.uns[f"{store_key}_motif_array"] = final_motifs adata.uns[f"{store_key}_orientation_array"] = final_oris adata.uns[f"{store_key}_consensus"] = consensus_motifs adata.uns[f"{store_key}_consensus_orientation"] = consensus_oris logger.info( "Aligned %d samples (%d unique). " "Original lengths: %s. " "Aligned length: %d.", n_total, n_unique, [len(sequences[n]) for n in all_names], len(final_motifs[all_names[0]]), ) return adata
def _build_sub_matrix( adata: ad.AnnData, match_score: int, mismatch_penalty: int, ) -> tuple[np.ndarray, np.ndarray]: """Build substitution matrices from motif_distance, rc_motif_distance and motif_length. Returns both the forward-forward matrix and the forward-rc (rc-forward) matrix so that ``sample_msa`` can respect per-motif orientation. Score formula:: score = (avg_len - distance) * match_score + distance * mismatch_penalty where ``avg_len`` is the average motif length of the two motifs. """ import numpy as np n_motifs = len(adata.var) dist_mat = adata.varp["motif_distance"] rc_dist_mat = adata.varp.get("rc_motif_distance", dist_mat) motif_lengths = adata.var["motif_length"].to_numpy() sub_matrix = np.zeros((n_motifs, n_motifs), dtype=int) rc_sub_matrix = np.zeros((n_motifs, n_motifs), dtype=int) for i in range(n_motifs): for j in range(n_motifs): dist = dist_mat[i, j] rc_dist = rc_dist_mat[i, j] avg_len = (motif_lengths[i] + motif_lengths[j]) / 2.0 sub_matrix[i, j] = int( round((avg_len - dist) * match_score + dist * mismatch_penalty) ) rc_sub_matrix[i, j] = int( round((avg_len - rc_dist) * match_score + rc_dist * mismatch_penalty) ) return sub_matrix, rc_sub_matrix def _nw_score( seq_a: list[str], seq_b: list[str], sub_matrix: np.ndarray, gap_open_penalty: int, gap_extend_penalty: int, ) -> float: """Needleman-Wunsch optimal alignment score (affine gap).""" import numpy as np n = len(seq_a) m = len(seq_b) NEG_INF = -10**9 go = int(gap_open_penalty) ge = int(gap_extend_penalty) M = np.full((n + 1, m + 1), NEG_INF, dtype=int) M[0, 0] = 0 I = np.full((n + 1, m + 1), NEG_INF, dtype=int) D = np.full((n + 1, m + 1), NEG_INF, dtype=int) for i in range(1, n + 1): I[i, 0] = go + (i - 1) * ge for j in range(1, m + 1): D[0, j] = go + (j - 1) * ge for i in range(1, n + 1): for j in range(1, m + 1): score = sub_matrix[int(seq_a[i - 1]), int(seq_b[j - 1])] M[i, j] = max(M[i - 1, j - 1], I[i - 1, j - 1], D[i - 1, j - 1]) + score I[i, j] = max(M[i - 1, j] + go, I[i - 1, j] + ge) D[i, j] = max(M[i, j - 1] + go, D[i, j - 1] + ge) return float(max(M[n, m], I[n, m], D[n, m])) def _nw( seq_a: list[str], seq_b: list[str], sub_matrix: np.ndarray, gap_open_penalty: int, gap_extend_penalty: int, ) -> tuple[list[str], list[str]]: """Needleman-Wunsch global alignment with affine gap penalty.""" import numpy as np n = len(seq_a) m = len(seq_b) NEG_INF = -10**9 go = int(gap_open_penalty) ge = int(gap_extend_penalty) M = np.full((n + 1, m + 1), NEG_INF, dtype=int) M[0, 0] = 0 I = np.full((n + 1, m + 1), NEG_INF, dtype=int) D = np.full((n + 1, m + 1), NEG_INF, dtype=int) for i in range(1, n + 1): I[i, 0] = go + (i - 1) * ge for j in range(1, m + 1): D[0, j] = go + (j - 1) * ge for i in range(1, n + 1): for j in range(1, m + 1): score = sub_matrix[int(seq_a[i - 1]), int(seq_b[j - 1])] M[i, j] = max(M[i - 1, j - 1], I[i - 1, j - 1], D[i - 1, j - 1]) + score I[i, j] = max(M[i - 1, j] + go, I[i - 1, j] + ge) D[i, j] = max(M[i, j - 1] + go, D[i, j - 1] + ge) # Traceback aligned_a: list[str] = [] aligned_b: list[str] = [] i, j = n, m curr_score = max(M[i, j], I[i, j], D[i, j]) if curr_score == M[i, j]: curr = "M" elif curr_score == I[i, j]: curr = "I" else: curr = "D" while i > 0 or j > 0: if i == 0: curr = "D" elif j == 0: curr = "I" if curr == "M": aligned_a.append(seq_a[i - 1]) aligned_b.append(seq_b[j - 1]) score = sub_matrix[int(seq_a[i - 1]), int(seq_b[j - 1])] prev_val = M[i, j] - score if i > 0 and j > 0 and M[i - 1, j - 1] == prev_val: curr = "M" elif i > 0 and j > 0 and I[i - 1, j - 1] == prev_val: curr = "I" else: curr = "D" i -= 1 j -= 1 elif curr == "I": aligned_a.append(seq_a[i - 1]) aligned_b.append("-") if i > 0 and M[i - 1, j] + go == I[i, j]: curr = "M" else: curr = "I" i -= 1 else: # "D" aligned_a.append("-") aligned_b.append(seq_b[j - 1]) if j > 0 and M[i, j - 1] + go == D[i, j]: curr = "M" else: curr = "D" j -= 1 aligned_a.reverse() aligned_b.reverse() return aligned_a, aligned_b def _profile_consensus(profile: list[list[str]]) -> list[str]: """Build consensus sequence from a profile (all-gap columns are skipped).""" from collections import Counter consensus: list[str] = [] for col in profile: motifs = [m for m in col if m != "-"] if motifs: consensus.append(Counter(motifs).most_common(1)[0][0]) return consensus def _merge_profiles( profile_a: list[list[str]], profile_b: list[list[str]], aligned_a: list[str], aligned_b: list[str], ) -> list[list[str]]: """Merge two profiles based on aligned consensus sequences.""" merged: list[list[str]] = [] idx_a = 0 idx_b = 0 len_a = len(profile_a[0]) if profile_a else 0 len_b = len(profile_b[0]) if profile_b else 0 for ca, cb in zip(aligned_a, aligned_b): if ca == "-" and cb != "-": col_a = ["-"] * len_a col_b = profile_b[idx_b] merged.append(col_a + col_b) idx_b += 1 elif ca != "-" and cb == "-": col_a = profile_a[idx_a] col_b = ["-"] * len_b merged.append(col_a + col_b) idx_a += 1 else: col_a = profile_a[idx_a] if ca != "-" else ["-"] * len_a col_b = profile_b[idx_b] if cb != "-" else ["-"] * len_b merged.append(col_a + col_b) if ca != "-": idx_a += 1 if cb != "-": idx_b += 1 return merged def _find_homopolymers(consensus: list[str]) -> list[tuple[int, int]]: """Find contiguous runs of identical non-gap tokens in consensus. Returns list of (start, end) half-open intervals where each run has length >= 2 and consensus[start] == ... == consensus[end-1] != "-". """ if not consensus: return [] regions: list[tuple[int, int]] = [] start = 0 for i in range(1, len(consensus)): if consensus[i] != consensus[i - 1] or consensus[i] == "-": if i - start >= 2 and consensus[start] != "-": regions.append((start, i)) start = i if len(consensus) - start >= 2 and consensus[start] != "-": regions.append((start, len(consensus))) return regions def _find_homopolymers_ignore_gaps(consensus: list[str]) -> list[tuple[int, int]]: """Find homopolymer runs, ignoring gaps when computing length. Gaps inside a run do **not** split it, so ``A-A-A-A`` is treated as a single run of 4 A's spanning positions 0..7. """ if not consensus: return [] no_gap = [(i, c) for i, c in enumerate(consensus) if c != "-"] if len(no_gap) < 2: return [] regions: list[tuple[int, int]] = [] start_idx = 0 for i in range(1, len(no_gap)): if no_gap[i][1] != no_gap[i - 1][1]: if i - start_idx >= 2: regions.append((no_gap[start_idx][0], no_gap[i - 1][0] + 1)) start_idx = i if len(no_gap) - start_idx >= 2: regions.append((no_gap[start_idx][0], no_gap[-1][0] + 1)) return regions def _reposition_homopolymer_insertions( ref_aln: list[str], query_aln: list[str], consensus: list[str], divergence_scores: list[int] | None = None, ) -> list[str]: """Move same-base insertions to the most divergent positions. Only acts when the inserted nucleotide is identical to the homopolymer consensus base (e.g. inserting A into a poly-A run). In that case the insertion can slide freely because the query base already matches the consensus; only the ref gap needs to be relocated. Parameters ---------- ref_aln Aligned reference sequence (may contain gaps ``'-'``). query_aln Aligned query sequence. consensus Consensus sequence (same length as ``ref_aln`` / ``query_aln``). divergence_scores Optional per-position divergence counts. If ``None`` they are computed on the fly from deletion / substitution positions. Returns ------- list[str] Updated reference alignment with repositioned gaps. """ regions = _find_homopolymers_ignore_gaps(consensus) if not regions: return list(ref_aln) new_ref = list(ref_aln) for start, end in regions: # Identify the consensus base for this run (skip any leading gap) base = consensus[start] while base == "-" and start < end: start += 1 base = consensus[start] if base == "-": continue # Build divergence scores from non-insertion variants scores = [0] * (end - start) if divergence_scores is not None: scores = divergence_scores[start:end] else: for j in range(start, end): if new_ref[j] != "-" and query_aln[j] != consensus[j]: scores[j - start] += 1 # Locate consecutive same-base insertions in this region i = start while i < end: if new_ref[i] == "-" and query_aln[i] == base: ins_start = i while i < end and new_ref[i] == "-" and query_aln[i] == base: i += 1 ins_end = i ins_len = ins_end - ins_start # Find the best window (highest sum of divergence scores) best_pos = ins_start best_score = -1 for pos in range(start, end - ins_len + 1): # Skip windows that overlap existing gaps if any(new_ref[p] == "-" for p in range(pos, pos + ins_len)): continue score = sum(scores[pos - start : pos - start + ins_len]) if score > best_score: best_score = score best_pos = pos if best_pos != ins_start: # Restore bases at old insertion site for j in range(ins_start, ins_end): new_ref[j] = base # Place gaps at new site for j in range(best_pos, best_pos + ins_len): new_ref[j] = "-" else: i += 1 return new_ref def _reposition_homopolymer_insertions_in_variants( variants_df: "pl.DataFrame", ref_seq: str, ) -> "pl.DataFrame": """Move same-base insertion variants to the most divergent positions. Acts on insertions where every inserted base matches the reference base at or next to the insertion point. This covers two cases: 1. Inside a pre-existing homopolymer. 2. Adjacent to a matching singleton base, creating a new homopolymer. Such insertions can slide freely within the effective homopolymer region. They are repositioned to the window with the highest divergence from other samples. """ import polars as pl if variants_df.is_empty(): return variants_df # Build homopolymer lookup regions = _find_homopolymers(list(ref_seq)) pos_to_region: dict[int, tuple[int, int]] = {} for start, end in regions: for p in range(start, end): pos_to_region[p] = (start, end) # Helper: determine whether an insertion is slidable and return its effective sliding region. def _get_effective_region( pos: int, seq: str, ) -> tuple[int, int] | None: if not seq: return None # only homopolymer insertions can slide if len(set(seq)) != 1: return None base = seq[0] ins_len = len(seq) # Case 1: insertion occurs inside an existing homopolymer region = pos_to_region.get(pos) if region is not None: start, end = region if ( ref_seq[start] == base and end - start >= ins_len ): return region return None # Case 2: insertion is adjacent to matching base(s) matches_next = ( 0 <= pos < len(ref_seq) and ref_seq[pos] == base ) matches_prev = ( 0 < pos <= len(ref_seq) and ref_seq[pos - 1] == base ) if not matches_next and not matches_prev: return None region_start = pos region_end = pos if matches_prev: region_start = pos - 1 while ( region_start > 0 and ref_seq[region_start - 1] == base ): region_start -= 1 if matches_next: region_end = pos + 1 while ( region_end < len(ref_seq) and ref_seq[region_end] == base ): region_end += 1 if region_end - region_start < ins_len: return None return region_start, region_end # Compute divergence map divergence: dict[int, int] = {} for row in variants_df.iter_rows(named=True): pos = row["pos"] var_type = row["type"] if var_type in {"sub", "del"}: length = row.get("length", 1) for i in range(length): divergence[pos + i] = ( divergence.get(pos + i, 0) + 1 ) elif var_type == "ins": seq = row.get("seq", "") or "" # Ignore homopolymer-expanding insertions if _get_effective_region(pos, seq) is not None: continue divergence[pos] = ( divergence.get(pos, 0) + max(1, len(seq)) ) # Reposition slidable insertions new_rows: list[dict] = [] for row in variants_df.iter_rows(named=True): if row["type"] != "ins": new_rows.append(row) continue pos = row["pos"] seq = row.get("seq", "") or "" region = _get_effective_region(pos, seq) if region is None: new_rows.append(row) continue start, end = region ins_len = len(seq) best_pos = pos best_score = sum( divergence.get(pos + i, 0) for i in range(ins_len) ) for candidate in range( start, end - ins_len + 1 + 1, ): score = sum( divergence.get(candidate + i, 0) for i in range(ins_len) ) if score > best_score or ( score == best_score and candidate < best_pos ): best_score = score best_pos = candidate if best_pos != pos: new_row = dict(row) new_row["pos"] = best_pos new_rows.append(new_row) else: new_rows.append(row) return pl.DataFrame(new_rows) def _squeeze_homopolymer( sub_seq: list[str], sub_consensus: list[str], anchor: int | None = None, ) -> list[str]: """Squeeze gaps and mismatches into a contiguous block. Within a homopolymer region every position is equivalent for scoring, so we can freely slide anomalies. The greedy strategy: 1. Collect gaps and mismatches (including insertions). 2. Choose the anchor position (global median across samples if provided, otherwise the local median of this single sequence). 3. Build a contiguous block [gap...gap, mismatch...mismatch] centred on the anchor. 4. Fill remaining positions with the consensus base. This maximises gap continuity (fewer gap-open penalties) and clusters all anomalies at a single locus. """ n = len(sub_seq) if n < 2: return sub_seq base = sub_consensus[0] # Classify positions gaps = [i for i, s in enumerate(sub_seq) if s == "-"] mismatches = [ s for i, (s, c) in enumerate(zip(sub_seq, sub_consensus)) if s != c and s != "-" ] n_gap = len(gaps) n_mm = len(mismatches) if n_gap + n_mm == 0: return sub_seq # Anchor: use provided global anchor, or fall back to local median if anchor is None: anomaly_pos = gaps + [ i for i, (s, c) in enumerate(zip(sub_seq, sub_consensus)) if s != c and s != "-" ] import numpy as np anchor = int(np.median(anomaly_pos)) # Build anomaly block: gaps first, then mismatches block = ["-"] * n_gap + mismatches block_start = max(0, min(anchor - len(block) // 2, n - len(block))) new_sub = [base] * n for i, char in enumerate(block): new_sub[block_start + i] = char return new_sub def _squeeze_homopolymer_regions( aligned_seq: list[str], consensus: list[str], global_anchors: dict[tuple[int, int], int] | None = None, ) -> list[str]: """Apply _squeeze_homopolymer to every homopolymer region in consensus. Parameters ---------- aligned_seq The aligned sequence (may contain gaps). consensus The consensus sequence (no gaps within homopolymer regions). global_anchors Optional mapping from (start, end) region tuple to a pre-computed anchor position. When provided, all sequences in the same region use the same anchor, forcing cross-sample anomaly alignment. """ regions = _find_homopolymers(consensus) if not regions: return aligned_seq new_seq = list(aligned_seq) for start, end in regions: anchor = ( global_anchors.get((start, end)) if global_anchors is not None else None ) new_seq[start:end] = _squeeze_homopolymer( new_seq[start:end], consensus[start:end], anchor ) return new_seq def _align_homopolymer_variants( aligned_seq: list[str], consensus: list[str], global_anchors: dict[tuple[int, int], int] | None = None, ) -> list[str]: """Align variant positions within homopolymer regions to common anchors. For each homopolymer region, slides the anomaly block so that the first anomaly aligns with the global anchor, preserving the original order and relative spacing of anomalies. Parameters ---------- aligned_seq The aligned sequence (may contain gaps). consensus The consensus sequence (no gaps within homopolymer regions). global_anchors Mapping from (start, end) region tuple to a pre-computed anchor position. All sequences in the same region use the same anchor, forcing cross-sample variant alignment. Returns ------- list[str] Sequence with normalized variant positions. """ regions = _find_homopolymers(consensus) if not regions: return list(aligned_seq) new_seq = list(aligned_seq) for start, end in regions: anchor = ( global_anchors.get((start, end)) if global_anchors is not None else None ) sub_seq = new_seq[start:end] sub_cons = consensus[start:end] n = len(sub_seq) if n < 2: continue base = sub_cons[0] # Collect anomalies in original order anomalies: list[tuple[int, str]] = [] for i, (s, c) in enumerate(zip(sub_seq, sub_cons)): if s != c: anomalies.append((i, s)) if not anomalies: continue # Determine anchor: use global anchor or local median of first anomalies if anchor is None: import numpy as np anchor = int(np.median([pos for pos, _ in anomalies])) # Compute offset to align block *center* with anchor first_pos = anomalies[0][0] block_len = anomalies[-1][0] - anomalies[0][0] + 1 block_center = (anomalies[0][0] + anomalies[-1][0]) / 2 offset = round(anchor - block_center) # Ensure the anomaly block fits within the region after sliding if first_pos + offset < 0: offset = -first_pos if first_pos + offset + block_len > n: offset = n - block_len - first_pos # Apply slide new_sub = [base] * n for orig_pos, char in anomalies: new_pos = orig_pos + offset if 0 <= new_pos < n: new_sub[new_pos] = char new_seq[start:end] = new_sub return new_seq def _msa_core( sequences: dict[str, list[str]], sub_matrix: np.ndarray, gap_open_penalty: int, gap_extend_penalty: int, refine: bool = True, max_refine_iter: int = 3, ) -> tuple[dict[str, list[str]], list[str]]: """ Generic progressive MSA engine. Parameters ---------- sequences Mapping from sequence name to list of token strings. sub_matrix Substitution score matrix. gap_open_penalty Gap open penalty (negative). gap_extend_penalty Gap extension penalty (negative). refine Whether to perform iterative consensus refinement. max_refine_iter Maximum refinement iterations. Returns ------- aligned_sequences Mapping from name to aligned token list. consensus Consensus token list. """ import numpy as np from scipy.cluster.hierarchy import linkage, to_tree from scipy.spatial.distance import squareform names = list(sequences.keys()) n = len(names) if n == 0: return {}, [] if n == 1: seq = sequences[names[0]] return {names[0]: list(seq)}, list(seq) # Initialize profiles profiles: dict[int, list[list[str]]] = { i: [[sequences[names[i]][k]] for k in range(len(sequences[names[i]]))] for i in range(n) } seq_indices: dict[int, list[int]] = {i: [i] for i in range(n)} # Compute pairwise distances score_mat = np.zeros((n, n), dtype=float) for i in range(n): for j in range(i + 1, n): score = _nw_score( sequences[names[i]], sequences[names[j]], sub_matrix, gap_open_penalty, gap_extend_penalty, ) score_mat[i, j] = score_mat[j, i] = score max_score = float(score_mat.max()) dist_mat = max_score - score_mat np.fill_diagonal(dist_mat, 0) # UPGMA progressive merge condensed_dist = squareform(dist_mat, checks=False) Z = linkage(condensed_dist, method="average") root = to_tree(Z, rd=False) profiles_cache: dict[int, list[list[str]]] = {} seq_indices_cache: dict[int, list[int]] = {} def _merge_node(node) -> int: if node.is_leaf(): idx = node.id profiles_cache[idx] = profiles[idx] seq_indices_cache[idx] = seq_indices[idx] return idx left_idx = _merge_node(node.get_left()) right_idx = _merge_node(node.get_right()) cons_left = _profile_consensus(profiles_cache[left_idx]) cons_right = _profile_consensus(profiles_cache[right_idx]) aligned_left, aligned_right = _nw( cons_left, cons_right, sub_matrix, gap_open_penalty, gap_extend_penalty, ) merged = _merge_profiles( profiles_cache[left_idx], profiles_cache[right_idx], aligned_left, aligned_right, ) merged_indices = seq_indices_cache[left_idx] + seq_indices_cache[right_idx] new_idx = node.id profiles_cache[new_idx] = merged seq_indices_cache[new_idx] = merged_indices return new_idx final_id = _merge_node(root) final_profile = profiles_cache[final_id] final_indices = seq_indices_cache[final_id] # Build aligned arrays aligned: dict[str, list[str]] = {} for pos, seq_idx in enumerate(final_indices): name = names[seq_idx] aligned[name] = [col[pos] for col in final_profile] # Iterative refinement: re-align raw sequences to consensus if refine: current = aligned consensus: list[str] | None = None for iteration in range(max_refine_iter): msa_len = len(current[names[0]]) temp_profile = [ [current[name][pos] for name in names] for pos in range(msa_len) ] consensus = _profile_consensus(temp_profile) old_consensus = consensus # Re-align every raw sequence to the consensus new_aligned: dict[str, list[str]] = {} for name in names: aligned_seq, _ = _nw( sequences[name], consensus, sub_matrix, gap_open_penalty, gap_extend_penalty, ) new_aligned[name] = aligned_seq # Rebuild consensus from new alignments new_msa_len = len(new_aligned[names[0]]) new_profile = [ [new_aligned[name][pos] for name in names] for pos in range(new_msa_len) ] new_consensus = _profile_consensus(new_profile) if new_consensus == old_consensus: logger.info( "Refinement converged after %d iteration(s).", iteration + 1, ) current = new_aligned break current = new_aligned else: logger.info( "Refinement completed after %d iterations.", max_refine_iter, ) # Build final consensus if loop exited without convergence msa_len = len(current[names[0]]) temp_profile = [ [current[name][pos] for name in names] for pos in range(msa_len) ] consensus = _profile_consensus(temp_profile) result = current else: result = aligned consensus = _profile_consensus(final_profile) # ---- One-pass homopolymer variant position alignment ---- regions = _find_homopolymers(consensus) global_anchors: dict[tuple[int, int], int] = {} for start, end in regions: mismatch_pos: list[int] = [] all_anomaly_pos: list[int] = [] for name in names: sub_seq = result[name][start:end] sub_cons = consensus[start:end] for i, (s, c) in enumerate(zip(sub_seq, sub_cons)): if s != c: all_anomaly_pos.append(i) if s != "-": mismatch_pos.append(i) if not all_anomaly_pos: continue # Density-priority anchor: choose the position with the most anomalies # (gaps + mismatches). If tied, prefer positions that have mismatches. from collections import Counter pos_counts = Counter(all_anomaly_pos) max_count = max(pos_counts.values()) candidates = [p for p, c in pos_counts.items() if c == max_count] if len(candidates) == 1: anchor = candidates[0] else: mismatch_set = set(mismatch_pos) mismatch_candidates = [p for p in candidates if p in mismatch_set] if mismatch_candidates: anchor = mismatch_candidates[0] else: anchor = candidates[0] global_anchors[(start, end)] = anchor # Normalize variant positions normalized: dict[str, list[str]] = {} for name in names: normalized[name] = _align_homopolymer_variants( result[name], consensus, global_anchors, ) # Rebuild consensus after normalization norm_msa_len = len(normalized[names[0]]) norm_profile = [ [normalized[name][pos] for name in names] for pos in range(norm_msa_len) ] result_consensus = _profile_consensus(norm_profile) return normalized, result_consensus
[docs] def motif_msa( adata: ad.AnnData, reference: str | int | None = None, *, store_key: str = "motif_msa", match_score: int = 2, mismatch_penalty: int = -3, gap_open_penalty: int = -5, gap_extend_penalty: int = -1, ) -> ad.AnnData: """ Align motif sequences using progressive MSA or pairwise reference alignment. Parameters ---------- adata : ad.AnnData Annotated data with ``var["motif"]`` containing motif sequences. reference : str | int | None, default=None Reference motif. If ``None``, performs a progressive MSA of all motifs. If an ``int`` or ``str``, performs pairwise alignments of each motif against the specified reference. store_key : str, default="motif_msa" Key under which results are stored in ``adata.uns``. match_score : int, default=2 Match score for alignment. mismatch_penalty : int, default=-3 Mismatch penalty for alignment. gap_open_penalty : int, default=-5 Gap open penalty. gap_extend_penalty : int, default=-1 Gap extension penalty. Returns ------- ad.AnnData The updated AnnData with alignment results in ``uns``. Examples -------- >>> import vampire as vp >>> adata = vp.datasets.wdr7_hprc() >>> vp.anno.tl.motif_msa(adata) """ import numpy as np import polars as pl import parasail if match_score <= 0: raise ValueError("match_score should be positive.") if mismatch_penalty >= 0: raise ValueError("mismatch_penalty should be negative.") if gap_open_penalty >= 0: raise ValueError("gap_open_penalty should be negative.") if gap_extend_penalty >= 0: raise ValueError("gap_extend_penalty should be negative.") if "motif" not in adata.var.columns: raise ValueError("adata.var['motif'] not found") motifs = adata.var["motif"] if reference is None: # MSA mode: use generic progressive MSA engine logger.info("Performing MSA of %d motifs...", len(motifs)) # Map DNA bases to string indices so _nw's int() lookup works bases = "ACGT" base_to_stridx = {b: str(i) for i, b in enumerate(bases)} stridx_to_base = {str(i): b for i, b in enumerate(bases)} # Build DNA substitution matrix (4x4) sub_matrix = np.full((len(bases), len(bases)), mismatch_penalty, dtype=int) np.fill_diagonal(sub_matrix, match_score) # Convert sequences to string-index token lists sequences_str: dict[str, list[str]] = {} for motif_id, seq in motifs.items(): tokens = [base_to_stridx[c] for c in str(seq).upper() if c in base_to_stridx] sequences_str[str(motif_id)] = tokens if len(sequences_str) == 0: adata.uns[store_key] = { "mode": "msa", "reference": "", "reference_id": "consensus", "alignment": {}, "consensus": "", "n_motifs": 0, "variants": pl.DataFrame(), } return # Run MSA engine aligned_tokens, consensus_tokens = _msa_core( sequences_str, sub_matrix, gap_open_penalty, gap_extend_penalty, refine=True, max_refine_iter=3, ) # Map back to DNA strings alignment = { k: "".join(stridx_to_base.get(t, t) for t in v) for k, v in aligned_tokens.items() } consensus = "".join(stridx_to_base.get(t, t) for t in consensus_tokens) # Build aligned consensus (gaps at all-gap columns) for unified format seqs = list(alignment.values()) n_cols = len(seqs[0]) all_gap_cols = {i for i in range(n_cols) if all(s[i] == "-" for s in seqs)} consensus_aln_list: list[str] = [] ci = 0 for i in range(n_cols): if i in all_gap_cols: consensus_aln_list.append("-") else: consensus_aln_list.append(consensus[ci]) ci += 1 alignment["reference"] = "".join(consensus_aln_list) # Compute variants directly from MSA alignment (reflects squeeze) variants_list = _msa_alignment_to_variants(alignment, consensus) variants_df = pl.DataFrame(variants_list) adata.uns[store_key] = { "mode": "msa", "reference": consensus, "reference_id": "consensus", "alignment": alignment, "consensus": consensus, "n_motifs": len(motifs), "variants": variants_df, } logger.info( "MSA completed. Aligned length: %d. Found %d variants.", len(consensus), len(variants_df), ) else: # Pairwise mode: align each motif against the reference if isinstance(reference, int): ref_id = str(reference) if ref_id not in adata.var.index: raise KeyError(f"Motif id '{ref_id}' not found in adata.var.index") ref_seq = str(adata.var.loc[ref_id, "motif"]) else: ref_id = None ref_seq = reference logger.info( "Reference specified; performing pairwise alignments of %d motifs against '%s'", len(motifs), ref_seq, ) matrix = parasail.matrix_create("ACGT", match_score, mismatch_penalty) # Step 1: Collect all raw pairwise alignments raw_alignments: dict[str, tuple[str, str]] = {} for motif_id, seq in motifs.items(): if str(seq) == ref_seq: raw_alignments[str(motif_id)] = (ref_seq, ref_seq) else: result = parasail.nw_trace_striped_16( seq, ref_seq, -gap_open_penalty, -gap_extend_penalty, matrix, ) raw_alignments[str(motif_id)] = (result.traceback.ref, result.traceback.query) # Step 2: Find homopolymer regions in reference ref_regions = _find_homopolymers(list(ref_seq)) # Step 3: Compute global anchors from ALL alignments global_anchors: dict[tuple[int, int], int] = {} for start, end in ref_regions: mismatch_pos: list[int] = [] all_anomaly_pos: list[int] = [] for motif_id, (ref_aln, query_aln) in raw_alignments.items(): if motif_id == "reference": continue ref_idx = 0 for i, char in enumerate(ref_aln): if char != "-": if start <= ref_idx < end: if query_aln[i] != char: rel_pos = ref_idx - start all_anomaly_pos.append(rel_pos) if query_aln[i] != "-": mismatch_pos.append(rel_pos) ref_idx += 1 if not all_anomaly_pos: continue # Density-priority anchor: choose the position with the most anomalies # (gaps + mismatches). If tied, prefer positions that have mismatches. from collections import Counter pos_counts = Counter(all_anomaly_pos) max_count = max(pos_counts.values()) candidates = [p for p, c in pos_counts.items() if c == max_count] if len(candidates) == 1: anchor = candidates[0] else: mismatch_set = set(mismatch_pos) mismatch_candidates = [p for p in candidates if p in mismatch_set] if mismatch_candidates: anchor = mismatch_candidates[0] else: anchor = candidates[0] global_anchors[(start, end)] = anchor # Step 4: Normalize variant positions in each query alignment normalized_alignments: dict[str, tuple[str, str]] = {} for motif_id, (ref_aln, query_aln) in raw_alignments.items(): new_query = list(query_aln) for start, end in ref_regions: anchor = global_anchors.get((start, end)) if anchor is None: continue # Extract region from alignment (map ref_seq coords to aln cols) region_ref: list[str] = [] region_query: list[str] = [] ref_idx = 0 for i, char in enumerate(ref_aln): if char != "-": if start <= ref_idx < end: region_ref.append(char) region_query.append(query_aln[i]) ref_idx += 1 if not region_ref: continue # Find anomalies in region anomalies: list[tuple[int, str]] = [] for i, (q, r) in enumerate(zip(region_query, region_ref)): if q != r: anomalies.append((i, q)) if not anomalies: continue # Slide anomalies so first anomaly aligns with anchor first_pos = anomalies[0][0] offset = anchor - first_pos block_len = anomalies[-1][0] - anomalies[0][0] + 1 if first_pos + offset < 0: offset = -first_pos if first_pos + offset + block_len > len(region_ref): offset = len(region_ref) - block_len - first_pos new_region = [region_ref[0]] * len(region_ref) for orig_pos, char in anomalies: new_pos = orig_pos + offset if 0 <= new_pos < len(region_ref): new_region[new_pos] = char # Map back to alignment coordinates ref_idx = 0 region_idx = 0 for i, char in enumerate(ref_aln): if char != "-": if start <= ref_idx < end: new_query[i] = new_region[region_idx] region_idx += 1 ref_idx += 1 normalized_alignments[motif_id] = (ref_aln, "".join(new_query)) # Step 5: Build final alignment and variants from normalized alignments variants_list: list[dict] = [] alignment: dict[str, str] = {"reference": ref_seq} for motif_id, seq in motifs.items(): if str(seq) == ref_seq: alignment[str(motif_id)] = ref_seq continue ref_aln, query_aln = normalized_alignments[str(motif_id)] alignment[str(motif_id)] = query_aln sample_variants = _pairwise_alignment_to_variants( ref_aln, query_aln, ref_seq, str(motif_id), ) variants_list.extend(sample_variants) variants_df = pl.DataFrame(variants_list) # Step 5b: Reposition same-base insertions to most divergent positions variants_df = _reposition_homopolymer_insertions_in_variants( variants_df, ref_seq ) adata.uns[store_key] = { "mode": "pairwise", "reference": ref_seq, "reference_id": ref_id, "alignment": alignment, "consensus": None, "n_motifs": len(motifs), "variants": variants_df, } logger.info( "Aligned %d motifs against reference '%s'. Found %d variants.", len(motifs), ref_seq, len(variants_df), ) return adata
def _pairwise_alignment_to_variants( ref_aln: str, seq_aln: str, ref: str, motif_id: str, ) -> list[dict]: """ Convert a parasail traceback to variant records. Parameters ---------- ref_aln Aligned reference sequence from parasail (may contain gaps ``'-'``). seq_aln Aligned query sequence from parasail (may contain gaps ``'-'``). ref Original reference motif sequence. motif_id Motif identifier (used as ``sample`` in output). Returns ------- list[dict] Variant records with keys ``sample``, ``pos``, ``type``, ``ref``, ``alt``, ``seq``, ``length``. """ variants: list[dict] = [] ref_idx = 0 i = 0 while i < len(ref_aln): r = ref_aln[i] s = seq_aln[i] # Skip any position where both are gaps (should not happen, # but defensive). if r == "-" and s == "-": i += 1 continue # Match if r != "-" and s != "-" and r == s: ref_idx += 1 i += 1 continue # Substitution if r != "-" and s != "-" and r != s: variants.append( { "sample": motif_id, "pos": ref_idx, "type": "sub", "ref": r, "alt": s, "seq": None, "length": 1, } ) ref_idx += 1 i += 1 continue # Insertion in query (gap in reference) if r == "-" and s != "-": ins_seq = "" while i < len(ref_aln) and ref_aln[i] == "-" and seq_aln[i] != "-": ins_seq += seq_aln[i] i += 1 variants.append( { "sample": motif_id, "pos": ref_idx, "type": "ins", "ref": None, "alt": None, "seq": ins_seq, "length": len(ins_seq), } ) continue # Deletion in query (gap in query) if r != "-" and s == "-": del_len = 0 del_start = ref_idx del_seq = "" while i < len(ref_aln) and ref_aln[i] != "-" and seq_aln[i] == "-": del_seq += ref_aln[i] del_len += 1 ref_idx += 1 i += 1 variants.append( { "sample": motif_id, "pos": del_start, "type": "del", "ref": del_seq, "alt": None, "seq": None, "length": del_len, } ) continue return variants def _msa_alignment_to_variants( alignment: dict[str, str], consensus: str, ) -> list[dict]: """Convert MSA alignment to variant records relative to consensus. Parameters ---------- alignment Mapping from sample name to aligned DNA sequence (may contain gaps). Must include a ``"reference"`` key with the aligned consensus. consensus Consensus sequence without gaps. Returns ------- list[dict] Variant records with keys ``sample``, ``pos``, ``type``, ``ref``, ``alt``, ``seq``, ``length``. """ if not alignment: return [] seqs = [v for k, v in alignment.items() if k != "reference"] if not seqs: return [] n_cols = len(seqs[0]) ref_aln = alignment.get("reference", "") variants: list[dict] = [] for sample, seq_aln in alignment.items(): if sample == "reference": continue ref_idx = 0 i = 0 while i < n_cols: r = ref_aln[i] if i < len(ref_aln) else "-" s = seq_aln[i] # Skip positions where both are gaps if r == "-" and s == "-": i += 1 continue # Match if r != "-" and s != "-" and r == s: ref_idx += 1 i += 1 continue # Substitution if r != "-" and s != "-" and r != s: variants.append( { "sample": sample, "pos": ref_idx, "type": "sub", "ref": r, "alt": s, "seq": None, "length": 1, } ) ref_idx += 1 i += 1 continue # Insertion in query (gap in reference) if r == "-" and s != "-": ins_seq = "" while ( i < n_cols and (ref_aln[i] if i < len(ref_aln) else "-") == "-" and seq_aln[i] != "-" ): ins_seq += seq_aln[i] i += 1 variants.append( { "sample": sample, "pos": ref_idx, "type": "ins", "ref": None, "alt": None, "seq": ins_seq, "length": len(ins_seq), } ) continue # Deletion in query (gap in query) if r != "-" and s == "-": del_len = 0 del_start = ref_idx del_seq = "" while ( i < n_cols and (ref_aln[i] if i < len(ref_aln) else "-") != "-" and seq_aln[i] == "-" ): del_seq += ref_aln[i] del_len += 1 ref_idx += 1 i += 1 variants.append( { "sample": sample, "pos": del_start, "type": "del", "ref": del_seq, "alt": None, "seq": None, "length": del_len, } ) continue return variants