Skip to content
Open
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.3.0] - 2026-08-28

### Added

- `MultiHeadRidgeCalibration`, a calibration that uses several LC setups of the multitask model
instead of only the best-correlating one. It keeps the existing ranking, calibrates the 80 best
heads individually with `SplineTransformerCalibration`, and fits a ridge regression from those
calibrated estimates onto the observed retention times.

On the eight PRIDE setups that no DeepLC model was trained on it lowered the held-out error on
all eight, by a median of 13 % relative to the observed gradient (0.01248 to 0.01090 MAE/span):
0.7 % on a setup that pools several fractions into one run, 7.5 to 17 % on four others, 28 to
38 % on the three where the single best head fitted worst, the largest gain being the smallest
reference of 230 peptidoforms. Fitting is not slower than the current path despite the extra
spline fits, because the head ranking is vectorised (a median of 1.0 s against 2.3 s in this
test), and prediction is unchanged since the full head matrix is computed either way.

Opt in per call, the default is untouched:

```python
from deeplc import MultiHeadRidgeCalibration, predict_and_calibrate

rt = predict_and_calibrate(psms, psm_list_reference=reference,
calibration=MultiHeadRidgeCalibration())
```

- `Calibration.uses_all_heads`, telling `calibrate` and `predict_and_calibrate` to hand a
calibration the whole `(n, n_heads)` prediction matrix rather than a single column. False for
every calibration that works on one series, which is all of them except the above.

## [4.2.0] - 2026-08-28

### Changed
Expand Down
2 changes: 2 additions & 0 deletions deeplc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from importlib.metadata import version

from deeplc.calibration import MultiHeadRidgeCalibration

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this explicitly need to be part of the public API? As part of deeplc.calibration, it would be. But I don't think an explicit mention in __all__ is needed. It's not consistent with the other items in __all__.

from deeplc.core import (
calibrate,
finetune,
Expand All @@ -14,6 +15,7 @@

__version__: str = version("deeplc")
__all__: list[str] = [
"MultiHeadRidgeCalibration",
"calibrate",
"predict",
"predict_and_calibrate",
Expand Down
159 changes: 159 additions & 0 deletions deeplc/calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class Calibration(ABC):

selected_model_head: int | None = None

#: Whether ``fit`` and ``transform`` take the whole ``(n, n_heads)`` prediction matrix of a
#: multitask model instead of a single series. False for every calibration here except
#: :class:`MultiHeadRidgeCalibration`.
uses_all_heads: bool = False
Comment on lines +24 to +27

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also advocate for instructing Claude to only use inline comments to describe code when not obvious from the code itself. To me, this line feels quite self-explanatory and does not require three lines of comments to explain itself :P


@abstractmethod
def __init__(self, *args, **kwargs):
"""Initialize the calibration model."""
Expand Down Expand Up @@ -325,6 +330,160 @@ def transform(self, source: np.ndarray) -> np.ndarray:
return np.array(cal_preds)


class MultiHeadRidgeCalibration(Calibration):
"""
Calibrate against several LC setups of a multitask model at once.

The default path keeps one setup head: it ranks all heads by Pearson correlation to the
reference and fits a spline on the winner. A gradient that no trained setup matches exactly
is then described by the closest single setup, and the rest of the matrix is discarded.

Comment on lines +337 to +340

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably compile some instructions for LLMs in how to document code. This initial paragraph can be quite confusing. I expected an description of this class, and instead got one of the previous MT calibration method 😅 Generally, it could also be a bit more concise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, it's also clear that Claude writes comments, docstrings, and PR messages from the context of the conversation that led to the work.

I had a short chat with Claude about it to come up with some instructions 😅

Documentation should be self-contained, not conversation-dependent.

Comments, docstrings, and PR descriptions must be understandable to a reader with no access to the conversation or reasoning that produced the code. Do not assume the reader knows what was discussed, tried, or rejected beforehand.

Comments and docstrings should describe the current state and behavior of the code: what it does and how it is used. They should not read as a log of the development process. Historical context (why a previous approach was replaced, what motivated a design choice) belongs in commit messages, not in comments or docstrings, unless it is strictly necessary to understand the current implementation, in which case it should be brief.

PR descriptions may include more of this process-oriented information: the reasoning behind the change, alternatives considered, and relevant historical context. This is the appropriate place for a log of the development process, distinct from the code documentation itself.

This calibration keeps that ranking, calibrates the ``n_heads`` best heads individually with
:class:`SplineTransformerCalibration`, and fits a ridge regression from those calibrated
estimates onto the observed retention times. Every head therefore contributes an estimate
already in the unit of the reference, and the ridge decides how much to trust each one.

On the eight PRIDE setups that no DeepLC model was trained on, this lowered the held-out
error on all eight: a median of 13 % relative to the observed gradient (0.01248 to 0.01090
MAE/span), from 1 % on a setup whose retention times are not a single gradient to 38 % on the
smallest reference of 230 peptidoforms. The cost is ``n_heads`` spline fits and one ridge on
the reference; prediction is unchanged, because the full matrix is computed either way.
Comment on lines +346 to +350

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could also be left out? Benchmarking results can go in the PR message, but do not really need to be part of a docstring.


Parameters
----------
n_heads
How many of the best-correlating heads to combine. 80 sits on the flat part of the
optimum for references from 230 to 2,000 peptidoforms; below about ten the gain shrinks,
and beyond a few hundred it slowly reverses.
alphas
Ridge strengths offered to the internal cross-validation. The default spans 1e-3 to 1e6,
wide enough for the fit to collapse towards an average when the reference is small.

"""

uses_all_heads = True

def __init__(self, n_heads: int = 80, alphas: np.ndarray | None = None) -> None:
"""Initialize MultiHeadRidgeCalibration."""
super().__init__()
if n_heads < 1:
raise ValueError(f"n_heads must be at least 1, got {n_heads}")
self.n_heads = n_heads
self.alphas = np.logspace(-3, 6, 19) if alphas is None else np.asarray(alphas)
self._head_idx: np.ndarray | None = None
self._head_calibrations: list[SplineTransformerCalibration] = []
self._ridge = None

@property
def is_fitted(self) -> bool:
"""True once the heads are selected, calibrated and weighted."""
return self._head_idx is not None and self._ridge is not None

def fit(self, target: np.ndarray, source: np.ndarray) -> None:
"""
Select, calibrate and weight the heads.

Parameters
----------
target
Observed retention times of the reference, shape ``(n,)``.
source
Reference predictions for every head, shape ``(n, n_heads_total)``. A 1-D array is
accepted and treated as a single head, so a single-task model still works.

"""
from sklearn.linear_model import RidgeCV

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not be a lazy import; move to head of module.


source = np.asarray(source, dtype=np.float64)
if source.ndim == 1:
source = source[:, None]
target = np.asarray(target, dtype=np.float64).ravel()
if source.shape[0] != target.shape[0]:
raise CalibrationError(
f"source has {source.shape[0]} rows and target {target.shape[0]}"
)
finite = np.isfinite(target) & np.isfinite(source).all(axis=1)
if int(finite.sum()) < 3:
raise CalibrationError("Fewer than three reference points with finite values.")
source, target = source[finite], target[finite]

order = _rank_heads_by_correlation(source, target)
# never fit more weights than half the reference: a 230-peptide reference cannot support
# eighty of them, and the ridge would be extrapolating its own regularisation
n_heads = int(min(self.n_heads, source.shape[1], max(1, len(target) // 2)))
self._head_idx = order[:n_heads]
self.selected_model_head = int(order[0])

calibrated = np.empty((len(target), n_heads), dtype=np.float64)
self._head_calibrations = []
for position, head in enumerate(self._head_idx):
head_calibration = SplineTransformerCalibration()
column = source[:, head].astype(np.float32)
head_calibration.fit(target=target.astype(np.float32), source=column)
calibrated[:, position] = np.asarray(
head_calibration.transform(column), dtype=np.float64
)
self._head_calibrations.append(head_calibration)

n_splits = int(min(5, max(2, len(target) // 20)))
self._ridge = RidgeCV(alphas=self.alphas, cv=n_splits).fit(calibrated, target)
LOGGER.info(
"Calibrated on %d of %d heads with ridge strength %.4g; head %d correlates best.",
n_heads,
source.shape[1],
float(getattr(self._ridge, "alpha_", float("nan"))),
self.selected_model_head,
)

def transform(self, source: np.ndarray) -> np.ndarray:
"""
Calibrate predictions of the model this calibration was fitted with.

Parameters
----------
source
Predictions for every head, shape ``(n, n_heads_total)``, as returned by
``predict(..., return_matrix=True)``.

"""
if not self.is_fitted:
raise CalibrationError("The model has not been fitted yet. Call fit() first.")
source = np.asarray(source, dtype=np.float64)
if source.ndim == 1:
source = source[:, None]
head_idx = cast(np.ndarray, self._head_idx)
if source.shape[1] <= int(head_idx.max()):
raise CalibrationError(
f"source has {source.shape[1]} heads, but the calibration was fitted on a model "
f"with at least {int(head_idx.max()) + 1}."
)
if source.shape[0] == 0:
return np.array([])
calibrated = np.column_stack(
[
np.asarray(cal.transform(source[:, head].astype(np.float32)), dtype=np.float64)
for cal, head in zip(self._head_calibrations, head_idx, strict=True)
]
)
return np.asarray(self._ridge.predict(calibrated), dtype=np.float64)


def _rank_heads_by_correlation(source: np.ndarray, target: np.ndarray) -> np.ndarray:
"""
Head indices by decreasing Pearson correlation with the target, in one pass.

The same criterion as :func:`deeplc.core._best_correlating_head`, which takes the first
element of this order, but vectorised because thousands of heads are ranked at once.
"""
centred = source - source.mean(axis=0)
target_centred = target - target.mean()
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
correlation = np.where(np.isfinite(correlation), correlation, -np.inf)
return np.argsort(-correlation)


def _prepare_series(
target: np.ndarray,
source: np.ndarray,
Expand Down
10 changes: 10 additions & 0 deletions deeplc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ def calibrate(
LOGGER.debug("Fitting calibration...")
target_rt_cal = np.array(psm_list_reference["retention_time"], dtype=np.float32)

# A calibration that combines heads is given the whole matrix and picks its own; it sets
# selected_model_head itself, for callers that want to know which setup came out on top.
if getattr(calibration, "uses_all_heads", False):
calibration.fit(target=target_rt_cal, source=source_rt_cal)
return calibration

# Select the best head for calibration if the model predicts for multiple LC setups
if source_rt_cal.shape[1] > 1:
calibration.selected_model_head = _best_correlating_head(source_rt_cal, target_rt_cal)
Expand Down Expand Up @@ -278,6 +284,10 @@ def predict_and_calibrate(
else:
LOGGER.info("Calibration is already fitted, skipping fitting step.")

if getattr(calibration, "uses_all_heads", False):
# the calibration combines several heads, so it takes the matrix as it is
return calibration.transform(predicted_rt)

if predicted_rt.shape[1] > 1:
if calibration.selected_model_head is None:
raise ValueError(
Expand Down
27 changes: 27 additions & 0 deletions docs/source/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ The 4.0 default, ``multitask_model.pt`` (shared trunk, one head per setup), stay
bundled as :data:`deeplc.core.LEGACY_MULTITASK_MODEL` and can be passed as
``model=`` to any core function to reproduce 4.0 and 4.1.0 predictions.

Calibrating against several setups at once
==========================================

By default calibration keeps one output head: every head is ranked by Pearson correlation to the
reference and a spline is fitted on the winner. A gradient that no trained setup matches exactly
is then described by the closest single setup.

:class:`~deeplc.calibration.MultiHeadRidgeCalibration` keeps that ranking but calibrates the 80
best heads individually and fits a ridge regression from those calibrated estimates onto the
observed retention times, so several setups contribute:

.. code-block:: python

from deeplc import MultiHeadRidgeCalibration, predict_and_calibrate

calibrated_rt = predict_and_calibrate(
psm_list,
psm_list_reference=reference,
calibration=MultiHeadRidgeCalibration(),
)

On eight PRIDE setups that no DeepLC model was trained on, this lowered the held-out error on all
eight, by a median of 13 % relative to the gradient and by up to 38 % on the smallest reference
(230 peptidoforms). The number of heads is the one parameter worth changing: 80 sits on a flat
optimum between roughly 40 and 320, and the class never fits more weights than half the reference
allows. Prediction costs nothing extra, because the full head matrix is computed either way.

Training a model from scratch
==============================

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "deeplc"
version = "4.2.0"
version = "4.3.0"
description = "DeepLC: Retention time prediction for (modified) peptides using Deep Learning."
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
Loading
Loading