PFNEngineeringConstrainedBO / pfns4bo /bar_distribution.py
rosenyu's picture
Upload 529 files
165ee00 verified
from .utils import print_once
import torch
from torch import nn
class BarDistribution(nn.Module):
def __init__(self, borders: torch.Tensor, smoothing=.0, ignore_nan_targets=True): # here borders should start with min and end with max, where all values lie in (min,max) and are sorted
'''
:param borders:
:param smoothing:
:param append_mean_pred: Whether to predict the mean of the other positions as a last output in forward,
is enabled when additionally y has a sequence length 1 shorter than logits, i.e. len(logits) == 1 + len(y)
'''
super().__init__()
assert len(borders.shape) == 1
self.register_buffer('borders', borders)
self.register_buffer('smoothing', torch.tensor(smoothing))
self.register_buffer('bucket_widths', self.borders[1:] - self.borders[:-1])
full_width = self.bucket_widths.sum()
assert (1 - (full_width / (self.borders[-1] - self.borders[0]))).abs() < 1e-2, f'diff: {full_width - (self.borders[-1] - self.borders[0])} with {full_width} {self.borders[-1]} {self.borders[0]}'
assert (self.bucket_widths >= 0.0).all() , "Please provide sorted borders!" # This also allows size zero buckets
self.num_bars = len(borders) - 1
self.ignore_nan_targets = ignore_nan_targets
self.to(borders.device)
def __setstate__(self, state):
super().__setstate__(state)
self.__dict__.setdefault('append_mean_pred', False)
def map_to_bucket_idx(self, y):
target_sample = torch.searchsorted(self.borders, y) - 1
target_sample[y == self.borders[0]] = 0
target_sample[y == self.borders[-1]] = self.num_bars - 1
return target_sample
def ignore_init(self, y):
ignore_loss_mask = torch.isnan(y)
if ignore_loss_mask.any():
if not self.ignore_nan_targets: raise ValueError(f'Found NaN in target {y}')
print_once("A loss was ignored because there was nan target.")
y[ignore_loss_mask] = self.borders[0] # this is just a default value, it will be ignored anyway
return ignore_loss_mask
def compute_scaled_log_probs(self, logits):
# this is equivalent to log(p(y)) of the density p
bucket_log_probs = torch.log_softmax(logits, -1)
scaled_bucket_log_probs = bucket_log_probs - torch.log(self.bucket_widths)
return scaled_bucket_log_probs
def forward(self, logits, y, mean_prediction_logits=None): # gives the negative log density (the _loss_), y: T x B, logits: T x B x self.num_bars
y = y.clone().view(*logits.shape[:-1]) # no trailing one dimension
ignore_loss_mask = self.ignore_init(y)
target_sample = self.map_to_bucket_idx(y)
assert (target_sample >= 0).all() and (target_sample < self.num_bars).all(), f'y {y} not in support set for borders (min_y, max_y) {self.borders}'
assert logits.shape[-1] == self.num_bars, f'{logits.shape[-1]} vs {self.num_bars}'
scaled_bucket_log_probs = self.compute_scaled_log_probs(logits)
nll_loss = -scaled_bucket_log_probs.gather(-1,target_sample[..., None]).squeeze(-1) # T x B
if mean_prediction_logits is not None:
if not self.training:
print('Calculating loss incl mean prediction loss for nonmyopic BO.')
scaled_mean_log_probs = self.compute_scaled_log_probs(mean_prediction_logits)
nll_loss = torch.cat((nll_loss, self.mean_loss(logits, scaled_mean_log_probs)), 0)
smooth_loss = -scaled_bucket_log_probs.mean(dim=-1)
smoothing = self.smoothing if self.training else 0.
loss = (1. - smoothing) * nll_loss + smoothing * smooth_loss
loss[ignore_loss_mask] = 0.
return loss
def mean_loss(self, logits, scaled_mean_logits):
assert (len(logits.shape) == 3) and (len(scaled_mean_logits.shape) == 2), \
(len(logits.shape), len(scaled_mean_logits.shape))
means = self.mean(logits).detach() # T x B
target_mean = self.map_to_bucket_idx(means).clamp_(0, self.num_bars - 1) # T x B
return -scaled_mean_logits.gather(1, target_mean.T).mean(1).unsqueeze(0) # 1 x B
def mean(self, logits):
bucket_means = self.borders[:-1] + self.bucket_widths/2
p = torch.softmax(logits, -1)
return p @ bucket_means
def median(self, logits):
return self.icdf(logits, 0.5)
def icdf(self, logits, left_prob):
"""
Implementation of the quantile function
:param logits: Tensor of any shape, with the last dimension being logits
:param left_prob: float: The probability mass to the left of the result.
:return: Position with `left_prob` probability weight to the left.
"""
probs = logits.softmax(-1)
cumprobs = torch.cumsum(probs, -1)
idx = torch.searchsorted(cumprobs, left_prob * torch.ones(*cumprobs.shape[:-1], 1, device=logits.device))\
.squeeze(-1).clamp(0, cumprobs.shape[-1] - 1) # this might not do the right for outliers
cumprobs = torch.cat([torch.zeros(*cumprobs.shape[:-1], 1, device=logits.device), cumprobs], -1)
rest_prob = left_prob - cumprobs.gather(-1, idx[..., None]).squeeze(-1)
left_border = self.borders[idx]
right_border = self.borders[idx+1]
return left_border + (right_border - left_border) * rest_prob / probs.gather(-1, idx[..., None]).squeeze(-1)
def quantile(self, logits, center_prob=.682):
side_probs = (1.-center_prob)/2
return torch.stack((self.icdf(logits, side_probs), self.icdf(logits, 1.-side_probs)),-1)
def ucb(self, logits, best_f, rest_prob=(1-.682)/2, maximize=True):
"""
UCB utility. Rest Prob is the amount of utility above (below) the confidence interval that is ignored.
Higher rest_prob is equivalent to lower beta in the standard GP-UCB formulation.
:param logits: Logits, as returned by the Transformer.
:param rest_prob: The amount of utility above (below) the confidence interval that is ignored.
The default is equivalent to using GP-UCB with `beta=1`.
To get the corresponding `beta`, where `beta` is from
the standard GP definition of UCB `ucb_utility = mean + beta * std`,
you can use this computation: `beta = math.sqrt(2)*torch.erfinv(torch.tensor(2*(1-rest_prob)-1))`.
:param maximize:
:return: utility
"""
if maximize:
rest_prob = 1 - rest_prob
return self.icdf(logits, rest_prob)
def mode(self, logits):
mode_inds = logits.argmax(-1)
bucket_means = self.borders[:-1] + self.bucket_widths/2
return bucket_means[mode_inds]
def ei(self, logits, best_f, maximize=True): # logits: evaluation_points x batch x feature_dim
bucket_diffs = self.borders[1:] - self.borders[:-1]
assert maximize
if not torch.is_tensor(best_f) or not len(best_f.shape):
best_f = torch.full(logits[...,0].shape, best_f, device=logits.device)
best_f = best_f[..., None].repeat(*[1]*len(best_f.shape), logits.shape[-1])
clamped_best_f = best_f.clamp(self.borders[:-1], self.borders[1:])
#bucket_contributions = (best_f[...,None] < self.borders[:-1]).float() * bucket_means
# true bucket contributions
bucket_contributions = ((self.borders[1:]**2-clamped_best_f**2)/2 - best_f*(self.borders[1:] - clamped_best_f))/bucket_diffs
p = torch.softmax(logits, -1)
return torch.einsum("...b,...b->...", p, bucket_contributions)
def pi(self, logits, best_f, maximize=True):# logits: evaluation_points x batch x feature_dim
"""
Acquisition Function: Probability of Improvement
:param logits: as returned by Transformer
:param best_f: best evaluation so far (the incumbent)
:param maximize: whether to maximize
:return: utility
"""
assert maximize is True
if not torch.is_tensor(best_f) or not len(best_f.shape):
best_f = torch.full(logits[...,0].shape, best_f, device=logits.device)
p = torch.softmax(logits, -1)
border_widths = self.borders[1:] - self.borders[:-1]
factor = 1. - ((best_f[...,None] - self.borders[:-1]) / border_widths).clamp(0., 1.)
return (p * factor).sum(-1)
def mean_of_square(self, logits):
"""
Computes E[x^2].
:param logits: Output of the model.
"""
left_borders = self.borders[:-1]
right_borders = self.borders[1:]
bucket_mean_of_square = (left_borders.square() + right_borders.square() + left_borders*right_borders)/3.
p = torch.softmax(logits, -1)
return p @ bucket_mean_of_square
def variance(self, logits):
return self.mean_of_square(logits) - self.mean(logits).square()
def pi(self, logits, best_f, maximize=True):# logits: evaluation_points x batch x feature_dim
"""
Acquisition Function: Probability of Improvement
:param logits: as returned by Transformer
:param best_f: best evaluation so far (the incumbent)
:param maximize: whether to maximize
:return: utility
"""
assert maximize is True
p = torch.softmax(logits, -1)
border_widths = self.borders[1:] - self.borders[:-1]
factor = 1. - ((best_f - self.borders[:-1]) / border_widths).clamp(0., 1.)
return (p * factor).sum(-1)
def mean_of_square(self, logits):
"""
Computes E[x^2].
:param logits: Output of the model.
"""
left_borders = self.borders[:-1]
right_borders = self.borders[1:]
bucket_mean_of_square = (left_borders.square() + right_borders.square() + left_borders*right_borders)/3.
p = torch.softmax(logits, -1)
return p @ bucket_mean_of_square
def variance(self, logits):
return self.mean_of_square(logits) - self.mean(logits).square()
class FullSupportBarDistribution(BarDistribution):
@staticmethod
def halfnormal_with_p_weight_before(range_max,p=.5):
s = range_max / torch.distributions.HalfNormal(torch.tensor(1.)).icdf(torch.tensor(p))
return torch.distributions.HalfNormal(s)
def forward(self, logits, y, mean_prediction_logits=None): # gives the negative log density (the _loss_), y: T x B, logits: T x B x self.num_bars
assert self.num_bars > 1
y = y.clone().view(len(y),-1) # no trailing one dimension
ignore_loss_mask = self.ignore_init(y) # alters y
target_sample = self.map_to_bucket_idx(y) # shape: T x B (same as y)
target_sample.clamp_(0, self.num_bars - 1)
assert logits.shape[-1] == self.num_bars, f'{logits.shape[-1]} vs {self.num_bars}'
assert (target_sample >= 0).all() and (target_sample < self.num_bars).all(), \
f'y {y} not in support set for borders (min_y, max_y) {self.borders}'
assert logits.shape[-1] == self.num_bars, f'{logits.shape[-1]} vs {self.num_bars}'
# ignore all position with nan values
scaled_bucket_log_probs = self.compute_scaled_log_probs(logits)
assert len(scaled_bucket_log_probs) == len(target_sample), (len(scaled_bucket_log_probs), len(target_sample))
log_probs = scaled_bucket_log_probs.gather(-1, target_sample.unsqueeze(-1)).squeeze(-1)
side_normals = (self.halfnormal_with_p_weight_before(self.bucket_widths[0]), self.halfnormal_with_p_weight_before(self.bucket_widths[-1]))
log_probs[target_sample == 0] += side_normals[0].log_prob((self.borders[1]-y[target_sample == 0]).clamp(min=.00000001)) + torch.log(self.bucket_widths[0])
log_probs[target_sample == self.num_bars-1] += side_normals[1].log_prob((y[target_sample == self.num_bars-1]-self.borders[-2]).clamp(min=.00000001)) + torch.log(self.bucket_widths[-1])
nll_loss = -log_probs
if mean_prediction_logits is not None:
assert not ignore_loss_mask.any(), "Ignoring examples is not implemented with mean pred."
if not self.training:
print('Calculating loss incl mean prediction loss for nonmyopic BO.')
if not torch.is_grad_enabled():
print("Warning: loss is not correct in absolute terms, only the gradient is right, when using `append_mean_pred`.")
scaled_mean_log_probs = self.compute_scaled_log_probs(mean_prediction_logits)
nll_loss = torch.cat((nll_loss, self.mean_loss(logits, scaled_mean_log_probs)), 0)
#ignore_loss_mask = torch.zeros_like(nll_loss, dtype=torch.bool)
if self.smoothing:
smooth_loss = -scaled_bucket_log_probs.mean(dim=-1)
smoothing = self.smoothing if self.training else 0.
nll_loss = (1. - smoothing) * nll_loss + smoothing * smooth_loss
if ignore_loss_mask.any():
nll_loss[ignore_loss_mask] = 0.
return nll_loss
def mean(self, logits):
bucket_means = self.borders[:-1] + self.bucket_widths / 2
p = torch.softmax(logits, -1)
side_normals = (self.halfnormal_with_p_weight_before(self.bucket_widths[0]),
self.halfnormal_with_p_weight_before(self.bucket_widths[-1]))
bucket_means[0] = -side_normals[0].mean + self.borders[1]
bucket_means[-1] = side_normals[1].mean + self.borders[-2]
return p @ bucket_means.to(logits.device)
def mean_of_square(self, logits):
"""
Computes E[x^2].
:param logits: Output of the model.
"""
left_borders = self.borders[:-1]
right_borders = self.borders[1:]
bucket_mean_of_square = (left_borders.square() + right_borders.square() + left_borders*right_borders)/3.
side_normals = (self.halfnormal_with_p_weight_before(self.bucket_widths[0]),
self.halfnormal_with_p_weight_before(self.bucket_widths[-1]))
bucket_mean_of_square[0] = side_normals[0].variance + (-side_normals[0].mean + self.borders[1]).square()
bucket_mean_of_square[-1] = side_normals[1].variance + (side_normals[1].variance + self.borders[-2]).square()
p = torch.softmax(logits, -1)
return p @ bucket_mean_of_square
def pi(self, logits, best_f, maximize=True):# logits: evaluation_points x batch x feature_dim
"""
Acquisition Function: Probability of Improvement
:param logits: as returned by Transformer (evaluation_points x batch x feature_dim)
:param best_f: best evaluation so far (the incumbent)
:param maximize: whether to maximize
:return: utility
"""
assert maximize is True
if not torch.is_tensor(best_f) or not len(best_f.shape):
best_f = torch.full(logits[...,0].shape, best_f, device=logits.device) # evaluation_points x batch
assert best_f.shape == logits[...,0].shape, f"best_f.shape: {best_f.shape}, logits.shape: {logits.shape}"
p = torch.softmax(logits, -1) # evaluation_points x batch
border_widths = self.borders[1:] - self.borders[:-1]
factor = 1. - ((best_f[...,None] - self.borders[:-1]) / border_widths).clamp(0., 1.) # evaluation_points x batch x num_bars
side_normals = (self.halfnormal_with_p_weight_before(self.bucket_widths[0]),
self.halfnormal_with_p_weight_before(self.bucket_widths[-1]))
position_in_side_normals = (-(best_f - self.borders[1]).clamp(max=0.), (best_f - self.borders[-2]).clamp(min=0.)) # evaluation_points x batch
factor[...,0] = 0.
factor[...,0][position_in_side_normals[0] > 0.] = side_normals[0].cdf(position_in_side_normals[0][position_in_side_normals[0] > 0.])
factor[...,-1] = 1.
factor[...,-1][position_in_side_normals[1] > 0.] = 1. - side_normals[1].cdf(position_in_side_normals[1][position_in_side_normals[1] > 0.])
return (p * factor).sum(-1)
def ei_for_halfnormal(self, scale, best_f, maximize=True):
"""
This is the EI for a standard normal distribution with mean 0 and variance `scale` times 2.
Which is the same as the half normal EI.
I tested this with MC approximation:
ei_for_halfnormal = lambda scale, best_f: (torch.distributions.HalfNormal(torch.tensor(scale)).sample((10_000_000,))- best_f ).clamp(min=0.).mean()
print([(ei_for_halfnormal(scale,best_f), FullSupportBarDistribution().ei_for_halfnormal(scale,best_f)) for scale in [0.1,1.,10.] for best_f in [.1,10.,4.]])
:param scale:
:param best_f:
:param maximize:
:return:
"""
assert maximize
mean = torch.tensor(0.)
u = (mean - best_f) / scale
normal = torch.distributions.Normal(torch.zeros_like(u), torch.ones_like(u))
try:
ucdf = normal.cdf(u)
except ValueError:
print(f"u: {u}, best_f: {best_f}, scale: {scale}")
raise
updf = torch.exp(normal.log_prob(u))
normal_ei = scale * (updf + u * ucdf)
return 2*normal_ei
def ei(self, logits, best_f, maximize=True): # logits: evaluation_points x batch x feature_dim
if torch.isnan(logits).any():
raise ValueError(f"logits contains NaNs: {logits}")
bucket_diffs = self.borders[1:] - self.borders[:-1]
assert maximize
if not torch.is_tensor(best_f) or not len(best_f.shape):
best_f = torch.full(logits[...,0].shape, best_f, device=logits.device)
assert best_f.shape == logits[...,0].shape, f"best_f.shape: {best_f.shape}, logits.shape: {logits.shape}"
best_f_per_logit = best_f[..., None].repeat(*[1]*len(best_f.shape), logits.shape[-1])
clamped_best_f = best_f_per_logit.clamp(self.borders[:-1], self.borders[1:])
# true bucket contributions
bucket_contributions = ((self.borders[1:]**2-clamped_best_f**2)/2 - best_f_per_logit*(self.borders[1:] - clamped_best_f))/bucket_diffs
# extra stuff for continuous
side_normals = (self.halfnormal_with_p_weight_before(self.bucket_widths[0]),
self.halfnormal_with_p_weight_before(self.bucket_widths[-1]))
position_in_side_normals = (-(best_f - self.borders[1]).clamp(max=0.),
(best_f - self.borders[-2]).clamp(min=0.)) # evaluation_points x batch
bucket_contributions[...,-1] = self.ei_for_halfnormal(side_normals[1].scale, position_in_side_normals[1])
bucket_contributions[...,0] = self.ei_for_halfnormal(side_normals[0].scale, torch.zeros_like(position_in_side_normals[0])) \
- self.ei_for_halfnormal(side_normals[0].scale, position_in_side_normals[0])
p = torch.softmax(logits, -1)
return torch.einsum("...b,...b->...", p, bucket_contributions)
def get_bucket_limits(num_outputs:int, full_range:tuple=None, ys:torch.Tensor=None, verbose:bool=False):
assert (ys is None) != (full_range is None), 'Either full_range or ys must be passed.'
if ys is not None:
ys = ys.flatten()
ys = ys[~torch.isnan(ys)]
if len(ys) % num_outputs: ys = ys[:-(len(ys) % num_outputs)]
print(f'Using {len(ys)} y evals to estimate {num_outputs} buckets. Cut off the last {len(ys) % num_outputs} ys.')
ys_per_bucket = len(ys) // num_outputs
if full_range is None:
full_range = (ys.min(), ys.max())
else:
assert full_range[0] <= ys.min() and full_range[1] >= ys.max(), f'full_range {full_range} not in range of ys {ys.min(), ys.max()}'
full_range = torch.tensor(full_range)
ys_sorted, ys_order = ys.sort(0)
bucket_limits = (ys_sorted[ys_per_bucket-1::ys_per_bucket][:-1]+ys_sorted[ys_per_bucket::ys_per_bucket])/2
if verbose:
print(f'Using {len(ys)} y evals to estimate {num_outputs} buckets. Cut off the last {len(ys) % num_outputs} ys.')
print(full_range)
bucket_limits = torch.cat([full_range[0].unsqueeze(0), bucket_limits, full_range[1].unsqueeze(0)],0)
else:
class_width = (full_range[1] - full_range[0]) / num_outputs
bucket_limits = torch.cat([full_range[0] + torch.arange(num_outputs).float()*class_width, torch.tensor(full_range[1]).unsqueeze(0)], 0)
assert len(bucket_limits) - 1 == num_outputs, f'len(bucket_limits) - 1 == {len(bucket_limits) - 1} != {num_outputs} == num_outputs'
assert full_range[0] == bucket_limits[0], f'{full_range[0]} != {bucket_limits[0]}'
assert full_range[-1] == bucket_limits[-1], f'{full_range[-1]} != {bucket_limits[-1]}'
return bucket_limits
def get_custom_bar_dist(borders, criterion):
# Tested that a bar_dist with borders 0.54 (-> softplus 1.0) yields the same bar distribution as the passed one.
borders_ = torch.nn.functional.softplus(borders) + 0.001
borders_ = (torch.cumsum(torch.cat([criterion.borders[0:1], criterion.bucket_widths]) * borders_, 0))
criterion_ = criterion.__class__(borders=borders_, handle_nans=criterion.handle_nans)
return criterion_