|
| 1 | +"""Needleman-Wunsch algorithm for global sequence alignment. |
| 2 | +
|
| 3 | +Reference: |
| 4 | + https://en.wikipedia.org/wiki/Needleman%E2%80%93Wunsch_algorithm |
| 5 | +
|
| 6 | +The Needleman-Wunsch algorithm (1970) is a dynamic programming algorithm |
| 7 | +used in bioinformatics and computational biology to find the optimal global |
| 8 | +alignment between two sequences (such as DNA, RNA, or protein sequences). |
| 9 | +
|
| 10 | +Unlike local alignment algorithms (e.g., Smith-Waterman), which find the |
| 11 | +highest-scoring local sub-regions, Needleman-Wunsch aligns both sequences across |
| 12 | +their entire lengths from start to finish. |
| 13 | +
|
| 14 | +Algorithm: |
| 15 | +1. Initialization: |
| 16 | + - Construct a matrix of size (m + 1) x (n + 1) where m and n are sequence lengths. |
| 17 | + - Initialize boundary conditions: |
| 18 | + score_matrix[i][0] = i * gap_score |
| 19 | + score_matrix[0][j] = j * gap_score |
| 20 | +
|
| 21 | +2. Matrix Filling (Recurrence Relation): |
| 22 | + For each cell (i, j): |
| 23 | + diagonal = score_matrix[i - 1][j - 1] + (match_score if seq1[i-1] == seq2[j-1] |
| 24 | + else mismatch_score) |
| 25 | + deletion = score_matrix[i - 1][j] + gap_score |
| 26 | + insertion = score_matrix[i][j - 1] + gap_score |
| 27 | + score_matrix[i][j] = max(diagonal, deletion, insertion) |
| 28 | +
|
| 29 | +3. Traceback: |
| 30 | + - Start from the bottom-right cell (m, n) and trace back to (0, 0). |
| 31 | + - At each step, determine which direction (diagonal, up, or left) produced the |
| 32 | + maximum score, assembling the aligned sequences in reverse order. |
| 33 | +
|
| 34 | +Complexity: |
| 35 | + Time Complexity: O(m * n) where m and n are the lengths of the sequences. |
| 36 | + Space Complexity: O(m * n) to store the score matrix for traceback. |
| 37 | +""" |
| 38 | + |
| 39 | +from __future__ import annotations |
| 40 | + |
| 41 | + |
| 42 | +def needleman_wunsch( |
| 43 | + sequence1: str, |
| 44 | + sequence2: str, |
| 45 | + match_score: int = 1, |
| 46 | + mismatch_score: int = -1, |
| 47 | + gap_score: int = -1, |
| 48 | +) -> tuple[str, str, int]: |
| 49 | + """Compute the optimal global sequence alignment using Needleman-Wunsch. |
| 50 | +
|
| 51 | + Parameters: |
| 52 | + sequence1: The first input sequence to align. |
| 53 | + sequence2: The second input sequence to align. |
| 54 | + match_score: Score awarded when two characters match (default: 1). |
| 55 | + mismatch_score: Penalty score when characters do not match (default: -1). |
| 56 | + gap_score: Penalty score for introducing a gap '-' (default: -1). |
| 57 | +
|
| 58 | + Returns: |
| 59 | + A tuple containing: |
| 60 | + - aligned_sequence1: The first aligned sequence with inserted gaps. |
| 61 | + - aligned_sequence2: The second aligned sequence with inserted gaps. |
| 62 | + - alignment_score: The total optimal alignment score. |
| 63 | +
|
| 64 | + Raises: |
| 65 | + ValueError: If gap_score is positive (gap must be neutral or a penalty). |
| 66 | +
|
| 67 | + Examples: |
| 68 | + >>> # Wikipedia classic example |
| 69 | + >>> needleman_wunsch( |
| 70 | + ... "GCATGCG", "GATTACA", match_score=1, mismatch_score=-1, gap_score=-1 |
| 71 | + ... ) |
| 72 | + ('GCA-TGCG', 'G-ATTACA', 0) |
| 73 | +
|
| 74 | + >>> # Identical sequences |
| 75 | + >>> needleman_wunsch( |
| 76 | + ... "ACGT", "ACGT", match_score=2, mismatch_score=-1, gap_score=-2 |
| 77 | + ... ) |
| 78 | + ('ACGT', 'ACGT', 8) |
| 79 | +
|
| 80 | + >>> # Completely mismatched sequences |
| 81 | + >>> needleman_wunsch( |
| 82 | + ... "AAAA", "TTTT", match_score=1, mismatch_score=-1, gap_score=-2 |
| 83 | + ... ) |
| 84 | + ('AAAA', 'TTTT', -4) |
| 85 | +
|
| 86 | + >>> # One sequence is empty |
| 87 | + >>> needleman_wunsch("AGTC", "", match_score=1, mismatch_score=-1, gap_score=-1) |
| 88 | + ('AGTC', '----', -4) |
| 89 | +
|
| 90 | + >>> # Both sequences are empty |
| 91 | + >>> needleman_wunsch("", "") |
| 92 | + ('', '', 0) |
| 93 | +
|
| 94 | + >>> # Protein sequence example |
| 95 | + >>> needleman_wunsch( |
| 96 | + ... "HEAGAWGHEE", "PAWHEAE", match_score=2, mismatch_score=-1, gap_score=-2 |
| 97 | + ... ) |
| 98 | + ('HEAGAWGHE-E', '---PAW-HEAE', -1) |
| 99 | +
|
| 100 | + >>> # Invalid gap score |
| 101 | + >>> needleman_wunsch("A", "C", gap_score=5) |
| 102 | + Traceback (most recent call last): |
| 103 | + ... |
| 104 | + ValueError: gap_score must be non-positive (<= 0) |
| 105 | + """ |
| 106 | + if gap_score > 0: |
| 107 | + msg = "gap_score must be non-positive (<= 0)" |
| 108 | + raise ValueError(msg) |
| 109 | + |
| 110 | + first_sequence_length = len(sequence1) |
| 111 | + second_sequence_length = len(sequence2) |
| 112 | + |
| 113 | + # Initialize the (m + 1) x (n + 1) dynamic programming score matrix |
| 114 | + score_matrix = [ |
| 115 | + [0] * (second_sequence_length + 1) for _ in range(first_sequence_length + 1) |
| 116 | + ] |
| 117 | + |
| 118 | + # Fill base-case boundary penalties |
| 119 | + for row_index in range(first_sequence_length + 1): |
| 120 | + score_matrix[row_index][0] = row_index * gap_score |
| 121 | + for col_index in range(second_sequence_length + 1): |
| 122 | + score_matrix[0][col_index] = col_index * gap_score |
| 123 | + |
| 124 | + # Populate the score matrix using dynamic programming |
| 125 | + for row_index in range(1, first_sequence_length + 1): |
| 126 | + for col_index in range(1, second_sequence_length + 1): |
| 127 | + char1 = sequence1[row_index - 1] |
| 128 | + char2 = sequence2[col_index - 1] |
| 129 | + substitution = match_score if char1 == char2 else mismatch_score |
| 130 | + |
| 131 | + diagonal_score = score_matrix[row_index - 1][col_index - 1] + substitution |
| 132 | + deletion_score = score_matrix[row_index - 1][col_index] + gap_score |
| 133 | + insertion_score = score_matrix[row_index][col_index - 1] + gap_score |
| 134 | + |
| 135 | + score_matrix[row_index][col_index] = max( |
| 136 | + diagonal_score, deletion_score, insertion_score |
| 137 | + ) |
| 138 | + |
| 139 | + # Traceback from bottom-right (m, n) to top-left (0, 0) |
| 140 | + aligned_chars_first: list[str] = [] |
| 141 | + aligned_chars_second: list[str] = [] |
| 142 | + curr_row = first_sequence_length |
| 143 | + curr_col = second_sequence_length |
| 144 | + |
| 145 | + while curr_row > 0 or curr_col > 0: |
| 146 | + if curr_row > 0 and curr_col > 0: |
| 147 | + char1 = sequence1[curr_row - 1] |
| 148 | + char2 = sequence2[curr_col - 1] |
| 149 | + substitution = match_score if char1 == char2 else mismatch_score |
| 150 | + |
| 151 | + # Check if diagonal step was optimal |
| 152 | + if ( |
| 153 | + score_matrix[curr_row][curr_col] |
| 154 | + == score_matrix[curr_row - 1][curr_col - 1] + substitution |
| 155 | + ): |
| 156 | + aligned_chars_first.append(char1) |
| 157 | + aligned_chars_second.append(char2) |
| 158 | + curr_row -= 1 |
| 159 | + curr_col -= 1 |
| 160 | + continue |
| 161 | + |
| 162 | + # Check if vertical step (gap in second sequence) was optimal |
| 163 | + if ( |
| 164 | + curr_row > 0 |
| 165 | + and score_matrix[curr_row][curr_col] |
| 166 | + == score_matrix[curr_row - 1][curr_col] + gap_score |
| 167 | + ): |
| 168 | + aligned_chars_first.append(sequence1[curr_row - 1]) |
| 169 | + aligned_chars_second.append("-") |
| 170 | + curr_row -= 1 |
| 171 | + else: |
| 172 | + # Horizontal step (gap in first sequence) |
| 173 | + aligned_chars_first.append("-") |
| 174 | + aligned_chars_second.append(sequence2[curr_col - 1]) |
| 175 | + curr_col -= 1 |
| 176 | + |
| 177 | + aligned_sequence1 = "".join(reversed(aligned_chars_first)) |
| 178 | + aligned_sequence2 = "".join(reversed(aligned_chars_second)) |
| 179 | + final_score = score_matrix[first_sequence_length][second_sequence_length] |
| 180 | + |
| 181 | + return aligned_sequence1, aligned_sequence2, final_score |
| 182 | + |
| 183 | + |
| 184 | +if __name__ == "__main__": |
| 185 | + import doctest |
| 186 | + |
| 187 | + doctest.testmod() |
0 commit comments