Edit on GitHub

pynw

Check Build PyPI Python License: MIT

pynw aligns two ordered sequences element-by-element using any pairwise similarity matrix you supply. Think of it as a generalised diff for arbitrary ordered collections: words, embeddings, tokens, or any objects where you define how well each pair of elements matches.

Unlike string-distance or bioinformatics libraries that assume a fixed alphabet and built-in scoring scheme, pynw delegates scoring entirely to the user, so any pairwise metric works: cosine similarity of embeddings, model outputs, learned distances, or domain-specific rules. The Needleman-Wunsch global sequence alignment algorithm provides the underpinning for pynw. It is implemented in Rust with Python bindings built using PyO3.

import numpy as np
from pynw import needleman_wunsch

# Pairwise similarity matrix between your two sequences (source × target)
S = np.array([
    [1.0, 0.1],
    [0.1, 0.1],
    [0.1, 1.0],
])

score, editops = needleman_wunsch(S, gap_penalty=-0.5)
# score: 1.5
# editops: [EditOp.Align, EditOp.Delete, EditOp.Align]

Features

  • Fast: Alignment runs in $\mathcal{O}(nm)$ time; a $1000 \times 1000$ matrix takes <10 ms on modern CPUs.
  • NumPy-first: Pass NumPy arrays directly, no conversion needed.
  • Domain-agnostic: Operates on a user-supplied similarity matrix.
  • Asymmetric gaps: Penalize inserts and deletes independently.

When to Use pynw

Reach for pynw when you need global alignment of two ordered sequences and the notion of similarity is specific to your domain: aligning sentences from two translations using embedding similarities, matching token streams emitted by different tokenizers, comparing event logs with custom match rules, or any case where precomputing scores in NumPy is natural.

If your problem fits a standard string metric (Levenshtein, Jaro-Winkler, and friends), rapidfuzz is faster and more featureful. If you are aligning biological sequences, use a bioinformatics library such as BioPython. If ordering does not matter and you want optimal one-to-one assignment, see scipy.optimize.linear_sum_assignment. See Related Projects for more.

Installation

Prebuilt wheels for Linux, macOS, and Windows are published on PyPI:

pip install pynw

pynw requires Python 3.10+ and NumPy 1.22+. On platforms without a prebuilt wheel, pip will build from the source distribution; this requires a Rust toolchain (1.85+).

Quick Start

Using pynw involves three steps:

  1. Build a similarity matrix. Compute pairwise scores between every element of your two sequences using any scoring function.
  2. Run the alignment. Pass the matrix and a gap penalty to needleman_wunsch. The gap penalty controls when leaving an element unmatched is preferable to a low-scoring match.
  3. Interpret the results. The returned edit operations tell you which elements were aligned, inserted, or deleted.

The example below aligns two word sequences. The similarity matrix is built from cosine similarities of GloVe word embeddings, letting semantically related words align even without an exact match:

import numpy as np
from pynw import EditOp, needleman_wunsch, alignment_indices

source = np.array(
    ["clever", "sneaky", "fox", "leaped"]
)
target = np.array(
    ["sly", "fox", "jumped", "across"]
)

# Cosine similarity from GloVe (glove-wiki-gigaword-50)
similarity_matrix = np.array([
    # sly     fox     jumped  across
    [ 0.65,   0.25,   0.06,   0.20],  # clever
    [ 0.57,   0.06,  -0.14,  -0.05],  # sneaky
    [ 0.26,   1.00,   0.30,   0.41],  # fox
    [-0.00,   0.07,   0.77,   0.35],  # leaped
])

# Each gap deducts 0.5 from the total score; increase the penalty to force
# more alignments, decrease it to allow more gaps
score, editops = needleman_wunsch(similarity_matrix, gap_penalty=-0.5)
source_indices, target_indices = alignment_indices(editops)

# Reconstruct aligned sequences; masked positions are gaps
aligned_source = np.ma.array(source).take(source_indices).filled("-")
aligned_target = np.ma.array(target).take(target_indices).filled("-")

LABELS = {EditOp.Align: "match", EditOp.Delete: "delete", EditOp.Insert: "insert"}

print(f"Score: {round(score, 2)}")
for op, s, t in zip(editops, aligned_source, aligned_target):
    print(f"  {s:10s}  {t:10s}  ({LABELS[op]})")

Output:

Score: 1.42
  clever      sly         (match)
  sneaky      -           (delete)
  fox         fox         (match)
  leaped      jumped      (match)
  -           across      (insert)

User Guide

pynw exposes two alignment functions and a helper for interpreting results. Use needleman_wunsch when you need the actual alignment, and needleman_wunsch_score when you only need the score.

pynw takes a precomputed $n \times m$ similarity matrix rather than a scoring callback. This allows the alignment to run entirely in native code and lets you build scores using vectorized NumPy operations, at the cost of $\mathcal{O}(nm)$ memory for the matrix.

Score and alignment: needleman_wunsch

needleman_wunsch returns the optimal score along with an array of edit operations (editops). Each element in the editops array is one of three EditOp values:

  • EditOp.Align: a source element is matched with a target element.
  • EditOp.Delete: a source element is consumed with no matching target element (gap in target).
  • EditOp.Insert: a target element is consumed with no matching source element (gap in source).

The editops array alone is enough for aggregate statistics:

from pynw import EditOp, needleman_wunsch

score, editops = needleman_wunsch(similarity_matrix, gap_penalty=-1.0)

n_aligned = np.sum(editops == EditOp.Align)
n_inserted = np.sum(editops == EditOp.Insert)
n_deleted = np.sum(editops == EditOp.Delete)

When multiple alignments achieve the same optimal score, pynw breaks ties deterministically: Align > Delete > Insert.

gap_penalty applies equally to insertions and deletions. Pass insert_penalty and/or delete_penalty to penalize them independently, which is useful when the cost of missing a source element differs from the cost of introducing a spurious target element:

score, editops = needleman_wunsch(
    similarity_matrix,
    insert_penalty=-0.3,
    delete_penalty=-0.5,
)

Reconstructing the alignment: alignment_indices

alignment_indices converts an editops array into two masked index arrays (one per sequence) with one entry per alignment position. Gap positions are masked, so take(...).filled("-") reconstructs aligned sequences with gap markers:

from pynw import alignment_indices

source_indices, target_indices = alignment_indices(editops)

source = np.ma.array(["the", "quick", "fox"])
target = np.ma.array(["the", "slow", "red", "fox"])

aligned_source = source.take(source_indices).filled("-")
aligned_target = target.take(target_indices).filled("-")
# aligned_source: ['the', 'quick', '-',   'fox']
# aligned_target: ['the', 'slow',  'red', 'fox']

Iterating over a masked array yields np.ma.masked at gap positions, so you can branch on the editop without explicit mask checks:

for op, src, tgt in zip(editops, source_indices, target_indices):
    if op == EditOp.Align:
        print(f"  {source[src]}")
    elif op == EditOp.Delete:
        print(f"- {source[src]}")
    elif op == EditOp.Insert:
        print(f"+ {target[tgt]}")

Score only: needleman_wunsch_score

Use needleman_wunsch_score when you only need the alignment score, for example when ranking or filtering many sequence pairs. It skips the traceback entirely, using $\mathcal{O}(m)$ memory instead of $\mathcal{O}(nm)$:

from pynw import needleman_wunsch_score

score = needleman_wunsch_score(similarity_matrix, gap_penalty=-1.0)

Reproducing Classical Edit Distances

Needleman-Wunsch can reproduce common metrics with the right similarity-matrix values and gap penalty:

Metric S[i,j] match S[i,j] mismatch gap_penalty NW score equals
Levenshtein distance 0 -1 -1 -distance
Indel distance 0 -2 -1 -distance
LCS length 1 0 0 lcs_length
Hamming distance 0 -1 -(n+1) -distance

For Hamming distance, strings must have equal length.

 1"""
 2.. include:: ../../README.md
 3   :start-line: 1
 4   :end-before: ## API
 5"""
 6
 7from importlib.metadata import version
 8
 9from pynw._native import needleman_wunsch, needleman_wunsch_score
10from pynw._ops import EditOp, alignment_indices
11
12__docformat__ = "numpy"
13__version__ = version("pynw")
14
15__all__ = [
16    "needleman_wunsch",
17    "needleman_wunsch_score",
18    "EditOp",
19    "alignment_indices",
20]
def needleman_wunsch( similarity_matrix, *, gap_penalty=None, insert_penalty=None, delete_penalty=None):

Align two ordered sequences given a precomputed similarity matrix.

The total alignment score is the sum of similarity-matrix entries for matched positions and gap penalties for insertions/deletions.

Parameters
  • similarity_matrix (array_like, shape (n, m)): similarity_matrix[i, j] is the similarity score for aligning element i of the source sequence with element j of the target sequence.
  • gap_penalty (float, optional): Penalty applied when a gap is inserted in either sequence, used as fallback for insert_penalty and delete_penalty. Either gap_penalty or both insert_penalty and delete_penalty must be provided.
  • insert_penalty (float, optional): Penalty for advancing the target sequence without consuming a source element (gap in source). Defaults to gap_penalty.
  • delete_penalty (float, optional): Penalty for advancing the source sequence without consuming a target element (gap in target). Defaults to gap_penalty.
Raises
  • ValueError: If similarity_matrix is not 2-dimensional, if any value in similarity_matrix or the gap penalties is NaN or Inf, or if the resolved insert or delete penalty is unspecified.
Returns
  • score (float): The optimal alignment score.
  • editops (ndarray of uint8, shape (k,)): Sequence of edit operations describing the alignment. Each element is of type EditOp. Use alignment_indices to reconstruct source and target index arrays.
Notes

When multiple alignments achieve the same optimal score, ties are broken deterministically: Align > Delete > Insert. This prefers substitutions over gaps, producing compact alignments. Other tools may return different co-optimal alignments.

All values in similarity_matrix and the gap penalties must be finite.

def needleman_wunsch_score( similarity_matrix, *, gap_penalty=None, insert_penalty=None, delete_penalty=None):

Compute the optimal Needleman-Wunsch alignment score without the traceback.

Returns the same score as needleman_wunsch but uses O(m) memory instead of O(n*m) by retaining only two rows of the DP table at a time. The runtime difference between the two is minor. Use this function when you need the score but not the alignment itself.

Parameters
  • similarity_matrix (array_like, shape (n, m)): similarity_matrix[i, j] is the similarity score for aligning element i of the source sequence with element j of the target sequence.
  • gap_penalty (float, optional): Penalty applied when a gap is inserted in either sequence, used as fallback for insert_penalty and delete_penalty. Either gap_penalty or both insert_penalty and delete_penalty must be provided.
  • insert_penalty (float, optional): Penalty for advancing the target sequence without consuming a source element (gap in source). Defaults to gap_penalty.
  • delete_penalty (float, optional): Penalty for advancing the source sequence without consuming a target element (gap in target). Defaults to gap_penalty.
Raises
  • ValueError: If similarity_matrix is not 2-dimensional, if any value in similarity_matrix or the gap penalties is NaN or Inf, or if the resolved insert or delete penalty is unspecified.
Returns
  • score (float): The optimal alignment score.
Notes

All values in similarity_matrix and the gap penalties must be finite.

class EditOp(enum.IntEnum):
18class EditOp(IntEnum):
19    """
20    Edit operation codes.
21
22    Not guaranteed to be compatible between pynw version.
23    """
24
25    Align = OP_ALIGN
26    Insert = OP_INSERT
27    Delete = OP_DELETE

Edit operation codes.

Not guaranteed to be compatible between pynw version.

Align = <EditOp.Align: 0>
Insert = <EditOp.Insert: 1>
Delete = <EditOp.Delete: 2>
def alignment_indices( editops: ArrayLike) -> tuple[numpy.ma.MaskedArray[tuple[int], numpy.dtype[numpy.int64]], numpy.ma.MaskedArray[tuple[int], numpy.dtype[numpy.int64]]]:
33def alignment_indices(
34    editops: npt.ArrayLike,
35) -> tuple[MaskedIndexArray, MaskedIndexArray]:
36    """Reconstruct source and target indices from an editops array.
37
38    Converts a sequence of edit operations into a pair of masked index arrays.
39    Each array has one entry per alignment position.  Positions where the
40    corresponding sequence has a gap are masked out.
41
42    Parameters
43    ----------
44    editops : array_like of uint8, shape (k,)
45        Edit-operation sequence returned by ``needleman_wunsch``.
46
47    Returns
48    -------
49    source_idx : masked array of intp, shape (k,)
50        Index into the source sequence at each alignment position.
51        Masked (invalid) at insert positions (gap in source).
52    target_idx : masked array of intp, shape (k,)
53        Index into the target sequence at each alignment position.
54        Masked (invalid) at delete positions (gap in target).
55
56    Raises
57    ------
58    ValueError
59        If ``editops`` cannot be converted to a 1-D ``uint8`` array, if any
60        element is out of the ``uint8`` range, or if any element is not a
61        valid ``EditOp`` discriminant.
62    """
63    src_idx, src_mask, tgt_idx, tgt_mask = _alignment_indices(editops)
64    # np.ma.array is untyped before numpy 2.4
65    return np.ma.array(src_idx, mask=src_mask), np.ma.array(tgt_idx, mask=tgt_mask)  # type: ignore[no-untyped-call]

Reconstruct source and target indices from an editops array.

Converts a sequence of edit operations into a pair of masked index arrays. Each array has one entry per alignment position. Positions where the corresponding sequence has a gap are masked out.

Parameters
  • editops (array_like of uint8, shape (k,)): Edit-operation sequence returned by needleman_wunsch.
Returns
  • source_idx (masked array of intp, shape (k,)): Index into the source sequence at each alignment position. Masked (invalid) at insert positions (gap in source).
  • target_idx (masked array of intp, shape (k,)): Index into the target sequence at each alignment position. Masked (invalid) at delete positions (gap in target).
Raises
  • ValueError: If editops cannot be converted to a 1-D uint8 array, if any element is out of the uint8 range, or if any element is not a valid EditOp discriminant.