diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df1e8c..1ee5cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ 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.1.1] - 2026-08-26 + +### Changed + +- The default model is now the fused-trunk multitask model trained across 6,543 LC + setups (`multitask_flexcnn_model.pt`, bundled since 4.1.0 as an opt-in). Every core + function and the command line use it when no `model` is given. The 4.0 default, + `multitask_model.pt`, stays bundled as `deeplc.core.LEGACY_MULTITASK_MODEL`; pass it + as `model=` to reproduce 4.0 and 4.1.0 predictions exactly. +- Uncalibrated `predict()` on a multitask model that carries setup names reports the + setup named by `deeplc.core.DEFAULT_TASK_NAME` (`PXD005573_mcp`, the 200-minute + gradient the DeepLC 1.x to 3.x models were trained on) instead of head 0, which for + the new default was an arbitrary setup. `return_matrix=True` is unchanged, and so are + calibration and fine-tuning, which select or fit the setup from the reference. + ## [4.1.0] - 2026-08-20 ### Added diff --git a/README.md b/README.md index 5e3b401..93fc028 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ DeepLC predicts retention times for peptides carrying any modification. It does this by leveraging a deep learning model based on atomic composition features. Starting with v4, DeepLC comes with a -multitask pretrained model covering multiple LC setups, enabling accurate predictions out of the -box. For best results on a specific dataset, predictions can be calibrated or fine-tuned +multitask pretrained model covering multiple LC setups (6,543 setups since v4.1.1), enabling +accurate predictions out of the box. For best results on a specific dataset, predictions can be calibrated or fine-tuned using a small reference set of identified PSMs. ## Citation diff --git a/deeplc/core.py b/deeplc/core.py index 082857c..f6d58e3 100644 --- a/deeplc/core.py +++ b/deeplc/core.py @@ -22,7 +22,20 @@ LOGGER = logging.getLogger(__name__) DEEPLC_DIR = Path(__file__).resolve().parent -DEFAULT_MODEL = DEEPLC_DIR / "package_data" / "models" / "multitask_model.pt" +#: The model every core function uses when none is given: the fused-trunk multitask +#: model trained across 6,543 LC setups (see :data:`FLEXCNN_MULTITASK_MODEL`). +DEFAULT_MODEL = DEEPLC_DIR / "package_data" / "models" / "multitask_flexcnn_model.pt" + +#: The 4.0 default, a shared-trunk model with one head per LC setup, kept so that +#: existing workflows can pin it: ``predict(psms, model=LEGACY_MULTITASK_MODEL)``. +LEGACY_MULTITASK_MODEL = DEEPLC_DIR / "package_data" / "models" / "multitask_model.pt" + +#: The LC setup an uncalibrated ``predict()`` reports for a multitask model. The +#: default model has 6,543 setups and no reference to choose between them, so the +#: setup of the DeepLC 1.x to 3.x training data (PXD005573, 200-minute gradient) is +#: used, which keeps uncalibrated output on the gradient earlier versions reported. +#: Calibration and fine-tuning pick or fit the setup from the reference instead. +DEFAULT_TASK_NAME = "PXD005573_mcp" #: Below this many reference PSMs, fine-tuning measured worse than calibration on #: every held-out setup tried, so it is warned about rather than silently attempted. @@ -40,10 +53,9 @@ #: near 1 %. MAX_FINETUNE_ERROR_FRACTION = 0.15 -#: Fused-trunk multitask model, trained across 6,543 LC setups. Not the default: -#: switching would change every prediction, so the choice is left to the caller -#: until the calibration path is adapted to its low-rank head. -FLEXCNN_MULTITASK_MODEL = DEEPLC_DIR / "package_data" / "models" / "multitask_flexcnn_model.pt" +#: Fused-trunk multitask model, trained across 6,543 LC setups. The default since +#: 4.1.1; the name is kept for callers that pass it explicitly. +FLEXCNN_MULTITASK_MODEL = DEFAULT_MODEL def predict( @@ -65,8 +77,10 @@ def predict( Additional keyword arguments to pass to the prediction function. return_matrix If True, return the full prediction matrix of shape ``(n, n_heads)`` when using a - multitask model. If False (default), return a 1D array of shape ``(n,)`` using - head 0 when model output is 2D. + multitask model. If False (default), return a 1D array of shape ``(n,)`` for the + setup named by :data:`DEFAULT_TASK_NAME` when the model knows its setups, and head + 0 otherwise. Uncalibrated output is on that setup's gradient; use + :func:`predict_and_calibrate` to map it onto your own. Returns ------- @@ -96,7 +110,7 @@ def predict( and "task_idx" not in kwargs and _model_ops.supports_task_subset(loaded_model) ): - kwargs["task_idx"] = [0] + kwargs["task_idx"] = [_default_task_idx(loaded_model)] result = _model_ops.predict( model=loaded_model, @@ -106,10 +120,24 @@ def predict( **kwargs, ).numpy() if not return_matrix: - return result[:, 0] + return result[:, 0 if "task_idx" in kwargs else _default_task_idx(loaded_model)] return result +def _default_task_idx(model: torch.nn.Module) -> int: + """ + Index of the setup an uncalibrated prediction reports. + + :data:`DEFAULT_TASK_NAME` when the model carries setup names and lists it, else 0. + A model without names, or a model of a single setup, has nothing to choose from. + """ + names = getattr(model, "task_names", None) or [] + try: + return list(names).index(DEFAULT_TASK_NAME) + except ValueError: + return 0 + + def calibrate( psm_list_reference: PSMList, model: torch.nn.Module | PathLike | str | None = None, diff --git a/docs/source/migration.rst b/docs/source/migration.rst index 3b4f6b1..e7cabc7 100644 --- a/docs/source/migration.rst +++ b/docs/source/migration.rst @@ -60,9 +60,10 @@ Model checkpoints ================= Legacy ``.hdf5`` checkpoints from v3 are not compatible with v4. The bundled model -has been retrained as a PyTorch multitask model (``multitask_model.pt``). Custom -``.hdf5`` checkpoints cannot be loaded; retrain using the v4 API. The new model -and should serve as an ideal starting point for fine-tuning to any custom setup. +has been retrained as a PyTorch multitask model (``multitask_flexcnn_model.pt`` +since 4.1.1, covering 6,543 LC setups; ``multitask_model.pt`` in 4.0 and 4.1.0). +Custom ``.hdf5`` checkpoints cannot be loaded; retrain using the v4 API. The new +model should serve as an ideal starting point for fine-tuning to any custom setup. Backend: TensorFlow → PyTorch diff --git a/docs/source/models.rst b/docs/source/models.rst index 80caed0..dd85184 100644 --- a/docs/source/models.rst +++ b/docs/source/models.rst @@ -5,11 +5,22 @@ Prediction models Default model ============= -DeepLC 4.0 ships a pretrained multitask model (``multitask_model.pt``) as the -default. This model was trained jointly across multiple LC setups and outputs -one retention time prediction per setup. The best-fitting output head is -selected automatically during calibration based on Pearson correlation to the -observed retention times in the reference set. +DeepLC ships a pretrained multitask model as the default. Since 4.1.1 this is +``multitask_flexcnn_model.pt``: a fused-trunk convolutional model with a low-rank +multitask head, trained jointly across 6,543 LC setups from public repositories. +It outputs one retention time prediction (in minutes) per setup. The best-fitting +setup is selected automatically during calibration based on Pearson correlation to +the observed retention times in the reference set, and fine-tuning fits a new setup +head (66 parameters) on the reference with the trunk frozen. + +Without calibration, :func:`deeplc.predict` reports the setup named by +:data:`deeplc.core.DEFAULT_TASK_NAME` (``PXD005573_mcp``, the 200-minute gradient +that DeepLC 1.x to 3.x models were trained on), or the full matrix with +``return_matrix=True``. The setup names are available as ``model.task_names``. + +The 4.0 default, ``multitask_model.pt`` (shared trunk, one head per setup), stays +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. Training a model from scratch ============================== diff --git a/pyproject.toml b/pyproject.toml index 36b9ba3..1267661 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deeplc" -version = "4.1.0" +version = "4.1.1" description = "DeepLC: Retention time prediction for (modified) peptides using Deep Learning." readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_core.py b/tests/test_core.py index 51994a0..1bb2283 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -71,6 +71,45 @@ def test_predict_returns_matrix_when_flag_set(): assert result.shape[1] > 1 +def test_default_model_is_the_flexcnn_multitask_model(): + """Since 4.1.1 the fused-trunk model of 6,543 setups is what a bare call loads.""" + assert deeplc.core.DEFAULT_MODEL == deeplc.core.FLEXCNN_MULTITASK_MODEL + assert deeplc.core.DEFAULT_MODEL.name == "multitask_flexcnn_model.pt" + assert deeplc.core.LEGACY_MULTITASK_MODEL.name == "multitask_model.pt" + assert deeplc.core.LEGACY_MULTITASK_MODEL.exists() + + +def test_uncalibrated_predict_reports_the_default_setup(): + """ + A bare ``predict`` returns the column of :data:`DEFAULT_TASK_NAME`, not head 0. + + The default model lists thousands of setups, and head 0 is whichever sorted first. + The PXD005573 setup keeps uncalibrated output on the gradient DeepLC 1.x to 3.x + reported, so downstream code that never calibrated sees comparable numbers. + """ + model = deeplc.core._model_ops.load_model(deeplc.core.DEFAULT_MODEL, device="cpu") + idx = list(model.task_names).index(deeplc.core.DEFAULT_TASK_NAME) + assert idx != 0 + + psm_list = _make_psm_list(_PEPTIDES) + single = deeplc.core.predict(psm_list, predict_kwargs={"device": "cpu"}) + matrix = deeplc.core.predict(psm_list, return_matrix=True, predict_kwargs={"device": "cpu"}) + np.testing.assert_allclose(single, matrix[:, idx], rtol=1e-5, atol=1e-4) + assert not np.allclose(single, matrix[:, 0]) + assert np.isfinite(single).all() + + +def test_legacy_multitask_model_still_loads_and_predicts(): + """The 4.0 default remains bundled and usable when pinned explicitly.""" + result = deeplc.core.predict( + _make_psm_list(_PEPTIDES), + model=deeplc.core.LEGACY_MULTITASK_MODEL, + predict_kwargs={"device": "cpu"}, + ) + assert result.shape == (len(_PEPTIDES),) + assert np.isfinite(result).all() + + def test_predict_and_calibrate_auto_selects_reference(): # 200 PSMs cycling through _PEPTIDES; 100 with qvalue<=0.01, 100 with qvalue=1.0. # auto-selection picks the 100 low-qvalue PSMs as reference. diff --git a/tests/test_flexcnn.py b/tests/test_flexcnn.py index 161cfe0..8e5b3df 100644 --- a/tests/test_flexcnn.py +++ b/tests/test_flexcnn.py @@ -549,8 +549,10 @@ def test_bundled_model_predicts_in_minutes(): assert -100.0 < out.min() < 60.0 assert 20.0 < out.max() < 1000.0 + # Uncalibrated output reports the DeepLC 1.x to 3.x setup, not whichever sorted first. + default_idx = list(model.task_names).index(core.DEFAULT_TASK_NAME) single = core.predict(peptides, model=path) - np.testing.assert_allclose(single, out[:, 0], rtol=1e-5) + np.testing.assert_allclose(single, out[:, default_idx], rtol=1e-5) def test_small_reference_set_warns_and_widens_validation(tmp_path, caplog): diff --git a/tests/test_model_ops.py b/tests/test_model_ops.py index 0480eae..e3f187d 100644 --- a/tests/test_model_ops.py +++ b/tests/test_model_ops.py @@ -9,7 +9,7 @@ from torch.utils.data import Dataset from deeplc._architecture import DeepLCModel -from deeplc.core import DEFAULT_MODEL +from deeplc.core import LEGACY_MULTITASK_MODEL from deeplc.data import split_datasets @@ -59,7 +59,7 @@ def test_train_rejects_empty_validation_loader(): @pytest.mark.skipif( - not DEFAULT_MODEL.exists(), + not LEGACY_MULTITASK_MODEL.exists(), reason="multitask model not bundled", ) def test_load_multitask_model_without_prior_shim(): @@ -67,7 +67,7 @@ def test_load_multitask_model_without_prior_shim(): # Remove any previously registered shim so the test is self-contained. sys.modules.pop("multitask_model", None) - model = _model_ops.load_model(DEFAULT_MODEL, device="cpu") + model = _model_ops.load_model(LEGACY_MULTITASK_MODEL, device="cpu") assert isinstance(model, DeepLCModel)