Source code for alf_tools.models.esm_utils.scoring_function
# 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 Any
import numpy as np
import torch
from alf_core import Predictions
from alf_tools.models.esm_utils.loss import special_tokens_mask
logger = logging.getLogger("alf-tools")
[docs]
def compute_pll(
esm_model: Any,
tokeniser: Any,
device: str | torch.device,
all_input_ids: torch.Tensor,
all_attention_mask: torch.Tensor,
batch_threshold: int,
) -> Predictions:
"""Compute zero-shot pseudo-log-likelihood (PLL) scores for sequences.
Scores each sequence by masking one residue at a time and accumulating the
log-probability the model assigns to the correct residue at that position. The
per-sequence score is the mean (average) log-probability across all non-special
(i.e. amino-acid) positions. Two execution modes trade off memory and speed:
- If the number of scoreable residues is ≤ `batch_threshold`, residues are masked
in a single batched forward pass (one masked position per batch row) to leverage
GPU parallelism. This creates a forward pass of shape (n_residues × padded_seq_len),
which can spike GPU memory for sequences near the threshold on large models.
- For longer sequences, positions are masked and scored one-at-a-time to avoid
excessive memory usage.
Args:
esm_model: A HuggingFace masked-LM model returning `.logits`.
tokeniser: A HuggingFace tokeniser exposing `mask_token_id`.
device: Device to run the forward passes on.
all_input_ids: Tensor of shape (n_candidates, seq_len) with token IDs.
all_attention_mask: Tensor of shape (n_candidates, seq_len) with 1
for non-padding tokens.
batch_threshold: Maximum number of scoreable residues to score in a single
batched forward pass before falling back to position-by-position scoring.
Returns:
Predictions: means is a float32 numpy array of per-sequence PLL
scores (average log-likelihood per residue).
Raises:
ValueError: If any sequence has no scoreable residue positions
(e.g. all special tokens).
"""
n_candidates = all_input_ids.shape[0]
# PLL: mask one residue at a time, scored per sequence
log_likelihoods: list[float] = []
_pll_oom_warned = False
with torch.no_grad():
for i in range(n_candidates):
input_ids_i = all_input_ids[i].unsqueeze(0).to(device) # (1, L)
attention_mask_i = all_attention_mask[i].unsqueeze(0).to(device)
special_mask = special_tokens_mask(input_ids_i[0], tokeniser)
residue_positions = (~special_mask).nonzero(as_tuple=True)[0].tolist()
if not residue_positions:
raise ValueError(
"One or more sequences have no scoreable positions (all special tokens). "
"Ensure each sequence contains at least one amino acid residue, "
"or increase max_length to avoid full truncation."
)
if len(residue_positions) <= batch_threshold:
n = len(residue_positions)
seq_len = input_ids_i.shape[1]
if not _pll_oom_warned and n * seq_len > 50_000:
logger.warning(
"Zero-shot PLL batched forward pass: %d residues × %d padded tokens "
"= %d tokens. This may cause OOM on memory-constrained devices. "
"Reduce ESM2ModelConfig.max_length or call predict() on "
"smaller batches.",
n,
seq_len,
n * seq_len,
)
_pll_oom_warned = True
batch_input = input_ids_i.expand(n, -1).clone() # (N, L)
for row, pos in enumerate(residue_positions):
batch_input[row, pos] = tokeniser.mask_token_id
logits = esm_model(
input_ids=batch_input,
attention_mask=attention_mask_i.expand(n, -1),
).logits # (N, L, vocab_size)
ll = sum(
torch.nn.functional.log_softmax(logits[row, pos], dim=-1)[
input_ids_i[0, pos]
].item()
for row, pos in enumerate(residue_positions)
)
else:
ll = 0.0
for pos in residue_positions:
masked_input = input_ids_i.clone()
masked_input[0, pos] = tokeniser.mask_token_id
logits = esm_model(
input_ids=masked_input,
attention_mask=attention_mask_i,
).logits # (1, L, vocab_size)
ll += torch.nn.functional.log_softmax(logits[0, pos], dim=-1)[
input_ids_i[0, pos]
].item()
log_likelihoods.append(ll / len(residue_positions))
return Predictions(means=np.array(log_likelihoods, dtype=np.float32))