import logging
import numpy as np
import torch
from collections import OrderedDict
from .base import ConditionalEstimator, TheresAGoodReasonThisDoesntWork
from ..utils.ml.eval import evaluate_ratio_model
from ..utils.ml.models.ratio import DenseSingleParameterizedRatioModel
from ..utils.ml.trainer import SingleParameterizedRatioTrainer
from ..utils.ml.utils import get_optimizer, get_loss
from ..utils.various import load_and_check, shuffle, restrict_samplesize
logger = logging.getLogger(__name__)
[docs]class ParameterizedRatioEstimator(ConditionalEstimator):
"""
A neural estimator of the likelihood ratio as a function of the observation x as well as
the numerator hypothesis theta. The reference (denominator) hypothesis is kept fixed at some
reference value and NOT modeled by the network.
"""
[docs] def train(
self,
method,
x,
y,
theta,
r_xz=None,
t_xz=None,
x_val=None,
y_val=None,
theta_val=None,
r_xz_val=None,
t_xz_val=None,
alpha=1.0,
optimizer="amsgrad",
n_epochs=50,
batch_size=128,
initial_lr=0.001,
final_lr=0.0001,
nesterov_momentum=None,
validation_split=0.25,
early_stopping=True,
scale_inputs=True,
shuffle_labels=False,
limit_samplesize=None,
memmap=False,
verbose="some",
scale_parameters=True,
n_workers=8,
clip_gradient=None,
early_stopping_patience=None,
):
"""
Trains the network.
Parameters
----------
method : str
The inference method used for training. Allowed values are 'alice', 'alices', 'carl', 'cascal', 'rascal',
and 'rolr'.
x : ndarray or str
Observations, or filename of a pickled numpy array.
y : ndarray or str
Class labels (0 = numerator, 1 = denominator), or filename of a pickled numpy array.
theta : ndarray or str
Numerator parameter point, or filename of a pickled numpy array.
r_xz : ndarray or str or None, optional
Joint likelihood ratio, or filename of a pickled numpy array. Default value: None.
t_xz : ndarray or str or None, optional
Joint scores at theta, or filename of a pickled numpy array. Default value: None.
x_val : ndarray or str or None, optional
Validation observations, or filename of a pickled numpy array. If None
and validation_split > 0, validation data will be randomly selected from the training data.
Default value: None.
y_val : ndarray or str or None, optional
Validation labels (0 = numerator, 1 = denominator), or filename of a pickled numpy array. If None
and validation_split > 0, validation data will be randomly selected from the training data.
Default value: None.
theta_val : ndarray or str or None, optional
Validation numerator parameter points, or filename of a pickled numpy array. If None
and validation_split > 0, validation data will be randomly selected from the training data.
Default value: None.
r_xz_val : ndarray or str or None, optional
Validation joint likelihood ratio, or filename of a pickled numpy array. If None
and validation_split > 0, validation data will be randomly selected from the training data.
Default value: None.
t_xz_val : ndarray or str or None, optional
Validation joint scores at theta, or filename of a pickled numpy array. If None
and validation_split > 0, validation data will be randomly selected from the training data.
Default value: None.
alpha : float, optional
Hyperparameter weighting the score error in the loss function of the 'alices', 'rascal', and 'cascal'
methods. Default value: 1.
optimizer : {"adam", "amsgrad", "sgd"}, optional
Optimization algorithm. Default value: "amsgrad".
n_epochs : int, optional
Number of epochs. Default value: 50.
batch_size : int, optional
Batch size. Default value: 128.
initial_lr : float, optional
Learning rate during the first epoch, after which it exponentially decays to final_lr. Default value:
0.001.
final_lr : float, optional
Learning rate during the last epoch. Default value: 0.0001.
nesterov_momentum : float or None, optional
If trainer is "sgd", sets the Nesterov momentum. Default value: None.
validation_split : float or None, optional
Fraction of samples used for validation and early stopping (if early_stopping is True). If None, the entire
sample is used for training and early stopping is deactivated. Default value: 0.25.
early_stopping : bool, optional
Activates early stopping based on the validation loss (only if validation_split is not None). Default value:
True.
scale_inputs : bool, optional
Scale the observables to zero mean and unit variance. Default value: True.
shuffle_labels : bool, optional
If True, the labels (`y`, `r_xz`, `t_xz`) are shuffled, while the observations (`x`) remain in their
normal order. This serves as a closure test, in particular as cross-check against overfitting: an estimator
trained with shuffle_labels=True should predict to likelihood ratios around 1 and scores around 0.
limit_samplesize : int or None, optional
If not None, only this number of samples (events) is used to train the estimator. Default value: None.
memmap : bool, optional.
If True, training files larger than 1 GB will not be loaded into memory at once. Default value: False.
verbose : {"all", "many", "some", "few", "none}, optional
Determines verbosity of training. Default value: "some".
scale_parameters : bool, optional
Whether parameters are rescaled to mean zero and unit variance before going into the neural network.
Default value: True.
Returns
-------
result: ndarray
Training and validation losses from SingleParameterizedRatioTrainer.train or DoubleParameterizedRatioTrainer.train for example
"""
logger.info("Starting training")
logger.info(" Method: %s", method)
if method in ["cascal", "rascal", "alices"]:
logger.info(" alpha: %s", alpha)
logger.info(" Batch size: %s", batch_size)
logger.info(" Optimizer: %s", optimizer)
logger.info(" Epochs: %s", n_epochs)
logger.info(" Learning rate: %s initially, decaying to %s", initial_lr, final_lr)
if optimizer == "sgd":
logger.info(" Nesterov momentum: %s", nesterov_momentum)
logger.info(" Validation split: %s", validation_split)
logger.info(" Early stopping: %s", early_stopping)
logger.info(" Scale inputs: %s", scale_inputs)
logger.info(" Scale parameters: %s", scale_parameters)
logger.info(" Shuffle labels %s", shuffle_labels)
if limit_samplesize is None:
logger.info(" Samples: all")
else:
logger.info(" Samples: %s", limit_samplesize)
# Load training data
logger.info("Loading training data")
memmap_threshold = 1.0 if memmap else None
theta = load_and_check(theta, memmap_files_larger_than_gb=memmap_threshold)
x = load_and_check(x, memmap_files_larger_than_gb=memmap_threshold)
y = load_and_check(y, memmap_files_larger_than_gb=memmap_threshold)
r_xz = load_and_check(r_xz, memmap_files_larger_than_gb=memmap_threshold)
t_xz = load_and_check(t_xz, memmap_files_larger_than_gb=memmap_threshold)
self._check_required_data(method, r_xz, t_xz)
# Infer dimensions of problem
n_samples = x.shape[0]
n_observables = x.shape[1]
n_parameters = theta.shape[1]
logger.info("Found %s samples with %s parameters and %s observables", n_samples, n_parameters, n_observables)
# Limit sample size
if limit_samplesize is not None and limit_samplesize < n_samples:
logger.info("Only using %s of %s training samples", limit_samplesize, n_samples)
x, theta, y, r_xz, t_xz = restrict_samplesize(limit_samplesize, x, theta, y, r_xz, t_xz)
# Validation data
external_validation = x_val is not None and y_val is not None and theta_val is not None
if external_validation:
theta_val = load_and_check(theta_val, memmap_files_larger_than_gb=memmap_threshold)
x_val = load_and_check(x_val, memmap_files_larger_than_gb=memmap_threshold)
y_val = load_and_check(y_val, memmap_files_larger_than_gb=memmap_threshold)
r_xz_val = load_and_check(r_xz_val, memmap_files_larger_than_gb=memmap_threshold)
t_xz_val = load_and_check(t_xz_val, memmap_files_larger_than_gb=memmap_threshold)
logger.info("Found %s separate validation samples", x_val.shape[0])
assert x_val.shape[1] == n_observables
assert theta_val.shape[1] == n_parameters
if r_xz is not None:
assert r_xz_val is not None, "When providing r_xz and sep. validation data, also provide r_xz_val"
if t_xz is not None:
assert t_xz_val is not None, "When providing t_xz and sep. validation data, also provide t_xz_val"
# Scale features
if scale_inputs:
self.initialize_input_transform(x, overwrite=False)
x = self._transform_inputs(x)
if external_validation:
x_val = self._transform_inputs(x_val)
else:
self.initialize_input_transform(x, False, overwrite=False)
# Scale parameters
if scale_parameters:
logger.info("Rescaling parameters")
self.initialize_parameter_transform(theta)
theta = self._transform_parameters(theta)
t_xz = self._transform_score(t_xz, inverse=False)
if external_validation:
t_xz_val = self._transform_score(t_xz_val, inverse=False)
else:
self.initialize_parameter_transform(theta, False)
# Shuffle labels
if shuffle_labels:
logger.info("Shuffling labels")
y, r_xz, t_xz = shuffle(y, r_xz, t_xz)
# Features
if self.features is not None:
x = x[:, self.features]
logger.info("Only using %s of %s observables", x.shape[1], n_observables)
n_observables = x.shape[1]
if external_validation:
x_val = x_val[:, self.features]
# Check consistency of input with model
if self.n_observables is None:
self.n_observables = n_observables
if self.n_parameters is None:
self.n_parameters = n_parameters
if n_parameters != self.n_parameters:
raise RuntimeError(f"Number of parameters does not match: {n_parameters} vs {self.n_parameters}")
if n_observables != self.n_observables:
raise RuntimeError(f"Number of observables does not match: {n_observables} vs {self.n_observables}")
# Data
data = self._package_training_data(method, x, theta, y, r_xz, t_xz)
if external_validation:
data_val = self._package_training_data(method, x_val, theta_val, y_val, r_xz_val, t_xz_val)
else:
data_val = None
# Create model
if self.model is None:
logger.info("Creating model")
self._create_model()
# Losses
loss_functions, loss_labels, loss_weights = get_loss(method, alpha)
# Optimizer
opt, opt_kwargs = get_optimizer(optimizer, nesterov_momentum)
# Train model
logger.info("Training model")
trainer = SingleParameterizedRatioTrainer(self.model, n_workers=n_workers)
result = trainer.train(
data=data,
data_val=data_val,
loss_functions=loss_functions,
loss_weights=loss_weights,
loss_labels=loss_labels,
epochs=n_epochs,
batch_size=batch_size,
optimizer=opt,
optimizer_kwargs=opt_kwargs,
initial_lr=initial_lr,
final_lr=final_lr,
validation_split=validation_split,
early_stopping=early_stopping,
verbose=verbose,
clip_gradient=clip_gradient,
early_stopping_patience=early_stopping_patience,
)
return result
[docs] def evaluate_log_likelihood_ratio(self, x, theta, test_all_combinations=True, evaluate_score=False):
"""
Evaluates the log likelihood ratio for given observations x between the given parameter point theta and the
reference hypothesis.
Parameters
----------
x : str or ndarray
Observations or filename of a pickled numpy array.
theta : ndarray or str
Parameter points or filename of a pickled numpy array.
test_all_combinations : bool, optional
If False, the number of samples in the observable and theta
files has to match, and the likelihood ratio is evaluated only for the combinations
`r(x_i | theta0_i, theta1_i)`. If True, `r(x_i | theta0_j, theta1_j)` for all pairwise combinations `i, j`
are evaluated. Default value: True.
evaluate_score : bool, optional
Sets whether in addition to the likelihood ratio the score is evaluated. Default value: False.
Returns
-------
log_likelihood_ratio : ndarray
The estimated log likelihood ratio. If test_all_combinations is True, the result has shape
`(n_thetas, n_x)`. Otherwise, it has shape `(n_samples,)`.
score : ndarray or None
None if evaluate_score is False. Otherwise the derived estimated score at `theta0`. If test_all_combinations
is True, the result has shape `(n_thetas, n_x, n_parameters)`. Otherwise, it has shape
`(n_samples, n_parameters)`.
"""
if self.model is None:
raise ValueError("No model -- train or load model before evaluating it!")
# Load training data
logger.debug("Loading evaluation data")
x = load_and_check(x)
theta = load_and_check(theta)
# Scale observables
x = self._transform_inputs(x)
theta = self._transform_parameters(theta)
# Restrict features
if self.features is not None:
x = x[:, self.features]
all_log_r_hat = []
all_t_hat = []
if test_all_combinations:
logger.debug("Starting ratio evaluation for %s x-theta combinations", len(theta) * len(x))
for i, this_theta in enumerate(theta, start=1):
logger.debug("Starting ratio evaluation for thetas %s / %s: %s", i, len(theta), this_theta)
_, log_r_hat, t_hat, _ = evaluate_ratio_model(
model=self.model,
method_type="parameterized_ratio",
theta0s=[this_theta],
theta1s=None,
xs=x,
evaluate_score=evaluate_score,
)
t_hat = self._transform_score(t_hat, inverse=True)
all_log_r_hat.append(log_r_hat)
all_t_hat.append(t_hat)
all_log_r_hat = np.array(all_log_r_hat)
all_t_hat = np.array(all_t_hat)
else:
logger.debug("Starting ratio evaluation")
_, all_log_r_hat, all_t_hat, _ = evaluate_ratio_model(
model=self.model,
method_type="parameterized_ratio",
theta0s=theta,
theta1s=None,
xs=x,
evaluate_score=evaluate_score,
)
all_t_hat = self._transform_score(all_t_hat, inverse=True)
logger.debug("Evaluation done")
return all_log_r_hat, all_t_hat
[docs] def evaluate_log_likelihood_ratio_torch(self, x, theta, test_all_combinations=True):
"""
Evaluates the log likelihood ratio for given observations x between the given parameter point theta and the
reference hypothesis.
Parameters
----------
x : torch.tensor
Observations.
theta : torch.tensor
Parameter points.
test_all_combinations : bool, optional
If False, the number of samples in the observable and theta
files has to match, and the likelihood ratio is evaluated only for the combinations
`r(x_i | theta0_i, theta1_i)`. If True, `r(x_i | theta0_j, theta1_j)` for all pairwise combinations `i, j`
are evaluated. Default value: True.
Returns
-------
log_likelihood_ratio : torch.tensor
The estimated log likelihood ratio. If test_all_combinations is True, the result has shape
`(n_thetas, n_x)`. Otherwise, it has shape `(n_samples,)`.
"""
if self.model is None:
raise ValueError("No model -- train or load model before evaluating it!")
# Scale observables and parameters
x = self._transform_inputs(x)
theta = self._transform_parameters(theta)
# Eval
if test_all_combinations:
logger.debug("Starting ratio evaluation for %s x-theta combinations", len(theta) * len(x))
all_log_r_hat = []
for theta_ in theta:
theta_ = torch.stack([theta_ for _ in x])
_, log_r_hat, _ = self.model(theta_, x)
all_log_r_hat.append(log_r_hat[:, 0].unsqueeze(0))
log_r_hat = torch.cat(all_log_r_hat, dim=0)
else:
logger.debug("Starting ratio evaluation")
_, log_r_hat = self.model(theta, x)
return log_r_hat
[docs] def evaluate_log_likelihood(self, *args, **kwargs):
raise TheresAGoodReasonThisDoesntWork(
"This estimator can only estimate likelihood ratios, not the likelihood itself!"
)
[docs] def evaluate_score(self, x, theta, nuisance_mode="keep"):
"""
Evaluates the scores for given observations x between at a given parameter point theta.
Parameters
----------
x : str or ndarray
Observations or filename of a pickled numpy array.
theta : ndarray or str
Parameter points or filename of a pickled numpy array.
nuisance_mode : {"auto", "keep", "profile", "project"}
Decides how nuisance parameters are treated. If nuisance_mode is "keep", the
returned score is always (n+k)-dimensional.
Returns
-------
score : ndarray or None
The estimated score at `theta`. If test_all_combinations is True, the
result has shape `(n_thetas, n_x, n_parameters)`. Otherwise, it has shape
`(n_samples, n_parameters)`.
"""
if nuisance_mode == "keep":
logger.debug("Keeping nuisance parameter in score")
else:
raise ValueError(f"Unknown nuisance_mode {nuisance_mode}")
_, all_t_hat = self.evaluate_log_likelihood_ratio(x, theta, test_all_combinations=False, evaluate_score=True)
return all_t_hat
[docs] def evaluate(self, *args, **kwargs):
return self.evaluate_log_likelihood_ratio(*args, **kwargs)
def _create_model(self):
self.model = DenseSingleParameterizedRatioModel(
n_observables=self.n_observables,
n_parameters=self.n_parameters,
n_hidden=self.n_hidden,
activation=self.activation,
dropout_prob=self.dropout_prob,
)
@staticmethod
def _check_required_data(method, r_xz, t_xz):
if method in {"cascal", "alices", "rascal"} and t_xz is None:
raise RuntimeError(f"Method {method} requires joint score information")
if method in {"rolr", "alice", "alices", "rascal"} and r_xz is None:
raise RuntimeError(f"Method {method} requires joint likelihood ratio information")
@staticmethod
def _package_training_data(method, x, theta, y, r_xz, t_xz):
data = OrderedDict()
data["x"] = x
data["theta"] = theta
data["y"] = y
if method in ["rolr", "alice", "alices", "rascal"]:
data["r_xz"] = r_xz
if method in ["cascal", "alices", "rascal"]:
data["t_xz"] = t_xz
return data
def _wrap_settings(self):
settings = super()._wrap_settings()
settings["estimator_type"] = "parameterized_ratio"
return settings
def _unwrap_settings(self, settings):
super()._unwrap_settings(settings)
estimator_type = str(settings["estimator_type"])
if estimator_type != "parameterized_ratio":
raise RuntimeError(f"Saved model is an incompatible estimator type {estimator_type}.")