agglovar 0.0.1.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agglovar/__init__.py +21 -0
- agglovar/align/__init__.py +9 -0
- agglovar/align/op.py +173 -0
- agglovar/align/score.py +495 -0
- agglovar/fa.py +109 -0
- agglovar/io.py +50 -0
- agglovar/join/__init__.py +13 -0
- agglovar/join/config/__init__.py +36 -0
- agglovar/join/config/parser.py +385 -0
- agglovar/join/config/stage.py +792 -0
- agglovar/join/config/strategy.py +244 -0
- agglovar/join/multipair.py +145 -0
- agglovar/join/om/__init__.py +16 -0
- agglovar/join/pair.py +1344 -0
- agglovar/kmer/__init__.py +12 -0
- agglovar/kmer/plot.py +355 -0
- agglovar/kmer/util.py +362 -0
- agglovar/schema.py +30 -0
- agglovar/seqmatch.py +328 -0
- agglovar-0.0.1.dev2.dist-info/METADATA +40 -0
- agglovar-0.0.1.dev2.dist-info/RECORD +24 -0
- agglovar-0.0.1.dev2.dist-info/WHEEL +5 -0
- agglovar-0.0.1.dev2.dist-info/licenses/LICENSE +7 -0
- agglovar-0.0.1.dev2.dist-info/top_level.txt +1 -0
agglovar/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Agglovar: A toolkit for fast genomic variant transformations and intersects."""
|
|
2
|
+
|
|
3
|
+
__version__ = '0.0.1.dev2'
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'align',
|
|
7
|
+
'fa',
|
|
8
|
+
'join',
|
|
9
|
+
'io',
|
|
10
|
+
'kmer',
|
|
11
|
+
'schema',
|
|
12
|
+
'seqmatch',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from . import align
|
|
16
|
+
from . import fa
|
|
17
|
+
from . import join
|
|
18
|
+
from . import io
|
|
19
|
+
from . import kmer
|
|
20
|
+
from . import schema
|
|
21
|
+
from . import seqmatch
|
agglovar/align/op.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Constants and functions for working with alignment operations.
|
|
2
|
+
|
|
3
|
+
Definitions in this subpackage are borrowed from
|
|
4
|
+
`PAV 3+ <https://github.com/BeckLaboratory/pav>`__.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
# Sets of valid characters/codes
|
|
9
|
+
'INT_STR_SET',
|
|
10
|
+
'CIGAR_OP_SET',
|
|
11
|
+
|
|
12
|
+
# CIGAR operation codes
|
|
13
|
+
'M',
|
|
14
|
+
'I',
|
|
15
|
+
'D',
|
|
16
|
+
'N',
|
|
17
|
+
'S',
|
|
18
|
+
'H',
|
|
19
|
+
'P',
|
|
20
|
+
'EQ',
|
|
21
|
+
'X',
|
|
22
|
+
|
|
23
|
+
# Operation code sets
|
|
24
|
+
'CLIP_SET',
|
|
25
|
+
'ALIGN_SET',
|
|
26
|
+
'EQX_SET',
|
|
27
|
+
|
|
28
|
+
# Mappings
|
|
29
|
+
'OP_CHAR',
|
|
30
|
+
'OP_CHAR_FUNC',
|
|
31
|
+
'OP_CODE',
|
|
32
|
+
|
|
33
|
+
# Arrays
|
|
34
|
+
'CONSUMES_QRY_ARR',
|
|
35
|
+
'CONSUMES_REF_ARR',
|
|
36
|
+
'ADV_REF_ARR',
|
|
37
|
+
'ADV_QRY_ARR',
|
|
38
|
+
'VAR_ARR',
|
|
39
|
+
|
|
40
|
+
# Functions
|
|
41
|
+
'cigar_to_arr',
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
from types import MappingProxyType
|
|
47
|
+
from typing import Mapping
|
|
48
|
+
|
|
49
|
+
INT_STR_SET: frozenset[str] = frozenset({'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', })
|
|
50
|
+
"""Set of valid integer character strings representing operation codes."""
|
|
51
|
+
|
|
52
|
+
CIGAR_OP_SET: frozenset[str] = frozenset({'M', 'I', 'D', 'N', 'S', 'H', 'P', '=', 'X', })
|
|
53
|
+
"""Set of valid operation characters."""
|
|
54
|
+
|
|
55
|
+
M: int = 0
|
|
56
|
+
"""Match or mismatch operation code."""
|
|
57
|
+
|
|
58
|
+
I: int = 1 # noqa: E741
|
|
59
|
+
"""Insertion operation code."""
|
|
60
|
+
|
|
61
|
+
D: int = 2
|
|
62
|
+
"""Deletion operation code."""
|
|
63
|
+
|
|
64
|
+
N: int = 3
|
|
65
|
+
"""Skipped region operation code."""
|
|
66
|
+
|
|
67
|
+
S: int = 4
|
|
68
|
+
"""Soft clipping operation code."""
|
|
69
|
+
|
|
70
|
+
H: int = 5
|
|
71
|
+
"""Hard clipping operation code."""
|
|
72
|
+
|
|
73
|
+
P: int = 6
|
|
74
|
+
"""Padding operation code."""
|
|
75
|
+
|
|
76
|
+
EQ: int = 7
|
|
77
|
+
"""Sequence match operation code."""
|
|
78
|
+
|
|
79
|
+
X: int = 8
|
|
80
|
+
"""Sequence mismatch operation code."""
|
|
81
|
+
|
|
82
|
+
CLIP_SET: frozenset[int] = frozenset({S, H, })
|
|
83
|
+
"""Set of clipping operation codes (soft and hard clipping)."""
|
|
84
|
+
|
|
85
|
+
ALIGN_SET: frozenset[int] = frozenset({M, EQ, X, })
|
|
86
|
+
"""Set of alignment operation codes (match, sequence match, mismatch)."""
|
|
87
|
+
|
|
88
|
+
EQX_SET: frozenset[int] = frozenset({EQ, X, })
|
|
89
|
+
"""Set of exact match/mismatch operation codes."""
|
|
90
|
+
|
|
91
|
+
OP_CHAR: Mapping[int, str] = MappingProxyType({
|
|
92
|
+
M: 'M',
|
|
93
|
+
I: 'I',
|
|
94
|
+
D: 'D',
|
|
95
|
+
N: 'N',
|
|
96
|
+
S: 'S',
|
|
97
|
+
H: 'H',
|
|
98
|
+
P: 'P',
|
|
99
|
+
EQ: '=',
|
|
100
|
+
X: 'X'
|
|
101
|
+
})
|
|
102
|
+
"""Mapping from operation codes to their character representations."""
|
|
103
|
+
|
|
104
|
+
OP_CHAR_FUNC = np.vectorize(lambda val: OP_CHAR.get(val, '?'))
|
|
105
|
+
"""Vectorized function to convert operation codes to characters."""
|
|
106
|
+
|
|
107
|
+
OP_CODE: Mapping[str, int] = MappingProxyType({
|
|
108
|
+
'M': M,
|
|
109
|
+
'I': I,
|
|
110
|
+
'D': D,
|
|
111
|
+
'N': N,
|
|
112
|
+
'S': S,
|
|
113
|
+
'H': H,
|
|
114
|
+
'P': P,
|
|
115
|
+
'=': EQ,
|
|
116
|
+
'X': X
|
|
117
|
+
})
|
|
118
|
+
"""Mapping from CIGAR characters to operation codes."""
|
|
119
|
+
|
|
120
|
+
CONSUMES_QRY_ARR: np.typing.NDArray[np.integer] = np.array([M, I, S, EQ, X])
|
|
121
|
+
"""Array of operation codes that consume query bases."""
|
|
122
|
+
|
|
123
|
+
CONSUMES_REF_ARR: np.typing.NDArray[np.integer] = np.array([M, D, N, EQ, X])
|
|
124
|
+
"""Array of operation codes that consume reference bases."""
|
|
125
|
+
|
|
126
|
+
ADV_REF_ARR: np.typing.NDArray[np.integer] = np.array([M, EQ, X, D])
|
|
127
|
+
"""Array of operation codes that advance the reference position."""
|
|
128
|
+
|
|
129
|
+
ADV_QRY_ARR: np.typing.NDArray[np.integer] = np.array([M, EQ, X, I, S, H])
|
|
130
|
+
"""Array of operation codes that advance the query position."""
|
|
131
|
+
|
|
132
|
+
VAR_ARR: np.typing.NDArray[np.integer] = np.array([X, I, D])
|
|
133
|
+
"""Array of operation codes that introduce variation."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cigar_to_arr(
|
|
137
|
+
cigar_str: str
|
|
138
|
+
) -> np.ndarray:
|
|
139
|
+
"""Get a numpy array with two dimensions (dtype int).
|
|
140
|
+
|
|
141
|
+
The first column is the operation codes, the second column is the operation lengths.
|
|
142
|
+
|
|
143
|
+
:param cigar_str: CIGAR string.
|
|
144
|
+
|
|
145
|
+
:returns: Array of operation codes and lengths (dtype int).
|
|
146
|
+
|
|
147
|
+
:raises ValueError: If the CIGAR string is not properly formatted.
|
|
148
|
+
"""
|
|
149
|
+
pos = 0
|
|
150
|
+
max_pos = len(cigar_str)
|
|
151
|
+
|
|
152
|
+
op_tuples = list()
|
|
153
|
+
|
|
154
|
+
while pos < max_pos:
|
|
155
|
+
|
|
156
|
+
len_pos = pos
|
|
157
|
+
|
|
158
|
+
while cigar_str[len_pos] in INT_STR_SET:
|
|
159
|
+
len_pos += 1
|
|
160
|
+
|
|
161
|
+
if len_pos == pos:
|
|
162
|
+
raise ValueError(f'Missing length in CIGAR string at index {pos}')
|
|
163
|
+
|
|
164
|
+
if cigar_str[len_pos] not in CIGAR_OP_SET:
|
|
165
|
+
raise ValueError(f'Unknown CIGAR operation {cigar_str[len_pos]}')
|
|
166
|
+
|
|
167
|
+
op_tuples.append(
|
|
168
|
+
(OP_CODE[cigar_str[len_pos]], int(cigar_str[pos:len_pos]))
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
pos = len_pos + 1
|
|
172
|
+
|
|
173
|
+
return np.array(op_tuples)
|
agglovar/align/score.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""Alignment routines."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
'AFFINE_SCORE_MATCH',
|
|
5
|
+
'AFFINE_SCORE_MISMATCH',
|
|
6
|
+
'AFFINE_SCORE_GAP',
|
|
7
|
+
'AFFINE_SCORE_TS',
|
|
8
|
+
'DEFAULT_ALIGN_SCORE_MODEL',
|
|
9
|
+
'ScoreModel',
|
|
10
|
+
'AffineScoreModel',
|
|
11
|
+
'get_score_model',
|
|
12
|
+
'get_affine_by_params',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
import numpy as np
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Iterable, Self
|
|
19
|
+
|
|
20
|
+
from . import op
|
|
21
|
+
|
|
22
|
+
AFFINE_SCORE_MATCH = 2.0
|
|
23
|
+
"""Default match score."""
|
|
24
|
+
|
|
25
|
+
AFFINE_SCORE_MISMATCH = 4.0
|
|
26
|
+
"""Default mismatch score."""
|
|
27
|
+
|
|
28
|
+
AFFINE_SCORE_GAP = ((4.0, 2.0), (24.0, 1.0))
|
|
29
|
+
"""Default affine gap scores."""
|
|
30
|
+
|
|
31
|
+
AFFINE_SCORE_TS = None
|
|
32
|
+
"""Default template switch score."""
|
|
33
|
+
|
|
34
|
+
DEFAULT_ALIGN_SCORE_MODEL = (
|
|
35
|
+
f'affine::match={AFFINE_SCORE_MATCH},'
|
|
36
|
+
f'mismatch={AFFINE_SCORE_MISMATCH},'
|
|
37
|
+
f'gap={";".join([f"{gap_open}:{gap_extend}" for gap_open, gap_extend in AFFINE_SCORE_GAP])}'
|
|
38
|
+
)
|
|
39
|
+
"""Default alignment score model."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ScoreModel(ABC):
|
|
43
|
+
"""Score model interface."""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def match(
|
|
47
|
+
self,
|
|
48
|
+
n: int = 1
|
|
49
|
+
) -> float:
|
|
50
|
+
"""Score match.
|
|
51
|
+
|
|
52
|
+
:param n: Number of matching bases.
|
|
53
|
+
|
|
54
|
+
:returns: Match score.
|
|
55
|
+
"""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def mismatch(
|
|
60
|
+
self,
|
|
61
|
+
n: int = 1
|
|
62
|
+
) -> float:
|
|
63
|
+
"""Score mismatch.
|
|
64
|
+
|
|
65
|
+
:param n: Number of mismatched bases.
|
|
66
|
+
|
|
67
|
+
:returns: Mismatch score.
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def gap(
|
|
73
|
+
self,
|
|
74
|
+
n: int = 1
|
|
75
|
+
) -> float:
|
|
76
|
+
"""Score gap (insertion or deletion).
|
|
77
|
+
|
|
78
|
+
Compute all affine alignment gap score for each affine
|
|
79
|
+
segment and return the highest value (least negative).
|
|
80
|
+
|
|
81
|
+
:param n: Size of gap.
|
|
82
|
+
|
|
83
|
+
:returns: Gap score.
|
|
84
|
+
""" # noqa: D402
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def template_switch(self) -> float:
|
|
88
|
+
"""Score a template switch.
|
|
89
|
+
|
|
90
|
+
:returns: Score for a single template switch.
|
|
91
|
+
"""
|
|
92
|
+
return 2 * self.gap(50)
|
|
93
|
+
|
|
94
|
+
def score(
|
|
95
|
+
self,
|
|
96
|
+
op_code: int | str,
|
|
97
|
+
op_len: int,
|
|
98
|
+
) -> float:
|
|
99
|
+
"""Score an alignment operation.
|
|
100
|
+
|
|
101
|
+
:param op_code: Operation code. Can be a symbol (e.g. "=", "X", "I", etc.) or the numeric operation code.
|
|
102
|
+
:param op_len: Operation length.
|
|
103
|
+
|
|
104
|
+
:returns: Score for this operation or 0.0 if the operation is not scored (S, H, N, and P operations).
|
|
105
|
+
"""
|
|
106
|
+
if op_code in {'=', op.EQ}:
|
|
107
|
+
return self.match(op_len)
|
|
108
|
+
|
|
109
|
+
elif op_code in {'X', op.X}:
|
|
110
|
+
return self.mismatch(op_len)
|
|
111
|
+
|
|
112
|
+
elif op_code in {'I', 'D', op.I, op.D}:
|
|
113
|
+
return self.gap(op_len)
|
|
114
|
+
|
|
115
|
+
elif op_code in {'S', 'H', op.S, op.H}:
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
elif op_code in {'M', op.M}:
|
|
119
|
+
raise RuntimeError('Cannot score alignments with match ("M") in CIGAR string (requires "=" and "X")')
|
|
120
|
+
|
|
121
|
+
elif op_code in {'N', 'P', op.N, op.P}:
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
else:
|
|
125
|
+
raise RuntimeError(f'Unrecognized CIGAR op code: {op_code}')
|
|
126
|
+
|
|
127
|
+
def score_operations(
|
|
128
|
+
self,
|
|
129
|
+
op_arr: np.ndarray,
|
|
130
|
+
) -> float:
|
|
131
|
+
"""A vectorized implementation of summing scores for affine models.
|
|
132
|
+
|
|
133
|
+
:param op_arr: Array of alignment operations (op_code: first column, op_len: second column).
|
|
134
|
+
|
|
135
|
+
:returns: Sum of scores for each CIGAR operation.
|
|
136
|
+
"""
|
|
137
|
+
return np.sum(np.vectorize(self.score)(op_arr[:, 0], op_arr[:, 1]))
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def mismatch_model(self) -> Self:
|
|
141
|
+
"""Get a mismatch model.
|
|
142
|
+
|
|
143
|
+
:returns: A copy of this score model that does not penalize gaps. Used for computing the score of mismatches.
|
|
144
|
+
"""
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
@abstractmethod
|
|
148
|
+
def model_param_string(self) -> str:
|
|
149
|
+
"""Get score parameter string.
|
|
150
|
+
|
|
151
|
+
:returns: A parameter string representing this score model.
|
|
152
|
+
"""
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def __eq__(
|
|
157
|
+
self,
|
|
158
|
+
other: Any
|
|
159
|
+
) -> bool:
|
|
160
|
+
"""Determine if this scoremodel is the same as another.
|
|
161
|
+
|
|
162
|
+
:param other: Other object.
|
|
163
|
+
|
|
164
|
+
:returns: True if this scoremodel is equivalent to `other`.
|
|
165
|
+
"""
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
@abstractmethod
|
|
169
|
+
def __repr__(self):
|
|
170
|
+
"""Get a string representation of this score model."""
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class AffineScoreModel(ScoreModel):
|
|
175
|
+
"""Affine score model with default values modeled on minimap2 (2.26) default parameters.
|
|
176
|
+
|
|
177
|
+
:ivar match: Match score.
|
|
178
|
+
:ivar mismatch: Mismatch penalty.
|
|
179
|
+
:ivar affine_gap: An iterable containing tuples of two elements (gap open, gap extend).
|
|
180
|
+
:ivar template_switch: Template switch penalty. If none, defaults to 2x the penalty of a 50 bp gap.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
score_match: float
|
|
184
|
+
score_mismatch: float
|
|
185
|
+
score_affine_gap: tuple
|
|
186
|
+
score_template_switch: float
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
match: float = AFFINE_SCORE_MATCH,
|
|
191
|
+
mismatch: float = AFFINE_SCORE_MISMATCH,
|
|
192
|
+
affine_gap: Iterable[tuple[float, float]] = AFFINE_SCORE_GAP,
|
|
193
|
+
template_switch: float = AFFINE_SCORE_TS
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Initialize an affine score model.
|
|
196
|
+
|
|
197
|
+
:param match: Match score.
|
|
198
|
+
:param mismatch: Mismatch score.
|
|
199
|
+
:param affine_gap: Iterable of tuples (gap_open, gap_extend) for affine gap penalties.
|
|
200
|
+
:param template_switch: Template switch score.
|
|
201
|
+
"""
|
|
202
|
+
self.score_match = abs(float(match))
|
|
203
|
+
self.score_mismatch = -abs(float(mismatch))
|
|
204
|
+
self.score_affine_gap = tuple((
|
|
205
|
+
(-abs(float(gap_open)), -abs(float(gap_extend)))
|
|
206
|
+
for gap_open, gap_extend in affine_gap
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
if template_switch is None:
|
|
210
|
+
self.score_template_switch = 2 * self.gap(50)
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
self.score_template_switch = - np.abs(float(template_switch))
|
|
214
|
+
except ValueError as e:
|
|
215
|
+
raise ValueError(f'template_switch parameter is not numeric: {template_switch}') from e
|
|
216
|
+
|
|
217
|
+
def match(
|
|
218
|
+
self,
|
|
219
|
+
n: int = 1
|
|
220
|
+
) -> float:
|
|
221
|
+
"""Score match.
|
|
222
|
+
|
|
223
|
+
:param n: Number of matching bases.
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
:returns: Match score.
|
|
227
|
+
"""
|
|
228
|
+
return self.score_match * n
|
|
229
|
+
|
|
230
|
+
def mismatch(
|
|
231
|
+
self,
|
|
232
|
+
n: int = 1
|
|
233
|
+
) -> float:
|
|
234
|
+
"""Score mismatch.
|
|
235
|
+
|
|
236
|
+
:param n: Number of mismatched bases.
|
|
237
|
+
|
|
238
|
+
:returns: Mismatch score.
|
|
239
|
+
"""
|
|
240
|
+
return self.score_mismatch * n
|
|
241
|
+
|
|
242
|
+
def gap(
|
|
243
|
+
self,
|
|
244
|
+
n: int = 1
|
|
245
|
+
) -> float:
|
|
246
|
+
"""Score gap (insertion or deletion).
|
|
247
|
+
|
|
248
|
+
Compute all affine alignment gap score for each affine
|
|
249
|
+
segment and return the highest value (least negative).
|
|
250
|
+
|
|
251
|
+
:param n: Size of gap.
|
|
252
|
+
|
|
253
|
+
:returns: Gap score.
|
|
254
|
+
""" # noqa: D402
|
|
255
|
+
if n == 0.0:
|
|
256
|
+
return 0.0
|
|
257
|
+
|
|
258
|
+
return np.max(
|
|
259
|
+
[
|
|
260
|
+
gap_open + (gap_extend * n)
|
|
261
|
+
for gap_open, gap_extend in self.score_affine_gap
|
|
262
|
+
]
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def template_switch(self) -> float:
|
|
266
|
+
"""Score a template switch.
|
|
267
|
+
|
|
268
|
+
:returns: Score for a single template switch.
|
|
269
|
+
"""
|
|
270
|
+
return self.score_template_switch
|
|
271
|
+
|
|
272
|
+
def mismatch_model(self) -> Self:
|
|
273
|
+
"""Get a mismatch model.
|
|
274
|
+
|
|
275
|
+
:returns: A copy of this score model that does not penalize gaps. Used for computing the score of mismatches.
|
|
276
|
+
"""
|
|
277
|
+
return AffineScoreModel(
|
|
278
|
+
match=self.score_match,
|
|
279
|
+
mismatch=self.score_mismatch,
|
|
280
|
+
affine_gap=[(0.0, 0.0)],
|
|
281
|
+
template_switch=self.score_template_switch
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def score_operations(
|
|
285
|
+
self,
|
|
286
|
+
op_arr: np.ndarray,
|
|
287
|
+
) -> float:
|
|
288
|
+
"""A vectorized implementation of summing scores for affine models.
|
|
289
|
+
|
|
290
|
+
:param op_arr: Array of alignment operations (op_code: first column, op_len: second column).
|
|
291
|
+
|
|
292
|
+
:returns: Sum of scores for each CIGAR operation.
|
|
293
|
+
"""
|
|
294
|
+
if np.any(op_arr[:, 0] == op.M):
|
|
295
|
+
raise RuntimeError('Cannot score alignments with match ("M") in CIGAR string (requires "=" and "X")')
|
|
296
|
+
|
|
297
|
+
# Score gaps
|
|
298
|
+
gap_arr = op_arr[(op_arr[:, 0] == op.D) | (op_arr[:, 0] == op.I), 1]
|
|
299
|
+
|
|
300
|
+
gap_score = np.full((gap_arr.shape[0], 2), -np.inf)
|
|
301
|
+
|
|
302
|
+
for gap_open, gap_extend in self.score_affine_gap:
|
|
303
|
+
gap_score[:, 1] = gap_open + gap_arr * gap_extend
|
|
304
|
+
gap_score[:, 0] = np.max(gap_score, axis=1)
|
|
305
|
+
|
|
306
|
+
return float(
|
|
307
|
+
np.sum(op_arr[:, 1] * (op_arr[:, 0] == op.EQ) * self.score_match)
|
|
308
|
+
+ np.sum(op_arr[:, 1] * (op_arr[:, 0] == op.X) * self.score_mismatch)
|
|
309
|
+
# If no gap penalties (i.e. mismatch model), then gap_score is -inf (set to 0.0)
|
|
310
|
+
+ np.nan_to_num(gap_score[:, 0], neginf=0.0).sum()
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def model_param_string(self) -> str:
|
|
314
|
+
"""Get score parameter string.
|
|
315
|
+
|
|
316
|
+
:returns: A parameter string representing this score model.
|
|
317
|
+
"""
|
|
318
|
+
return (
|
|
319
|
+
f'affine::match={self.score_match},'
|
|
320
|
+
f'mismatch={self.score_mismatch},'
|
|
321
|
+
f'gap={";".join([f"{gap_open}:{gap_extend}" for gap_open, gap_extend in self.score_affine_gap])}'
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def __eq__(
|
|
325
|
+
self,
|
|
326
|
+
other: Any
|
|
327
|
+
) -> bool:
|
|
328
|
+
"""Determine if this scoremodel is the same as another.
|
|
329
|
+
|
|
330
|
+
:param other: Other object.
|
|
331
|
+
|
|
332
|
+
:returns: True if this scoremodel is equivalent to `other`.
|
|
333
|
+
"""
|
|
334
|
+
if other is None or not isinstance(other, self.__class__):
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
self.score_match == other.score_match
|
|
339
|
+
and self.score_mismatch == other.score_mismatch
|
|
340
|
+
and self.score_affine_gap == other.score_affine_gap
|
|
341
|
+
and self.score_template_switch == other.score_template_switch
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def __repr__(self):
|
|
345
|
+
"""Get a string representation of this score model."""
|
|
346
|
+
gap_str = ';'.join([f'{abs(gap_open)}:{abs(gap_extend)}' for gap_open, gap_extend in self.score_affine_gap])
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
f'AffineScoreModel('
|
|
350
|
+
f'match={self.score_match},'
|
|
351
|
+
f'mismatch={-self.score_mismatch},'
|
|
352
|
+
f'gap={gap_str},'
|
|
353
|
+
f'ts={-self.score_template_switch}'
|
|
354
|
+
f')'
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_score_model(
|
|
359
|
+
param_string: str = None
|
|
360
|
+
) -> ScoreModel:
|
|
361
|
+
"""Get score model from a string of alignment parameters.
|
|
362
|
+
|
|
363
|
+
:param param_string: Parameter string. Can be None or an empty string (default model is used). If
|
|
364
|
+
the string is an instance of `ScoreModel`, then the `ScoreModel` object is returned.
|
|
365
|
+
Otherwise, the string is parsed and a score model object is returned.
|
|
366
|
+
|
|
367
|
+
:returns: A `ScoreModel` object.
|
|
368
|
+
"""
|
|
369
|
+
if isinstance(param_string, ScoreModel):
|
|
370
|
+
return param_string
|
|
371
|
+
|
|
372
|
+
if param_string is not None:
|
|
373
|
+
param_string = param_string.strip()
|
|
374
|
+
|
|
375
|
+
if param_string is None or len(param_string) == 0:
|
|
376
|
+
param_string = DEFAULT_ALIGN_SCORE_MODEL
|
|
377
|
+
|
|
378
|
+
if '::' in param_string:
|
|
379
|
+
model_type, model_params = re.split(r'::', param_string, maxsplit=1)
|
|
380
|
+
|
|
381
|
+
else:
|
|
382
|
+
model_type, model_params = 'affine', param_string
|
|
383
|
+
|
|
384
|
+
if model_type == 'affine':
|
|
385
|
+
return get_affine_by_params(model_params)
|
|
386
|
+
|
|
387
|
+
raise RuntimeError(f'Unrecognized score model type: {model_type}')
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def get_affine_by_params(
|
|
391
|
+
param_string: str
|
|
392
|
+
) -> AffineScoreModel:
|
|
393
|
+
"""Parse a string to get alignment parameters from it.
|
|
394
|
+
|
|
395
|
+
:param param_string: Parameter string.
|
|
396
|
+
|
|
397
|
+
:returns: A configured AffineScoreModel.
|
|
398
|
+
"""
|
|
399
|
+
# Set defaults
|
|
400
|
+
params = [
|
|
401
|
+
AFFINE_SCORE_MATCH,
|
|
402
|
+
AFFINE_SCORE_MISMATCH,
|
|
403
|
+
AFFINE_SCORE_GAP,
|
|
404
|
+
AFFINE_SCORE_TS
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
keys = ['match', 'mismatch', 'gap', 'ts']
|
|
408
|
+
|
|
409
|
+
# Sanitize parameter string
|
|
410
|
+
if param_string is not None:
|
|
411
|
+
param_string = param_string.strip()
|
|
412
|
+
|
|
413
|
+
if len(param_string) == 0:
|
|
414
|
+
param_string = None
|
|
415
|
+
|
|
416
|
+
if param_string is None:
|
|
417
|
+
param_string = DEFAULT_ALIGN_SCORE_MODEL
|
|
418
|
+
|
|
419
|
+
# Parse param string
|
|
420
|
+
param_pos = 0
|
|
421
|
+
|
|
422
|
+
for tok in param_string.split(','):
|
|
423
|
+
tok = tok.strip()
|
|
424
|
+
|
|
425
|
+
if len(tok) == 0:
|
|
426
|
+
param_pos += 1
|
|
427
|
+
continue # Accept default for missing
|
|
428
|
+
|
|
429
|
+
if '=' in tok:
|
|
430
|
+
param_pos = None # Do not allow positional parameters after named ones
|
|
431
|
+
|
|
432
|
+
key, val = tok.split('=')
|
|
433
|
+
|
|
434
|
+
key = key.strip()
|
|
435
|
+
val = val.strip()
|
|
436
|
+
|
|
437
|
+
else:
|
|
438
|
+
if param_pos is None:
|
|
439
|
+
raise RuntimeError(
|
|
440
|
+
f'Named parameters (with "=") must be specified after positional parameters (no "="): '
|
|
441
|
+
f'{param_string}'
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
key = keys[param_pos]
|
|
445
|
+
val = tok
|
|
446
|
+
|
|
447
|
+
param_pos += 1
|
|
448
|
+
|
|
449
|
+
if key in {'match', 'mismatch', 'ts'}:
|
|
450
|
+
try:
|
|
451
|
+
val = abs(float(val))
|
|
452
|
+
except ValueError:
|
|
453
|
+
raise RuntimeError(f'Unrecognized alignment parameter: {key} (allowed: match, mismatch, gap, ts)')
|
|
454
|
+
|
|
455
|
+
if key == 'match':
|
|
456
|
+
params[0] = val
|
|
457
|
+
|
|
458
|
+
elif key == 'mismatch':
|
|
459
|
+
params[1] = val
|
|
460
|
+
|
|
461
|
+
elif key == 'gap':
|
|
462
|
+
|
|
463
|
+
gap_list = list()
|
|
464
|
+
|
|
465
|
+
for gap_pair in val.split(';'):
|
|
466
|
+
gap_tok = gap_pair.split(':')
|
|
467
|
+
|
|
468
|
+
if len(gap_tok) != 2:
|
|
469
|
+
raise RuntimeError(
|
|
470
|
+
f'Unrecognized gap format: Expected "open:extend" for each element '
|
|
471
|
+
f'(multiple pairs separated by ";"): "{gap_pair}" in {val}'
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
gap_open = abs(float(gap_tok[0].strip()))
|
|
476
|
+
gap_extend = abs(float(gap_tok[1].strip()))
|
|
477
|
+
|
|
478
|
+
except ValueError:
|
|
479
|
+
raise RuntimeError(
|
|
480
|
+
f'Unrecognized gap format: Expected integer values for "open:extend" '
|
|
481
|
+
f'for each gap cost element: {val}'
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
gap_list.append((gap_open, gap_extend))
|
|
485
|
+
|
|
486
|
+
params[2] = tuple(gap_list)
|
|
487
|
+
|
|
488
|
+
elif key == 'ts':
|
|
489
|
+
params[3] = val
|
|
490
|
+
|
|
491
|
+
else:
|
|
492
|
+
raise RuntimeError(f'Unrecognized alignment parameter: {key} (allowed: match, mismatch, gap, ts)')
|
|
493
|
+
|
|
494
|
+
# Return alignment object
|
|
495
|
+
return AffineScoreModel(*params)
|