Source code for alf_core.model.normaliser
# Copyright 2026 InstaDeep Ltd. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Literal
import numpy as np
from jaxtyping import Float
logger = logging.getLogger("alf-core")
[docs]
class OutputStandardiser:
"""Standardises output labels to zero mean and unit variance.
Fit on training labels, then use transform/inverse_transform to convert
between original and standardised space. The model always returns predictions
in the original label scale via inverse_transform.
Handles the variance inverse transform correctly:
var_orig = var_standardised * std²
Edge case: if the training labels have near-zero standard deviation
(constant targets), std is clamped to _MIN_STD to avoid division by zero.
"""
_MIN_STD: float = 1e-8
def __init__(self) -> None:
"""Initialize with no fitted parameters."""
self._mean: float | None = None
self._std: float | None = None
@property
def is_fitted(self) -> bool:
"""Check if fit() has been called and mean/std are available."""
return self._mean is not None and self._std is not None
[docs]
def fit(self, Y: Float[np.ndarray, "n_samples"]) -> None:
"""Compute mean and std from training labels.
Args:
Y: 1-D array of training labels, shape (n_samples,).
"""
self._mean = float(np.mean(Y))
raw_std = float(np.std(Y))
self._std = max(raw_std, self._MIN_STD)
if raw_std < self._MIN_STD:
logger.warning(
"OutputStandardiser: training labels have near-zero std (%.2e). "
"Clamping to %.2e. Standardisation may not be meaningful.",
raw_std,
self._MIN_STD,
)
[docs]
def transform(self, Y: Float[np.ndarray, "n_samples"]) -> Float[np.ndarray, "n_samples"]:
"""Standardise labels to zero mean, unit variance.
Args:
Y: Array of labels to transform, shape (n_samples,).
Returns:
Standardised labels, shape (n_samples,).
Raises:
RuntimeError: If called before fit().
"""
if self._mean is None or self._std is None:
raise RuntimeError("OutputStandardiser must be fitted before calling transform.")
return (Y - self._mean) / self._std
[docs]
def inverse_transform(
self,
mean: Float[np.ndarray, "n_samples"],
var: Float[np.ndarray, "n_samples"] | None = None,
) -> tuple[Float[np.ndarray, "n_samples"], Float[np.ndarray, "n_samples"] | None]:
"""Transform predictions back to the original label scale.
Applies the correct variance scaling: var_orig = var_standardised * std².
Args:
mean: Predicted means in standardised space, shape (n_samples,).
var: Predicted variances in standardised space, shape (n_samples,), or None.
Returns:
Tuple of (mean_original, var_original). var_original is None if var is None.
Raises:
RuntimeError: If called before fit().
"""
if self._mean is None or self._std is None:
raise RuntimeError(
"OutputStandardiser must be fitted before calling inverse_transform."
)
mean_orig = mean * self._std + self._mean
var_orig = var * (self._std**2) if var is not None else None
return mean_orig, var_orig
[docs]
class InputNormaliser:
"""Normalises input features to the [0, 1] range via min-max scaling.
Fit on training features, then apply transform at both train and predict time.
Each feature dimension is scaled independently.
Supports both 2-D inputs (n_samples, n_features) and higher-dimensional inputs
such as the CNN's 3-D one-hot tensors (n_samples, alphabet_size, seq_len).
Statistics are always computed over the batch dimension (dim=0).
Edge case: if a feature has zero range (constant column), the range is set to
1.0 so the feature is only min-shifted (i.e., (X - min) == 0 for training).
This prevents unseen non-zero test values from being amplified by division by a
tiny epsilon. For one-hot inputs, ensure the training set covers all amino acids
at all positions, or clip outputs after transform.
Note:
Min-max scaling is well suited for GP models, where the kernel computes
distances between input points and benefits from inputs spanning the unit
cube [0, 1]. For deep neural networks (e.g. CNNModel), Z-score
standardisation (zero mean, unit variance) is generally preferred as it
zero-centres inputs and avoids the gradient bias that arises from
non-zero-centred activations.
"""
_MIN_RANGE: float = 1e-8
def __init__(self) -> None:
"""Initialize with no fitted parameters."""
self._min: np.ndarray | None = None
self._range: np.ndarray | None = None
@property
def is_fitted(self) -> bool:
"""Check if fit() has been called and min/range are available."""
return self._min is not None and self._range is not None
[docs]
def fit(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> None:
"""Compute per-feature min and range from training features.
Args:
X: Training feature tensor, shape (n_samples, ...).
Statistics are computed over the batch dimension (dim=0),
so each feature position gets its own min/max.
"""
x_min, x_max = (np.min(X, axis=0), np.max(X, axis=0))
self._min = x_min
raw_range = x_max - x_min
near_zero = raw_range < self._MIN_RANGE
# Constant features get a range of 1.0 so they are only min-shifted
# (i.e. (X - min) == 0 for training), keeping unseen non-zero values
# bounded at predict time rather than being amplified by division by
# a tiny epsilon. Mirror the behaviour of `InputStandardiser.fit`.
self._range = np.where(near_zero, 1.0, raw_range)
if np.any(near_zero):
logger.warning(
"InputNormaliser: %d feature(s) have near-zero range (< %.2e). "
"Setting their range to 1.0; those features are only min-shifted.",
int(np.sum(near_zero)),
self._MIN_RANGE,
)
[docs]
def transform(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> Float[np.ndarray, "n_samples ..."]:
"""Scale features to [0, 1].
Args:
X: Feature tensor, shape (n_samples, ...), matching the dimensionality
of the data passed to fit().
Returns:
Normalised feature tensor, same shape as input, values in [0, 1].
Raises:
RuntimeError: If called before fit().
"""
if not self.is_fitted:
raise RuntimeError("InputNormaliser must be fitted before calling transform.")
return (X - self._min) / self._range
[docs]
def inverse_transform(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> Float[np.ndarray, "n_samples ..."]:
"""Map normalised features in [0, 1] back to the original scale.
Args:
X: Normalised feature tensor, shape (n_samples, ...), matching the
dimensionality of the data passed to fit().
Returns:
Feature tensor in the original scale, same shape as input.
Raises:
RuntimeError: If called before fit().
"""
if not self.is_fitted:
raise RuntimeError("InputNormaliser must be fitted before calling inverse_transform.")
return X * self._range + self._min
[docs]
class InputStandardiser:
"""Standardises input features to zero mean and unit variance (Z-score).
Fit on training features, then apply transform at both train and predict time.
Each feature dimension is standardised independently.
Supports both 2-D inputs (n_samples, n_features) and higher-dimensional inputs
such as the CNN's 3-D one-hot tensors (n_samples, alphabet_size, seq_len).
Statistics are always computed over the batch dimension (dim=0).
Edge case: if a feature has near-zero standard deviation (constant column), its
scale is set to 1.0 (rather than a tiny epsilon), and a warning is logged. The
column is therefore only mean-centred, so unseen non-constant values at predict
time (e.g. a one-hot position never varied in training) stay bounded instead of
being amplified by division by a near-zero std.
Note:
Z-score standardisation is generally preferred for deep neural networks
(e.g. CNNModel) as it zero-centres inputs and avoids the gradient bias that
arises from non-zero-centred activations. For GP models, the min-max
`InputNormaliser` is usually preferred, since the kernel computes distances
between input points and benefits from inputs spanning the unit cube [0, 1].
"""
_MIN_STD: float = 1e-8
def __init__(self) -> None:
"""Initialize with no fitted parameters."""
self._mean: np.ndarray | None = None
self._std: np.ndarray | None = None
@property
def is_fitted(self) -> bool:
"""Check if fit() has been called and mean/std are available."""
return self._mean is not None and self._std is not None
[docs]
def fit(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> None:
"""Compute per-feature mean and std from training features.
Args:
X: Training feature tensor, shape (n_samples, ...).
Statistics are computed over the batch dimension (dim=0),
so each feature position gets its own mean/std.
"""
self._mean = np.mean(X, axis=0)
raw_std = np.std(X, axis=0)
# Constant features get a scale of 1.0 so they are only mean-centred,
# keeping unseen non-constant values bounded at predict time.
near_zero = raw_std < self._MIN_STD
self._std = np.where(near_zero, 1.0, raw_std)
if np.any(near_zero):
logger.warning(
"InputStandardiser: %d feature(s) have near-zero std (< %.2e). "
"Setting their scale to 1.0; those features are only mean-centred.",
int(np.sum(near_zero)),
self._MIN_STD,
)
[docs]
def transform(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> Float[np.ndarray, "n_samples ..."]:
"""Standardise features to zero mean, unit variance.
Args:
X: Feature tensor, shape (n_samples, ...), matching the dimensionality
of the data passed to fit().
Returns:
Standardised feature tensor, same shape as input.
Raises:
RuntimeError: If called before fit().
"""
if not self.is_fitted:
raise RuntimeError("InputStandardiser must be fitted before calling transform.")
return (X - self._mean) / self._std
[docs]
def inverse_transform(
self,
X: Float[np.ndarray, "n_samples ..."],
) -> Float[np.ndarray, "n_samples ..."]:
"""Map standardised features back to the original scale.
Args:
X: Standardised feature tensor, shape (n_samples, ...), matching the
dimensionality of the data passed to fit().
Returns:
Feature tensor in the original scale, same shape as input.
Raises:
RuntimeError: If called before fit().
"""
if not self.is_fitted:
raise RuntimeError("InputStandardiser must be fitted before calling inverse_transform.")
return X * self._std + self._mean
[docs]
def make_input_transform(
strategy: Literal["minmax", "zscore"],
) -> InputNormaliser | InputStandardiser:
"""Construct an unfitted input transform for the given strategy.
Args:
strategy: `minmax` for `InputNormaliser` (scaling to [0, 1]) or `zscore`
for `InputStandardiser` (zero mean, unit variance).
Returns:
An unfitted `InputNormaliser` or `InputStandardiser`.
Raises:
ValueError: If `strategy` is not one of `minmax` or `zscore`.
"""
if strategy == "minmax":
return InputNormaliser()
if strategy == "zscore":
return InputStandardiser()
raise ValueError(
f"Unknown input normalisation strategy: {strategy!r}. Expected 'minmax' or 'zscore'."
)