Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions deeplc/_architecture.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,10 @@ class FlexCNNMultitaskModel(nn.Module):
#: Index reserved for padding positions in the residue encoding.
PAD_INDEX = 20

#: The fused trunk reads the per-position matrix directly, so the rolling-sum array is
#: redundant and its ``forward`` deletes it. Encoding can skip building it.
uses_rolling_sum = False

def __init__(
self,
n_tasks: int,
Expand Down Expand Up @@ -934,6 +938,33 @@ def forward(
del x_atom_sum # the fused trunk reads x_atom directly
return self.head(self.encoder(x_atom, x_global, x_one_hot), task_idx)

@property
def padding_reach(self) -> int | None:
"""
How far a valid position can see across the right edge of the encoding window.

The trunk masks its output by the true residue count and pools over that mask, and
the features themselves do not depend on the window, so the only way padding can
reach a valid position is through the convolutions. Each convolution of width ``k``
and dilation ``d`` reaches ``(k - 1) // 2 * d`` positions, and the reaches add up.
A batch encoded in a window of its longest peptide plus this many positions
therefore predicts exactly what the full window predicts, which for a median
peptide of sixteen residues is a fraction of the sixty positions used otherwise.

Returns None when the trunk pools or strides across positions, because then the
mask no longer lines up position by position and the argument does not hold.

"""
reach = 0
for module in self.encoder.modules():
if isinstance(module, (nn.MaxPool1d, nn.AvgPool1d)):
return None
if isinstance(module, nn.Conv1d):
if module.stride[0] != 1:
return None
reach += ((module.kernel_size[0] - 1) // 2) * module.dilation[0]
return reach

def add_task_head(
self, targets: torch.Tensor | None = None, init_from: int | None = None
) -> int:
Expand Down
14 changes: 13 additions & 1 deletion deeplc/_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def encode_peptidoform(
dict_index_pos: dict[str, int] | None = None,
dict_index: dict[str, int] | None = None,
legacy_positional_deltas: bool = False,
include_rolling_sum: bool = True,
) -> dict[str, np.ndarray]:
"""
Extract features from a single peptidoform.
Expand All @@ -58,6 +59,10 @@ def encode_peptidoform(
modification on the same residue are indistinguishable.
padding_length
The maximum length of the sequence after padding. Default is 60.
include_rolling_sum
Whether to build ``matrix_sum``, the rolling sum over pairs of positions. Models
with a convolutional trunk ignore it; False returns an empty array in its place.
Default is True.
legacy_positional_deltas
Whether to place modification deltas in the positional block the way versions
before 4.0.1 did, which was to index ``pos_mat`` without the sorted-layout
Expand Down Expand Up @@ -139,7 +144,14 @@ def encode_peptidoform(
matrix_all = np.append(matrix_all, (seq.count("K") + seq.count("R")) / seq_len)
matrix_all = np.append(matrix_all, charge)

matrix_sum = _compute_rolling_sum(std_matrix.T, n=2)[:, ::2].T
# The fused trunk reads the per-position matrix directly and deletes this one on the
# first line of its forward, so building it is pure cost for those models: the cumulative
# sum and its slicing are about a tenth of the encoding work.
matrix_sum = (
_compute_rolling_sum(std_matrix.T, n=2)[:, ::2].T
if include_rolling_sum
else np.zeros((0, len(dict_index)), dtype=np.float16)
)

matrix_global = np.concatenate([matrix_all, pos_matrix.flatten()])
if add_terminal_composition:
Expand Down
152 changes: 143 additions & 9 deletions deeplc/_model_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import copy
import inspect
import logging
from collections.abc import Callable, Sequence
from collections.abc import Callable, Iterator, Sequence
from os import PathLike
from pathlib import Path

import numpy as np
import torch
from rich.progress import (
BarColumn,
Expand Down Expand Up @@ -250,8 +251,16 @@ def predict(
num_threads: int | None = None,
show_progress: bool = True,
task_idx: Sequence[int] | None = None,
length_buckets: bool = True,
) -> torch.Tensor:
"""Predict using the model for the given dataset."""
"""
Predict using the model for the given dataset.

``length_buckets`` runs length-sorted chunks in a window that fits them rather than
padding every peptide to the model's full window; see :func:`_length_buckets`. It is
exact for models that report a ``padding_reach`` and ignored for those that do not.
Set it to False to force one pass over the data in the dataset's own window.
"""
# ``task_idx`` selects which LC setups a multitask model evaluates. Without
# it a model trained on thousands of setups returns a column per setup: at
# 6,543 setups and a million peptides that output alone is tens of gigabytes,
Expand All @@ -260,11 +269,111 @@ def predict(
torch.set_num_threads(num_threads or torch.get_num_threads())
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
model = load_model(model, device)
data_loader = DataLoader(data, batch_size=batch_size, shuffle=False, num_workers=num_workers)
predictions = _predict_epoch(
model, data_loader, device, show_progress=show_progress, task_idx=task_idx

buckets = _length_buckets(model, data, batch_size) if length_buckets else None
if buckets is None:
predictions = _predict_epoch(
model,
data,
device,
batch_size=batch_size,
num_workers=num_workers,
show_progress=show_progress,
task_idx=task_idx,
)
return predictions.cpu().detach()

out: torch.Tensor | None = None
for indices, subset in buckets:
part = _predict_epoch(
model,
subset,
device,
batch_size=batch_size,
num_workers=num_workers,
show_progress=show_progress,
task_idx=task_idx,
).cpu()
if out is None:
out = torch.empty((len(data), part.shape[1]), dtype=part.dtype)
out[indices] = part
if out is None:
raise ValueError("Dataset is empty — nothing to predict.")
return out.detach()


#: Widest spread of peptide lengths allowed inside one prediction chunk. Small enough that
#: no peptide carries much padding, large enough that the dense middle of a length
#: distribution still fills a batch.
#: A tight window is worth more than a full batch: enforcing a minimum chunk size, so that the
#: sparse long tail rides along in a wider window, was measured slower (1,546 against 1,503
#: peptidoforms/s at a floor of 512 and 1,449 at 2,048).
_LENGTH_BAND = 4


def _residue_count(peptidoform: object) -> int:
"""
Residues in a peptidoform, whether it arrives parsed or as a ProForma string.

A dataset may hold either. The string form cannot be counted by its length, since
modifications and the charge state are part of it, so it is parsed once here rather
than per encoded item.
"""
sequence = getattr(peptidoform, "sequence", None)
if sequence is None:
from psm_utils import Peptidoform

sequence = Peptidoform(str(peptidoform)).sequence
return len(sequence)


def _length_buckets(
model: torch.nn.Module, data: Dataset, batch_size: int
) -> list[tuple[torch.Tensor, Dataset]] | None:
"""
Split the dataset into length-sorted chunks, each encoded in a window that fits it.

Padding every peptide to the model's full window makes the convolutions work on
padding: at a 60-position window and a median peptide of 16 residues most of the
trunk's arithmetic is spent on positions that are masked out again before pooling.
Sorting by length and giving each chunk a window of its own longest peptide plus the
trunk's reach is exact - it was measured identical to the full window over 50,000
peptides - and about three times faster on CPU.

Returns None when the model does not report a reach, when the data is not a
DeepLCDataset, or when there is nothing to gain, so the caller falls back to one pass.
"""
reach = getattr(model, "padding_reach", None)
if reach is None or not isinstance(data, DeepLCDataset) or len(data) == 0:
return None

lengths = np.fromiter(
(_residue_count(p) for p in data.peptidoforms), dtype=np.int64, count=len(data)
)
return predictions.cpu().detach()
window = data.padding_length
order = np.argsort(lengths, kind="stable")
sorted_lengths = lengths[order]

# A chunk is cut either at the batch size or as soon as its longest peptide would exceed
# the shortest by more than _LENGTH_BAND, so no peptide is padded much beyond its own
# length. Fixed-size chunks are not enough: the longest chunk of a length-sorted set holds
# thousands of ordinary peptides alongside the few long ones and inherits their window,
# which on a 20,000-peptide set cost 43 % of the throughput.
buckets: list[tuple[torch.Tensor, Dataset]] = []
start = 0
while start < len(order):
stop = min(start + batch_size, len(order))
band = sorted_lengths[start] + _LENGTH_BAND
within = int(np.searchsorted(sorted_lengths[start:stop], band, side="right"))
stop = start + max(within, 1)
padding = int(min(window, sorted_lengths[stop - 1] + reach))
chunk = order[start:stop]
buckets.append((torch.as_tensor(chunk), data.variant(chunk.tolist(), padding)))
start = stop

if len(buckets) == 1 and buckets[0][1].padding_length >= window:
return None # one chunk at the full window is what the plain path already does
return buckets


def supports_task_subset(model: torch.nn.Module) -> bool:
Expand Down Expand Up @@ -340,10 +449,30 @@ def _validate_epoch(
return float(val_loss / len(data_loader))


def _feature_batches(data: Dataset, batch_size: int, num_workers: int) -> Iterator[list]:
"""
Yield feature batches, assembled by the dataset itself where it can be.

A DeepLCDataset encodes a whole batch into one buffer per feature, which skips the
per-peptide tensors and the collate step a DataLoader needs. Worker processes and other
dataset types keep the DataLoader.
"""
if isinstance(data, DeepLCDataset) and num_workers == 0:
for start in range(0, len(data), batch_size):
stop = min(start + batch_size, len(data))
yield list(data.encode_batch(range(start, stop)))
else:
loader = DataLoader(data, batch_size=batch_size, shuffle=False, num_workers=num_workers)
for features, _ in loader:
yield list(features)


def _predict_epoch(
model: torch.nn.Module,
data_loader: DataLoader,
data: Dataset,
device: str,
batch_size: int = 512,
num_workers: int = 0,
show_progress: bool = False,
task_idx: Sequence[int] | None = None,
) -> torch.Tensor:
Expand All @@ -353,9 +482,14 @@ def _predict_epoch(
if task_idx is not None and supports_task_subset(model):
selected = torch.as_tensor(list(task_idx), dtype=torch.long, device=device)
predictions = []
total = int(np.ceil(len(data) / batch_size)) if hasattr(data, "__len__") else None
with torch.no_grad():
for features, _ in track(
data_loader, description="Predicting...", transient=True, disable=not show_progress
for features in track(
_feature_batches(data, batch_size, num_workers),
description="Predicting...",
transient=True,
disable=not show_progress,
total=total,
):
features = [feature_tensor.to(device) for feature_tensor in features]
outputs = model(*features) if selected is None else model(*features, task_idx=selected)
Expand Down
46 changes: 39 additions & 7 deletions deeplc/calibration/multihead.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ def fit(self, target: np.ndarray, source: np.ndarray) -> None:
accepted and treated as a single head, so a single-task model still works.

"""
source = np.asarray(source, dtype=np.float64)
# The matrix arrives from the model as float32 and is (n, 6,543) wide; promoting it
# here doubled a 276 MB reference to 552 MB for no gain, since every head's column is
# cast to float32 again for its spline and the ranking accumulates in float64 itself.
source = np.asarray(source)
if source.ndim == 1:
source = source[:, None]
target = np.asarray(target, dtype=np.float64).ravel()
Expand Down Expand Up @@ -261,7 +264,10 @@ def fit(self, target: np.ndarray, source: np.ndarray) -> None:
accepted and treated as a single head, so a single-task model still works.

"""
source = np.asarray(source, dtype=np.float64)
# The matrix arrives from the model as float32 and is (n, 6,543) wide; promoting it
# here doubled a 276 MB reference to 552 MB for no gain, since every head's column is
# cast to float32 again for its spline and the ranking accumulates in float64 itself.
source = np.asarray(source)
if source.ndim == 1:
source = source[:, None]
target = np.asarray(target, dtype=np.float64).ravel()
Expand Down Expand Up @@ -292,8 +298,20 @@ def fit(self, target: np.ndarray, source: np.ndarray) -> None:
)
self._head_calibrations.append(head_calibration)

# RidgeCV without an explicit cv uses the closed-form leave-one-out route, which
# decomposes the design once instead of refitting it for every fold and alpha: 0.08 s
# against 0.84 s on a 10,541-peptide reference, and accuracy-neutral over ten held-out
# setups (median MAE ratio 1.0000, better on five and worse on five).
#
# Small references keep the fold-based search. The eighty calibrated columns are
# nearly collinear, which makes a leave-one-out estimate jumpy when there are few
# rows: on a 725-peptide reference it chose alpha 1 against 316 and cost 3.7 % of
# accuracy. Below the threshold the fold-based search costs about 0.15 s, so there is
# nothing to win there anyway.
n_splits = int(min(5, max(2, len(target) // 20)))
self._ridge = RidgeCV(alphas=self.alphas, cv=n_splits).fit(calibrated, target)
self._ridge = RidgeCV(
alphas=self.alphas, cv=None if len(target) >= 2000 else n_splits
).fit(calibrated, target)
LOGGER.info(
"Calibrated on %d of %d heads with ridge strength %.4g; head %d correlates best.",
n_heads,
Expand Down Expand Up @@ -412,10 +430,24 @@ def _rank_heads_by_correlation(source: np.ndarray, target: np.ndarray) -> np.nda

Vectorised because a fused-trunk multitask model can have thousands of heads to rank at once.
"""
centred = source - source.mean(axis=0)
target_centred = target - target.mean()
target_centred = np.asarray(target, dtype=np.float64).ravel()
target_centred = target_centred - target_centred.mean()
target_norm = float(np.sqrt((target_centred**2).sum()))
n_rows, n_heads = source.shape

# Centring the source would allocate a copy of the whole matrix, and squaring it another:
# at 6,543 heads and a 20,000-peptide reference that is well over a gigabyte of
# temporaries for a vector of 6,543 numbers. Because the centred target sums to zero,
# sum_i (x_i - xbar) * yc_i is just sum_i x_i * yc_i, so the covariance is one dot
# product per block and the variance follows from the block's own sums. Blocks keep the
# accumulation in float64 without ever holding more than a slice of the matrix.
correlation = np.empty(n_heads, dtype=np.float64)
block = 512
with np.errstate(invalid="ignore", divide="ignore"):
denominator = np.sqrt((centred**2).sum(axis=0) * (target_centred**2).sum())
correlation = (centred * target_centred[:, None]).sum(axis=0) / denominator
for start in range(0, n_heads, block):
chunk = np.asarray(source[:, start : start + block], dtype=np.float64)
covariance = chunk.T @ target_centred
variance = np.einsum("ij,ij->j", chunk, chunk) - n_rows * chunk.mean(axis=0) ** 2
correlation[start : start + block] = covariance / np.sqrt(variance * target_norm**2)
correlation = np.where(np.isfinite(correlation), correlation, -np.inf)
return np.argsort(-correlation)
27 changes: 13 additions & 14 deletions deeplc/calibration/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,20 +318,19 @@ def transform(self, source: np.ndarray) -> np.ndarray:
if source.shape[0] == 0:
return np.array([])

y_pred_spline = model_main.predict(source.reshape(-1, 1))
y_pred_left = model_left.predict(source.reshape(-1, 1))
y_pred_right = model_right.predict(source.reshape(-1, 1))
within_range = (source >= calibrate_min) & (source <= calibrate_max)
within_range = within_range.ravel()

cal_preds = np.copy(y_pred_spline)
cal_preds[~within_range & (source.ravel() < calibrate_min)] = y_pred_left[
~within_range & (source.ravel() < calibrate_min)
]
cal_preds[~within_range & (source.ravel() > calibrate_max)] = y_pred_right[
~within_range & (source.ravel() > calibrate_max)
]
return np.array(cal_preds)
flat = source.ravel()
cal_preds = np.asarray(model_main.predict(source.reshape(-1, 1)), dtype=float)

# The trails only ever supply the points outside the fitted range, which on a
# reference that covers its own gradient is usually none of them. Predicting them
# for every point tripled the work of this method.
below = flat < calibrate_min
above = flat > calibrate_max
if below.any():
cal_preds[below] = model_left.predict(flat[below].reshape(-1, 1))
if above.any():
cal_preds[above] = model_right.predict(flat[above].reshape(-1, 1))
return cal_preds


def _prepare_series(
Expand Down
4 changes: 3 additions & 1 deletion deeplc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ def predict(
result = _model_ops.predict(
model=loaded_model,
data=DeepLCDataset.from_psm_list(
_parse_psms(psm_list), **_feature_kwargs_from_spec(feature_spec)
_parse_psms(psm_list),
include_rolling_sum=getattr(loaded_model, "uses_rolling_sum", True),
**_feature_kwargs_from_spec(feature_spec),
),
**kwargs,
).numpy()
Expand Down
Loading
Loading