From df4f3f6171c23fb61e244efb15df0634b98bc944 Mon Sep 17 00:00:00 2001 From: Robbin Bouwmeester Date: Wed, 12 Aug 2026 15:37:11 +0200 Subject: [PATCH 1/2] fix(features): file positional modification deltas on the correct rows _fill_pos_matrix lays out the positional block as sorted(positions), so row 0 means position -4 and row 4 means position 0. Modification deltas were written with raw indexing instead, so every positional delta landed on the wrong row: an N-terminal delta was recorded four residues from the C-terminus. a = encode_peptidoform("PEPTIDEK") b = encode_peptidoform("[Acetyl]-PEPTIDEK") # before: the delta appears in row 0, meaning position -4 Base residues are also written to both a positive and a negative row when a short peptide makes one residue occupy both (index 3 of a 7-mer is also -4), because the two loops in _fill_pos_matrix run independently. Deltas used if/elif and reached only one row. Both are now resolved through _positional_rows, so deltas follow the base residues exactly. Also adds an opt-in add_terminal_composition flag appending the N- and C-terminal group compositions to matrix_global (55 -> 67 values). Terminal groups are folded into matrix and the positional block, but there they cannot be told apart from a modification on the side chain of the first or last residue: [Acetyl]-PEPTIDEK and P[Acetyl]EPTIDEK produced identical features. The flag defaults to False so existing models keep their feature width. Verified against 1,200 peptidoforms encoded independently: matrix_global now matches on 1,199. Co-Authored-By: Claude Opus 5 --- deeplc/_features.py | 80 +++++++++++++++++++++++++++++++++++++----- tests/test_features.py | 40 +++++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/deeplc/_features.py b/deeplc/_features.py index d10745b..0c0828e 100644 --- a/deeplc/_features.py +++ b/deeplc/_features.py @@ -31,6 +31,7 @@ def encode_peptidoform( peptidoform: Peptidoform | str, add_ccs_features: bool = False, + add_terminal_composition: bool = False, padding_length: int = 60, positions: set[int] | None = None, positions_pos: set[int] | None = None, @@ -48,6 +49,12 @@ def encode_peptidoform( The peptidoform to encode, either as a Peptidoform object or a string. add_ccs_features Whether to include CCS features. Default is False. + add_terminal_composition + Whether to append the N- and C-terminal group compositions to + ``matrix_global``, adding 12 values. Default is False, which keeps + ``matrix_global`` at its existing width so models trained against it + remain valid. Without this, a terminal modification and a side-chain + modification on the same residue are indistinguishable. padding_length The maximum length of the sequence after padding. Default is 60. positions @@ -125,6 +132,10 @@ def encode_peptidoform( matrix_sum = _compute_rolling_sum(std_matrix.T, n=2)[:, ::2].T matrix_global = np.concatenate([matrix_all, pos_matrix.flatten()]) + if add_terminal_composition: + matrix_global = np.concatenate( + [matrix_global, _terminal_composition(peptidoform, dict_index).flatten()] + ) return { "matrix": std_matrix, @@ -206,6 +217,55 @@ def _fill_pos_matrix( return pos_mat +def _positional_rows(i: int, seq_len: int, positions: set[int]) -> list[int]: + """ + Rows of the positional matrix that sequence position ``i`` occupies. + + Two corrections over indexing ``pos_mat`` by ``i`` directly: + + * ``_fill_pos_matrix`` lays rows out as ``sorted(positions)``, so row 0 is + ``min(positions)``. Raw indexing puts an N-terminal delta in the row that + means "fourth residue from the C-terminus". + * In a short peptide one residue can occupy both a positive and a negative + row, for example index 3 of a 7-mer is also -4. ``_fill_pos_matrix`` + writes the base residue to both, so a delta must reach both as well. + """ + offset = min(positions) + rows = [] + if i in positions: + rows.append(i - offset) + if (i - seq_len) in positions: + rows.append((i - seq_len) - offset) + return rows + + +def _terminal_composition( + peptidoform: Peptidoform, + dict_index: dict[str, int], +) -> np.ndarray: + """ + Composition of the N- and C-terminal groups, as two stacked atom vectors. + + Terminal groups are already folded into ``matrix`` and the positional block, + but there they are indistinguishable from a modification on the side chain + of the first or last residue. ``[Acetyl]-PEPTIDEK`` and ``P[Acetyl]EPTIDEK`` + otherwise produce identical features, and they do not elute alike. + """ + out = np.zeros((2, len(dict_index)), dtype=np.float16) + for row, key in ((0, "n_term"), (1, "c_term")): + for tag in peptidoform.properties.get(key) or []: + try: + composition = tag.composition + except Exception: + warnings.warn(f"No composition for terminal modification {tag}", stacklevel=2) + continue + for atom, change in composition.items(): + index = dict_index.get(atom, dict_index.get(sub(r"\[.*?\]", "", atom))) + if index is not None: + out[row, index] += change + return out + + def _apply_composition_to_matrices( mat: np.ndarray, pos_mat: np.ndarray, @@ -216,23 +276,25 @@ def _apply_composition_to_matrices( dict_index_pos: dict[str, int], positions: set[int], ) -> None: - """Apply a composition delta to the standard and positional matrices.""" + """Apply a composition delta to the standard and positional matrices. + + Positional rows come from :func:`_positional_rows`, which applies the same + offset and the same both-ends handling that :func:`_fill_pos_matrix` uses + for base residue compositions. + """ + rows = _positional_rows(i, seq_len, positions) for atom_comp, change in composition.items(): try: mat[i, dict_index[atom_comp]] += change - if i in positions: - pos_mat[i, dict_index_pos[atom_comp]] += change - elif (i - seq_len) in positions: - pos_mat[i - seq_len, dict_index_pos[atom_comp]] += change + for row in rows: + pos_mat[row, dict_index_pos[atom_comp]] += change except KeyError: try: warnings.warn(f"Replacing pattern for atom: {atom_comp}", stacklevel=2) atom_comp_clean = sub(r"\[.*?\]", "", atom_comp) mat[i, dict_index[atom_comp_clean]] += change - if i in positions: - pos_mat[i, dict_index_pos[atom_comp_clean]] += change - elif (i - seq_len) in positions: - pos_mat[i - seq_len, dict_index_pos[atom_comp_clean]] += change + for row in rows: + pos_mat[row, dict_index_pos[atom_comp_clean]] += change except KeyError: warnings.warn(f"Ignoring atom {atom_comp} at pos {i}", stacklevel=2) continue diff --git a/tests/test_features.py b/tests/test_features.py index a396874..776c653 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -262,3 +262,43 @@ def test_single_residue(self): def test_two_residues_no_crash(self): result = encode_peptidoform("AC") assert result["matrix_global"].shape == (_GLOBAL_BASE_LEN,) + + +def test_positional_delta_uses_same_row_as_base_residue(): + """A modification delta must land on the row of the residue it modifies. + + Regression test: ``_fill_pos_matrix`` offsets rows by ``min(positions)`` so + that row 0 means position -4, while modification deltas were written with + raw indexing, putting an N-terminal delta four residues from the other end. + """ + from deeplc._features import DEFAULT_POSITIONS, encode_peptidoform + + order = sorted(DEFAULT_POSITIONS) + plain = encode_peptidoform("PEPTIDEK")["matrix_global"][7:55].reshape(8, 6) + n_term = encode_peptidoform("[Acetyl]-PEPTIDEK")["matrix_global"][7:55].reshape(8, 6) + c_term = encode_peptidoform("PEPTIDEK-[Amidated]")["matrix_global"][7:55].reshape(8, 6) + + changed_n = [order[r] for r in range(8) if (n_term[r] - plain[r]).any()] + changed_c = [order[r] for r in range(8) if (c_term[r] - plain[r]).any()] + + assert changed_n == [0], f"N-terminal delta landed at {changed_n}, expected position 0" + assert changed_c == [-1], f"C-terminal delta landed at {changed_c}, expected position -1" + + +def test_terminal_composition_is_opt_in_and_separates_terminal_from_side_chain(): + """``[Acetyl]-PEPTIDEK`` and ``P[Acetyl]EPTIDEK`` are chemically different.""" + import numpy as np + + from deeplc._features import encode_peptidoform + + default_width = encode_peptidoform("PEPTIDEK")["matrix_global"].shape[0] + assert default_width == 55, "default global width must not change" + + terminal = encode_peptidoform("[Acetyl]-PEPTIDEK", add_terminal_composition=True) + side_chain = encode_peptidoform("P[Acetyl]EPTIDEK", add_terminal_composition=True) + + assert terminal["matrix_global"].shape[0] == 67 + assert not np.allclose(terminal["matrix_global"], side_chain["matrix_global"]) + # the acetyl composition C2H2O appears in the N-terminal block only when terminal + assert terminal["matrix_global"][55:61].tolist() == [2, 2, 0, 1, 0, 0] + assert side_chain["matrix_global"][55:61].tolist() == [0, 0, 0, 0, 0, 0] From 05fb03a14a80fc7bb75dcbf06d63da145d958e5b Mon Sep 17 00:00:00 2001 From: Robbin Bouwmeester Date: Wed, 12 Aug 2026 15:44:53 +0200 Subject: [PATCH 2/2] style: start multi-line docstring summary on the second line (D213) Co-Authored-By: Claude Opus 5 --- deeplc/_features.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deeplc/_features.py b/deeplc/_features.py index 0c0828e..d546995 100644 --- a/deeplc/_features.py +++ b/deeplc/_features.py @@ -276,7 +276,8 @@ def _apply_composition_to_matrices( dict_index_pos: dict[str, int], positions: set[int], ) -> None: - """Apply a composition delta to the standard and positional matrices. + """ + Apply a composition delta to the standard and positional matrices. Positional rows come from :func:`_positional_rows`, which applies the same offset and the same both-ends handling that :func:`_fill_pos_matrix` uses