-
Notifications
You must be signed in to change notification settings - Fork 26
Calibrate against several LC setups at once (v4.3.0) #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.""" | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 😅
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
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__.