class_docstr: The acquisition function for the Joint Entropy Search, where the batches
  `q > 1` are supported through the lower bound formulation.
  
  This acquisition function computes the mutual information between the observation
  at a candidate point `X` and the optimal input-output pair.
  
  See [Tu2022joint]_ for a discussion on the estimation procedure.
class_docstr_tok: ["The","acquisition","function","for","the","Joint","Entropy","Search",",","where","the","batches","`","q",">","1","`","are","supported","through","the","lower","bound","formulation",".","This","acquisition","function","computes","the","mutual","information","between","the","observation","at","a","candidate","point","`","X","`","and","the","optimal","input-output","pair",".","See","[","Tu2022joint","]","_","for","a","discussion","on","the","estimation","procedure","."]
docstr: Evaluates the lower bound information gain at the design points `X`.
  
  Args:
      X: A `batch_shape x q x d`-dim Tensor of `batch_shape` t-batches with `q`
      `d`-dim design points each.
  
  Returns:
      A `batch_shape`-dim Tensor of acquisition values at the given design
      points `X`.
docstr_tok: ["Evaluates","the","lower","bound","information","gain","at","the","design","points","`","X","`",".","Args",":","X",":","A","`","batch_shape","x","q","x","d","`","-dim","Tensor","of","`","batch_shape","`","t-batches","with","`","q","`","`","d","`","-dim","design","points","each",".","Returns",":","A","`","batch_shape","`","-dim","Tensor","of","acquisition","values","at","the","given","design","points","`","X","`","."]
code: def _compute_monte_carlo_information_gain(
      self, X: Tensor, return_parts: bool = False
  ) -> Tensor:
      r"""Evaluates the lower bound information gain at the design points `X`.
  
      Args:
          X: A `batch_shape x q x d`-dim Tensor of `batch_shape` t-batches with `q`
          `d`-dim design points each.
  
      Returns:
          A `batch_shape`-dim Tensor of acquisition values at the given design
          points `X`.
      """
      initial_posterior = self.initial_model.posterior(
          X, observation_noise=True
      )
  
      batch_shape = X.shape[:-2]
      sample_dim = len(batch_shape)
      # We DISREGARD the additional constant term.
      initial_entropy = MC_ADD_TERM + 0.5 * torch.logdet(
          initial_posterior.mvn.lazy_covariance_matrix
      )
  
      # initial_entropy of shape batch_size or batch_size x num_models if FBGP
      # first need to unsqueeze the sample dim (after batch dim), then the two last
      initial_entropy = (
          initial_entropy.unsqueeze(sample_dim)
          .unsqueeze(-1)
          .unsqueeze(-1)
      )
  
      # Compute the mixture mean and variance
      posterior_m = self.conditional_model.posterior(
          X.unsqueeze(MCMC_DIM), observation_noise=True
      )
      noiseless_var = self.conditional_model.posterior(
          X.unsqueeze(MCMC_DIM), observation_noise=False
      ).variance
  
      mean_m = posterior_m.mean
      if not self.maximize:
          mean_m = -mean_m
      variance_m = posterior_m.variance.clamp_min(CLAMP_LB)
      (
          conditional_samples,
          conditional_logprobs,
      ) = self._compute_monte_carlo_variables(posterior_m)
  
      normalized_samples = (
          conditional_samples - mean_m
      ) / variance_m.sqrt()
      # Correlation between noisy observations and noiseless values f
      rho = (noiseless_var / variance_m).sqrt()
  
      normal = Normal(
          torch.zeros(1, device=X.device, dtype=X.dtype),
          torch.ones(1, device=X.device, dtype=X.dtype),
      )
      # prepare max value quantities and re-scale as required
      normalized_mvs = (
          self.optimal_outputs - mean_m
      ) / noiseless_var.sqrt()
      mvs_rescaled_mc = (normalized_mvs - rho * normalized_samples) / (
          1 - rho**2
      )
      cdf_mvs = normal.cdf(normalized_mvs).clamp_min(CLAMP_LB)
      cdf_rescaled_mvs = normal.cdf(mvs_rescaled_mc).clamp_min(CLAMP_LB)
      mv_ratio = cdf_rescaled_mvs / cdf_mvs
  
      log_term = torch.log(mv_ratio) + conditional_logprobs
      conditional_entropy = -(mv_ratio * log_term).mean(0)
      entropy_reduction = (
          initial_entropy
          - conditional_entropy.sum(dim=-2, keepdim=True)
      ).squeeze(-1)
  
      # average over the number of optima and squeeze the q-batch
      entropy_reduction = entropy_reduction.mean(
          dim=sample_dim
      ).squeeze(-1)
  
      return entropy_reduction
  
imports: from __future__ import annotations
  import warnings
  from math import log, pi
  from typing import Optional
  import torch
  from botorch import settings
  from botorch.acquisition.acquisition import AcquisitionFunction, MCSamplerMixin
  from botorch.acquisition.objective import PosteriorTransform
  from botorch.models.fully_bayesian import SaasFullyBayesianSingleTaskGP
  from botorch.models.gp_regression import MIN_INFERRED_NOISE_LEVEL
  from botorch.models.model import Model
  from botorch.models.utils import check_no_nans, fantasize
  from botorch.sampling.normal import SobolQMCNormalSampler
  from botorch.utils.transforms import concatenate_pending_points, t_batch_mode_transform
  from torch import Tensor
  from torch.distributions import Normal
label_desc: Use image node_id 5 for calling the qJointEntropySearch obj's underlying member method code with example usage: obj._compute_monte_carlo_information_gain(X, return_parts) and returns: entropy_reduction