#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
r"""
Pairwise likelihood for pairwise preference model (e.g., PairwiseGP).
"""
from __future__ import annotations
import math
from abc import ABC, abstractmethod
import torch
from botorch.utils.probability.utils import (
log_ndtr,
log_phi,
standard_normal_log_hazard,
)
from gpytorch.likelihoods import Likelihood
from torch import Tensor
from torch.distributions import Bernoulli
[docs]
class PairwiseLikelihood(Likelihood, ABC):
"""
Pairwise likelihood base class for pairwise preference GP (e.g., PairwiseGP).
"""
def __init__(self, max_plate_nesting: int = 1):
"""
Initialized like a `gpytorch.likelihoods.Likelihood`.
Args:
max_plate_nesting: Defaults to 1.
"""
super().__init__(max_plate_nesting)
[docs]
def forward(self, utility: Tensor, D: Tensor) -> Bernoulli:
"""Given the difference in (estimated) utility util_diff = f(v) - f(u),
return a Bernoulli distribution object representing the likelihood of
the user prefer v over u.
Note that this is not used by the `PairwiseGP` model,
"""
return Bernoulli(probs=self.p(utility=utility, D=D))
[docs]
@abstractmethod
def p(self, utility: Tensor, D: Tensor) -> Tensor:
"""Given the difference in (estimated) utility util_diff = f(v) - f(u),
return the probability of the user prefer v over u.
Args:
utility: A Tensor of shape `(batch_size) x n`, the utility at MAP point
D: D is `(batch_size x) m x n` matrix with all elements being zero in last
dimension except at two positions D[..., i] = 1 and D[..., j] = -1
respectively, representing item i is preferred over item j.
log: if true, return log probability
"""
[docs]
def log_p(self, utility: Tensor, D: Tensor) -> Tensor:
"""return the log of p"""
return torch.log(self.p(utility=utility, D=D))
[docs]
def negative_log_gradient_sum(self, utility: Tensor, D: Tensor) -> Tensor:
"""Calculate the sum of negative log gradient with respect to each item's latent
utility values. Useful for models using laplace approximation.
Args:
utility: A Tensor of shape `(batch_size x) n`, the utility at MAP point
D: D is `(batch_size x) m x n` matrix with all elements being zero in last
dimension except at two positions D[..., i] = 1 and D[..., j] = -1
respectively, representing item i is preferred over item j.
Returns:
A `(batch_size x) n` Tensor representing the sum of negative log gradient
values of the likelihood over all comparisons (i.e., the m dimension)
with respect to each item.
"""
raise NotImplementedError
[docs]
def negative_log_hessian_sum(self, utility: Tensor, D: Tensor) -> Tensor:
"""Calculate the sum of negative log hessian with respect to each item's latent
utility values. Useful for models using laplace approximation.
Args:
utility: A Tensor of shape `(batch_size) x n`, the utility at MAP point
D: D is `(batch_size x) m x n` matrix with all elements being zero in last
dimension except at two positions D[..., i] = 1 and D[..., j] = -1
respectively, representing item i is preferred over item j.
Returns:
A `(batch_size x) n x n` Tensor representing the sum of negative log hessian
values of the likelihood over all comparisons (i.e., the m dimension) with
respect to each item.
"""
raise NotImplementedError
[docs]
class PairwiseProbitLikelihood(PairwiseLikelihood):
"""Pairwise likelihood using probit function
Given two items v and u with utilities f(v) and f(u), the probability that we
prefer v over u with probability std_normal_cdf((f(v) - f(u))/sqrt(2)). Note
that this formulation implicitly assume the noise term is fixed at 1.
"""
# Clamping z values for better numerical stability. See self._calc_z for detail
# norm_cdf(z=3) ~= 0.999, top 0.1% percent
_zlim = 3
def _calc_z(self, utility: Tensor, D: Tensor) -> Tensor:
"""Calculate the z score given estimated utility values and
the comparison matrix D.
"""
scaled_util = (utility / math.sqrt(2)).unsqueeze(-1)
z = D.to(scaled_util) @ scaled_util
z = z.clamp(-self._zlim, self._zlim).squeeze(-1)
return z
def _calc_z_derived(self, z: Tensor) -> tuple[Tensor, Tensor, Tensor]:
"""Calculate auxiliary statistics derived from z, including log pdf,
log cdf, and the hazard function (pdf divided by cdf)
Args:
z: A Tensor of arbitrary shape.
Returns:
Tensors with standard normal logpdf(z), logcdf(z), and hazard function
values evaluated at -z.
"""
return log_phi(z), log_ndtr(z), standard_normal_log_hazard(-z).exp()
[docs]
def p(self, utility: Tensor, D: Tensor, log: bool = False) -> Tensor:
z = self._calc_z(utility=utility, D=D)
std_norm = torch.distributions.normal.Normal(
torch.zeros(1, dtype=z.dtype, device=z.device),
torch.ones(1, dtype=z.dtype, device=z.device),
)
return std_norm.cdf(z)
[docs]
def negative_log_gradient_sum(self, utility: Tensor, D: Tensor) -> Tensor:
# Compute the sum over of grad. of negative Log-LH wrt utility f.
# Original grad should be of dimension m x n, as in (6) from
# [Chu2005preference]_. The sum over the m dimension of grad. of
# negative log likelihood with respect to the utility
z = self._calc_z(utility, D)
_, _, h = self._calc_z_derived(z)
h_factor = h / math.sqrt(2)
grad = (h_factor.unsqueeze(-2) @ (-D)).squeeze(-2)
return grad
[docs]
def negative_log_hessian_sum(self, utility: Tensor, D: Tensor) -> Tensor:
# Original hess should be of dimension m x n x n, as in (7) from
# [Chu2005preference]_ Sum over the first dimension and return a tensor of
# shape n x n.
# The sum over the m dimension of hessian of negative log likelihood
# with respect to the utility
DT = D.transpose(-1, -2)
z = self._calc_z(utility, D)
_, _, h = self._calc_z_derived(z)
mul_factor = h * (h + z) / 2
mul_factor = mul_factor.unsqueeze(-2).expand(*DT.size())
# multiply the hessian value by preference signs
# (+1 if preferred or -1 otherwise) and sum over the m dimension
hess = DT * mul_factor @ D
return hess
[docs]
class PairwiseLogitLikelihood(PairwiseLikelihood):
"""Pairwise likelihood using logistic (i.e., sigmoid) function
Given two items v and u with utilities f(v) and f(u), the probability that we
prefer v over u with probability sigmoid(f(v) - f(u)). Note
that this formulation implicitly assume the beta term in logistic function is
fixed at 1.
"""
# Clamping logit values for better numerical stability.
# See self._calc_logit for detail logistic(8) ~= 0.9997, top 0.03% percent
_logit_lim = 8
def _calc_logit(self, utility: Tensor, D: Tensor) -> Tensor:
logit = D.to(utility) @ utility.unsqueeze(-1)
logit = logit.clamp(-self._logit_lim, self._logit_lim).squeeze(-1)
return logit
[docs]
def log_p(self, utility: Tensor, D: Tensor) -> Tensor:
logit = self._calc_logit(utility=utility, D=D)
return torch.nn.functional.logsigmoid(logit)
[docs]
def p(self, utility: Tensor, D: Tensor) -> Tensor:
logit = self._calc_logit(utility=utility, D=D)
return torch.sigmoid(logit)
[docs]
def negative_log_gradient_sum(self, utility: Tensor, D: Tensor) -> Tensor:
indices_shape = utility.shape[:-1] + (-1,)
winner_indices = (D == 1).nonzero(as_tuple=True)[-1].reshape(indices_shape)
loser_indices = (D == -1).nonzero(as_tuple=True)[-1].reshape(indices_shape)
ex = torch.exp(torch.gather(utility, -1, winner_indices))
ey = torch.exp(torch.gather(utility, -1, loser_indices))
unsigned_grad = ey / (ex + ey)
grad = (unsigned_grad.unsqueeze(-2) @ (-D)).squeeze(-2)
return grad
[docs]
def negative_log_hessian_sum(self, utility: Tensor, D: Tensor) -> Tensor:
DT = D.transpose(-1, -2)
# calculating f(v) - f(u) given u > v information in D
neg_logit = -(D @ utility.unsqueeze(-1)).squeeze(-1)
term = torch.sigmoid(neg_logit)
mul_factor = term - (term) ** 2
mul_factor = mul_factor.unsqueeze(-2).expand(*DT.size())
# multiply the hessian value by preference signs
# (+1 if preferred or -1 otherwise) and sum over the m dimension
hess = DT * mul_factor @ D
return hess