diff --git a/bilby/core/result.py b/bilby/core/result.py index 7dcb4b04f426e461f6aebfb1ecf5b818b99f39f5..3e2ef48fe5954a84007ff61f0c49ce8e453ec21d 100644 --- a/bilby/core/result.py +++ b/bilby/core/result.py @@ -5,6 +5,7 @@ from copy import copy from distutils.version import LooseVersion from importlib import import_module from itertools import product +from tqdm import tqdm import corner import h5py @@ -103,7 +104,7 @@ def read_in_result(filename=None, outdir=None, label=None, extension='json', gzi def get_weights_for_reweighting( result, new_likelihood=None, new_prior=None, old_likelihood=None, - old_prior=None): + old_prior=None, resume_file=None, n_checkpoint=5000): """ Calculate the weights for reweight() See bilby.core.result.reweight() for help with the inputs @@ -116,17 +117,33 @@ def get_weights_for_reweighting( An array of the natural-log likelihoods new_log_prior_array: array An array of the natural-log priors + resume_file: string + filepath for the resume file which stores the weights + n_checkpoint: int + Number of samples to reweight before writing a resume file """ + nposterior = len(result.posterior) - old_log_likelihood_array = np.zeros(nposterior) - old_log_prior_array = np.zeros(nposterior) - new_log_likelihood_array = np.zeros(nposterior) - new_log_prior_array = np.zeros(nposterior) - for ii, sample in result.posterior.iterrows(): + if (resume_file is not None) and os.path.exists(resume_file): + old_log_likelihood_array, old_log_prior_array, new_log_likelihood_array, new_log_prior_array = \ + np.genfromtxt(resume_file) + + starting_index = np.argmin(np.abs(old_log_likelihood_array)) + logger.info(f'Checkpoint resuming from {starting_index}.') + + else: + old_log_likelihood_array = np.zeros(nposterior) + old_log_prior_array = np.zeros(nposterior) + new_log_likelihood_array = np.zeros(nposterior) + new_log_prior_array = np.zeros(nposterior) + + starting_index = 0 + + for ii, sample in tqdm(result.posterior.iloc[starting_index:].iterrows()): # Convert sample to dictionary - par_sample = {key: sample[key] for key in result.search_parameter_keys} + par_sample = {key: sample[key] for key in result.posterior} if old_likelihood is not None: old_likelihood.parameters.update(par_sample) @@ -152,10 +169,17 @@ def get_weights_for_reweighting( # Don't perform prior reweighting (i.e. prior isn't updated) new_log_prior_array[ii] = old_log_prior_array[ii] + if (ii % (n_checkpoint) == 0) and (resume_file is not None): + checkpointed_index = np.argmin(np.abs(old_log_likelihood_array)) + logger.info(f'Checkpointing with {checkpointed_index} samples') + np.savetxt( + resume_file, + [old_log_likelihood_array, old_log_prior_array, new_log_likelihood_array, new_log_prior_array]) + ln_weights = ( new_log_likelihood_array + new_log_prior_array - old_log_likelihood_array - old_log_prior_array) - return ln_weights, new_log_likelihood_array, new_log_prior_array + return ln_weights, new_log_likelihood_array, new_log_prior_array, old_log_likelihood_array, old_log_prior_array def rejection_sample(posterior, weights): @@ -179,7 +203,9 @@ def rejection_sample(posterior, weights): def reweight(result, label=None, new_likelihood=None, new_prior=None, - old_likelihood=None, old_prior=None): + old_likelihood=None, old_prior=None, conversion_function=None, npool=1, + verbose_output=False, resume_file=None, n_checkpoint=5000, + use_nested_samples=False): """ Reweight a result to a new likelihood/prior using rejection sampling Parameters @@ -198,6 +224,21 @@ def reweight(result, label=None, new_likelihood=None, new_prior=None, old_prior: bilby.core.prior.PriorDict, (optional) If given, calculate the old prior from this object. If not given, the values stored in the posterior are used. + conversion_function: function, optional + Function which adds in extra parameters to the data frame, + should take the data_frame, likelihood and prior as arguments. + npool: int, optional + Number of threads with which to execute the conversion function + verbose_output: bool, optional + Flag determining whether the weight array and associated prior and + likelihood evaluations are output as well as the result file + resume_file: string, optional + filepath for the resume file which stores the weights + n_checkpoint: int, optional + Number of samples to reweight before writing a resume file + use_nested_samples: bool, optional + If true reweight the nested samples instead. This can greatly improve reweighting efficiency, especially if the + target distribution has support beyond the proposal posterior distribution. Returns ======= @@ -207,32 +248,56 @@ def reweight(result, label=None, new_likelihood=None, new_prior=None, """ result = copy(result) + + if use_nested_samples: + result.posterior = result.nested_samples + nposterior = len(result.posterior) logger.info("Reweighting posterior with {} samples".format(nposterior)) - ln_weights, new_log_likelihood_array, new_log_prior_array = get_weights_for_reweighting( - result, new_likelihood=new_likelihood, new_prior=new_prior, - old_likelihood=old_likelihood, old_prior=old_prior) + ln_weights, new_log_likelihood_array, new_log_prior_array, old_log_likelihood_array, old_log_prior_array =\ + get_weights_for_reweighting( + result, new_likelihood=new_likelihood, new_prior=new_prior, + old_likelihood=old_likelihood, old_prior=old_prior, + resume_file=resume_file, n_checkpoint=n_checkpoint) + + weights = np.exp(ln_weights) + + if use_nested_samples: + weights *= result.posterior['weights'] # Overwrite the likelihood and prior evaluations result.posterior["log_likelihood"] = new_log_likelihood_array result.posterior["log_prior"] = new_log_prior_array - weights = np.exp(ln_weights) - result.posterior = rejection_sample(result.posterior, weights=weights) + result.posterior = result.posterior.reset_index(drop=True) logger.info("Rejection sampling resulted in {} samples".format(len(result.posterior))) result.meta_data["reweighted_using_rejection_sampling"] = True - result.log_evidence += logsumexp(ln_weights) - np.log(nposterior) - result.priors = new_prior + if use_nested_samples: + result.log_evidence += np.log(np.sum(weights)) + else: + result.log_evidence += logsumexp(ln_weights) - np.log(nposterior) + + if conversion_function is not None: + data_frame = result.posterior + if "npool" in inspect.getargspec(conversion_function).args: + data_frame = conversion_function(data_frame, new_likelihood, new_prior, npool=npool) + else: + data_frame = conversion_function(data_frame, new_likelihood, new_prior) + result.posterior = data_frame if label: result.label = label else: result.label += "_reweighted" - return result + if verbose_output: + return result, weights, new_log_likelihood_array, \ + new_log_prior_array, old_log_likelihood_array, old_log_prior_array + else: + return result class Result(object): @@ -1398,7 +1463,7 @@ class Result(object): if isinstance(self.injection_parameters.get(key, None), float)} return credible_levels - def get_injection_credible_level(self, parameter): + def get_injection_credible_level(self, parameter, weights=None): """ Get the credible level of the injected parameter @@ -1415,11 +1480,15 @@ class Result(object): if self.injection_parameters is None: raise(TypeError, "Result object has no 'injection_parameters'. " "Cannot copmute credible levels.") + + if weights is None: + weights = np.ones(len(self.posterior)) + if parameter in self.posterior and\ parameter in self.injection_parameters: credible_level =\ - sum(self.posterior[parameter].values < - self.injection_parameters[parameter]) / len(self.posterior) + sum(np.array(self.posterior[parameter].values < + self.injection_parameters[parameter]) * weights) / (sum(weights)) return credible_level else: return np.nan @@ -1849,7 +1918,7 @@ def plot_multiple(results, filename=None, labels=None, colours=None, @latex_plot_format def make_pp_plot(results, filename=None, save=True, confidence_interval=[0.68, 0.95, 0.997], lines=None, legend_fontsize='x-small', keys=None, title=True, - confidence_interval_alpha=0.1, + confidence_interval_alpha=0.1, weight_list=None, **kwargs): """ Make a P-P plot for a set of runs with injected signals. @@ -1873,6 +1942,8 @@ def make_pp_plot(results, filename=None, save=True, confidence_interval=[0.68, 0 A list of keys to use, if None defaults to search_parameter_keys confidence_interval_alpha: float, list, optional The transparency for the background condifence interval + weight_list: list, optional + List of the weight arrays for each calculation. kwargs: Additional kwargs to pass to matplotlib.pyplot.plot @@ -1886,10 +1957,14 @@ def make_pp_plot(results, filename=None, save=True, confidence_interval=[0.68, 0 if keys is None: keys = results[0].search_parameter_keys + if weight_list is None: + weight_list = [None] * len(results) + credible_levels = pd.DataFrame() - for result in results: + for i, result in enumerate(results): credible_levels = credible_levels.append( - result.get_all_injection_credible_levels(keys), ignore_index=True) + result.get_all_injection_credible_levels(keys, weights=weight_list[i]), + ignore_index=True) if lines is None: colors = ["C{}".format(i) for i in range(8)] diff --git a/bilby/gw/conversion.py b/bilby/gw/conversion.py index caf68fd260036213e6a4c30a022a860b7fdc98c2..f034dfe8458178c91c8b340a8f5ab424ff243cf2 100644 --- a/bilby/gw/conversion.py +++ b/bilby/gw/conversion.py @@ -788,7 +788,8 @@ def _generate_all_cbc_parameters(sample, defaults, base_conversion, if ( hasattr(likelihood, 'phase_marginalization') or hasattr(likelihood, 'time_marginalization') or - hasattr(likelihood, 'distance_marginalization') + hasattr(likelihood, 'distance_marginalization') or + hasattr(likelihood, 'calibration_marginalization') ): try: generate_posterior_samples_from_marginalized_likelihood( @@ -1162,6 +1163,7 @@ def compute_snrs(sample, likelihood): for ifo in likelihood.interferometers: per_detector_snr = likelihood.calculate_snrs( signal_polarizations, ifo) + matched_filter_snrs[ifo.name].append( per_detector_snr.complex_matched_filter_snr) optimal_snrs[ifo.name].append( @@ -1201,7 +1203,8 @@ def generate_posterior_samples_from_marginalized_likelihood( """ if not any([likelihood.phase_marginalization, likelihood.distance_marginalization, - likelihood.time_marginalization]): + likelihood.time_marginalization, + likelihood.calibration_marginalization]): return samples # pass through a dictionary @@ -1227,6 +1230,8 @@ def generate_posterior_samples_from_marginalized_likelihood( samples['geocent_time'] = new_samples[:, 0] samples['luminosity_distance'] = new_samples[:, 1] samples['phase'] = new_samples[:, 2] + if likelihood.calibration_marginalization: + samples['recalib_index'] = new_samples[:, 3] return samples @@ -1254,4 +1259,10 @@ def fill_sample(args): sample = dict(sample).copy() likelihood.parameters.update(dict(sample).copy()) new_sample = likelihood.generate_posterior_sample_from_marginalized_likelihood() - return new_sample["geocent_time"], new_sample["luminosity_distance"], new_sample["phase"] + + if not likelihood.calibration_marginalization: + return new_sample["geocent_time"], new_sample["luminosity_distance"],\ + new_sample["phase"] + else: + return new_sample["geocent_time"], new_sample["luminosity_distance"],\ + new_sample["phase"], new_sample['recalib_index'] diff --git a/bilby/gw/detector/calibration.py b/bilby/gw/detector/calibration.py index efb6336e46c2198415ca646d100b6d2092581d29..88a5a664fbc8f148b6628b50532ec651254d77cf 100644 --- a/bilby/gw/detector/calibration.py +++ b/bilby/gw/detector/calibration.py @@ -2,9 +2,91 @@ """ import numpy as np +import tables from scipy.interpolate import interp1d +def read_calibration_file(filename, frequency_array, number_of_response_curves, starting_index=0): + """ + Function to read the hdf5 files from the calibration group containing the physical calibration response curves. + + Parameters + ---------- + filename: str + Location of the HDF5 file that contains the curves + frequency_array: array-like + The frequency values to calculate the calibration response curves at + number_of_response_curves: int + Number of random draws to use from the calibration file + starting_index: int + Index of the first curve to use within the array. This allows for segmenting the calibration curve array + into smaller pieces. + + Returns + ------- + calibration_draws: array-like + Array which contains the calibration responses as a function of the frequency array specified. + Shape is (number_of_response_curves x len(frequency_array)) + + """ + + calibration_file = tables.open_file(filename, 'r') + calibration_amplitude = \ + calibration_file.root.deltaR.draws_amp_rel[starting_index:number_of_response_curves + starting_index] + calibration_phase = \ + calibration_file.root.deltaR.draws_phase[starting_index:number_of_response_curves + starting_index] + + calibration_frequencies = calibration_file.root.deltaR.freq[:] + + calibration_file.close() + + if len(calibration_amplitude.dtype) != 0: # handling if this is a calibration group hdf5 file + calibration_amplitude = calibration_amplitude.view(np.float64).reshape(calibration_amplitude.shape + (-1,)) + calibration_phase = calibration_phase.view(np.float64).reshape(calibration_phase.shape + (-1,)) + calibration_frequencies = calibration_frequencies.view(np.float64) + + # interpolate to the frequency array (where if outside the range of the calibration uncertainty its fixed to 1) + calibration_draws = calibration_amplitude * np.exp(1j * calibration_phase) + calibration_draws = interp1d( + calibration_frequencies, calibration_draws, kind='cubic', + bounds_error=False, fill_value=1)(frequency_array) + + return calibration_draws + + +def write_calibration_file(filename, frequency_array, calibration_draws, calibration_parameter_draws=None): + """ + Function to write the generated response curves to file + + Parameters + ---------- + filename: str + Location and filename to save the file + frequency_array: array-like + The frequency values where the calibration response was calculated + calibration_draws: array-like + Array which contains the calibration responses as a function of the frequency array specified. + Shape is (number_of_response_curves x len(frequency_array)) + calibration_parameter_draws: data_frame + Parameters used to generate the random draws of the calibration response curves + + """ + + calibration_file = tables.open_file(filename, 'w') + deltaR_group = calibration_file.create_group(calibration_file.root, 'deltaR') + + # Save output + calibration_file.create_carray(deltaR_group, 'draws_amp_rel', obj=np.abs(calibration_draws)) + calibration_file.create_carray(deltaR_group, 'draws_phase', obj=np.angle(calibration_draws)) + calibration_file.create_carray(deltaR_group, 'freq', obj=frequency_array) + + calibration_file.close() + + # Save calibration parameter draws + if calibration_parameter_draws is not None: + calibration_parameter_draws.to_hdf(filename, key='CalParams', data_columns=True, format='table') + + class Recalibrate(object): name = 'none' diff --git a/bilby/gw/likelihood.py b/bilby/gw/likelihood.py index c33a67d23ea5a9b642b8b34c77c8797d9e8a08f4..0f113683972874cf4d9267b6e07682205c6bd53e 100644 --- a/bilby/gw/likelihood.py +++ b/bilby/gw/likelihood.py @@ -7,6 +7,8 @@ import copy import numpy as np import scipy.integrate as integrate from scipy.interpolate import interp1d +import pandas as pd +from tqdm import tqdm try: from scipy.special import logsumexp @@ -19,8 +21,8 @@ from ..core.utils import BilbyJsonEncoder, decode_bilby_json from ..core.utils import ( logger, UnsortedInterp2d, create_frequency_series, create_time_series, speed_of_light, radius_of_earth) -from ..core.prior import Interped, Prior, Uniform -from .detector import InterferometerList, get_empty_interferometer +from ..core.prior import Interped, Prior, Uniform, PriorDict, DeltaFunction +from .detector import InterferometerList, get_empty_interferometer, calibration from .prior import BBHPriorDict, CBCPriorDict, Cosmological from .source import lal_binary_black_hole from .utils import ( @@ -64,6 +66,9 @@ class GravitationalWaveTransient(Likelihood): If true, marginalize over phase in the likelihood. This is done analytically using a Bessel function. The phase prior is set to be a delta function at phase=0. + calibration_marginalization: bool, optional + If true, marginalize over calibration response curves in the likelihood. + This is done numerically over a number of calibration response curve realizations. priors: dict, optional If given, used in the distance and phase marginalization. distance_marginalization_lookup_table: (dict, str), optional @@ -74,6 +79,19 @@ class GravitationalWaveTransient(Likelihood): The lookup table is stored after construction in either the provided string or a default location: '.distance_marginalization_lookup_dmin{}_dmax{}_n{}.npz' + calibration_lookup_table: dict, optional + If a dict, contains the arrays over which to marginalize for each interferometer or the filepaths of the + calibration files. + If not provided, but calibration_marginalization is used, then the appropriate file is created to + contain the curves. + number_of_response_curves: int, optional + Number of curves from the calibration lookup table to use. + Default is 1000. + starting_index: int, optional + Sets the index for the first realization of the calibration curve to be considered. + This, coupled with number_of_response_curves, allows for restricting the set of curves used. This can be used + when dealing with large frequency arrays to split the calculation into sections. + Defaults to 0. jitter_time: bool, optional Whether to introduce a `time_jitter` parameter. This avoids either missing the likelihood peak, or introducing biases in the @@ -107,13 +125,16 @@ class GravitationalWaveTransient(Likelihood): ['d_inner_h', 'optimal_snr_squared', 'complex_matched_filter_snr', + 'd_inner_h_array', + 'optimal_snr_squared_array', 'd_inner_h_squared_tc_array']) def __init__( self, interferometers, waveform_generator, time_marginalization=False, - distance_marginalization=False, phase_marginalization=False, priors=None, - distance_marginalization_lookup_table=None, jitter_time=True, - reference_frame="sky", time_reference="geocenter" + distance_marginalization=False, phase_marginalization=False, calibration_marginalization=False, priors=None, + distance_marginalization_lookup_table=None, calibration_lookup_table={}, + number_of_response_curves=1000, starting_index=0, jitter_time=True, reference_frame="sky", + time_reference="geocenter" ): self.waveform_generator = waveform_generator @@ -122,6 +143,7 @@ class GravitationalWaveTransient(Likelihood): self.time_marginalization = time_marginalization self.distance_marginalization = distance_marginalization self.phase_marginalization = phase_marginalization + self.calibration_marginalization = calibration_marginalization self.priors = priors self._check_set_duration_and_sampling_frequency_of_waveform_generator() self.jitter_time = jitter_time @@ -180,11 +202,19 @@ class GravitationalWaveTransient(Likelihood): priors['luminosity_distance'] = float(self._ref_dist) self._marginalized_parameters.append('luminosity_distance') + if self.calibration_marginalization: + self.number_of_response_curves = number_of_response_curves + self.starting_index = starting_index + self._setup_calibration_marginalization(calibration_lookup_table) + self._marginalized_parameters.append('recalib_index') + def __repr__(self): return self.__class__.__name__ + '(interferometers={},\n\twaveform_generator={},\n\ttime_marginalization={}, ' \ - 'distance_marginalization={}, phase_marginalization={}, priors={})'\ + 'distance_marginalization={}, phase_marginalization={}, '\ + 'calibration_marginalization={}, priors={})'\ .format(self.interferometers, self.waveform_generator, self.time_marginalization, - self.distance_marginalization, self.phase_marginalization, self.priors) + self.distance_marginalization, self.phase_marginalization, self.calibration_marginalization, + self.priors) def _check_set_duration_and_sampling_frequency_of_waveform_generator(self): """ Check the waveform_generator has the same duration and @@ -221,23 +251,60 @@ class GravitationalWaveTransient(Likelihood): """ signal = interferometer.get_detector_response( waveform_polarizations, self.parameters) + _mask = interferometer.frequency_mask + + if 'recalib_index' in self.parameters: + signal[_mask] *= self.calibration_draws[interferometer.name][int(self.parameters['recalib_index'])] + d_inner_h = interferometer.inner_product(signal=signal) optimal_snr_squared = interferometer.optimal_snr_squared(signal=signal) complex_matched_filter_snr = d_inner_h / (optimal_snr_squared**0.5) - if self.time_marginalization: - d_inner_h_squared_tc_array =\ + d_inner_h_array = None + optimal_snr_squared_array = None + + if self.time_marginalization and self.calibration_marginalization: + + d_inner_h_integrand = np.tile( + 4. / self.waveform_generator.duration * + interferometer.frequency_domain_strain.conjugate() * signal / + interferometer.power_spectral_density_array, (self.number_of_response_curves, 1)).T + + d_inner_h_integrand[_mask] *= self.calibration_draws[interferometer.name].T + + d_inner_h_array =\ + 4 / self.waveform_generator.duration * np.fft.fft( + d_inner_h_integrand[0:-1], axis=0).T + + optimal_snr_squared_integrand = 4. / self.waveform_generator.duration *\ + np.abs(signal)**2 / interferometer.power_spectral_density_array + optimal_snr_squared_array = np.dot(optimal_snr_squared_integrand[_mask], + self.calibration_abs_draws[interferometer.name].T) + + elif self.time_marginalization and not self.calibration_marginalization: + d_inner_h_array =\ 4 / self.waveform_generator.duration * np.fft.fft( signal[0:-1] * interferometer.frequency_domain_strain.conjugate()[0:-1] / interferometer.power_spectral_density_array[0:-1]) - else: - d_inner_h_squared_tc_array = None + + elif self.calibration_marginalization and ('recalib_index' not in self.parameters): + d_inner_h_integrand = 4. / self.waveform_generator.duration * \ + interferometer.frequency_domain_strain.conjugate() * signal / \ + interferometer.power_spectral_density_array + d_inner_h_array = np.dot(d_inner_h_integrand[_mask], self.calibration_draws[interferometer.name].T) + + optimal_snr_squared_integrand = 4. / self.waveform_generator.duration *\ + np.abs(signal)**2 / interferometer.power_spectral_density_array + optimal_snr_squared_array = np.dot(optimal_snr_squared_integrand[_mask], + self.calibration_abs_draws[interferometer.name].T) return self._CalculatedSNRs( d_inner_h=d_inner_h, optimal_snr_squared=optimal_snr_squared, complex_matched_filter_snr=complex_matched_filter_snr, - d_inner_h_squared_tc_array=d_inner_h_squared_tc_array) + d_inner_h_array=d_inner_h_array, + optimal_snr_squared_array=optimal_snr_squared_array, + d_inner_h_squared_tc_array=None) def _check_marginalized_prior_is_set(self, key): if key in self.priors and self.priors[key].is_fixed: @@ -304,13 +371,27 @@ class GravitationalWaveTransient(Likelihood): d_inner_h = 0. optimal_snr_squared = 0. complex_matched_filter_snr = 0. - if self.time_marginalization: + + if self.time_marginalization and self.calibration_marginalization: + if self.jitter_time: + self.parameters['geocent_time'] += self.parameters['time_jitter'] + + d_inner_h_array = np.zeros( + (self.number_of_response_curves, len(self.interferometers.frequency_array[0:-1])), + dtype=np.complex128) + optimal_snr_squared_array = np.zeros(self.number_of_response_curves, dtype=np.complex128) + + elif self.time_marginalization: if self.jitter_time: self.parameters['geocent_time'] += self.parameters['time_jitter'] - d_inner_h_tc_array = np.zeros( - self.interferometers.frequency_array[0:-1].shape, + d_inner_h_array = np.zeros( + len(self.interferometers.frequency_array[0:-1]), dtype=np.complex128) + elif self.calibration_marginalization: + d_inner_h_array = np.zeros(self.number_of_response_curves, dtype=np.complex128) + optimal_snr_squared_array = np.zeros(self.number_of_response_curves, dtype=np.complex128) + for interferometer in self.interferometers: per_detector_snr = self.calculate_snrs( waveform_polarizations=waveform_polarizations, @@ -320,12 +401,27 @@ class GravitationalWaveTransient(Likelihood): optimal_snr_squared += np.real(per_detector_snr.optimal_snr_squared) complex_matched_filter_snr += per_detector_snr.complex_matched_filter_snr - if self.time_marginalization: - d_inner_h_tc_array += per_detector_snr.d_inner_h_squared_tc_array + if self.time_marginalization or self.calibration_marginalization: + d_inner_h_array += per_detector_snr.d_inner_h_array - if self.time_marginalization: + if self.calibration_marginalization: + optimal_snr_squared_array += per_detector_snr.optimal_snr_squared_array + + if self.calibration_marginalization and self.time_marginalization: + log_l = self.time_and_calibration_marginalized_likelihood( + d_inner_h_array=d_inner_h_array, + h_inner_h=optimal_snr_squared_array) + if self.jitter_time: + self.parameters['geocent_time'] -= self.parameters['time_jitter'] + + elif self.calibration_marginalization: + log_l = self.calibration_marginalized_likelihood( + d_inner_h_calibration_array=d_inner_h_array, + h_inner_h=optimal_snr_squared_array) + + elif self.time_marginalization: log_l = self.time_marginalized_likelihood( - d_inner_h_tc_array=d_inner_h_tc_array, + d_inner_h_tc_array=d_inner_h_array, h_inner_h=optimal_snr_squared) if self.jitter_time: self.parameters['geocent_time'] -= self.parameters['time_jitter'] @@ -361,12 +457,22 @@ class GravitationalWaveTransient(Likelihood): caching, as the signal is overwritten in place. """ if any([self.phase_marginalization, self.distance_marginalization, - self.time_marginalization]): + self.time_marginalization, self.calibration_marginalization]): signal_polarizations = copy.deepcopy( self.waveform_generator.frequency_domain_strain( self.parameters)) else: return self.parameters + + if self.calibration_marginalization and self.time_marginalization: + raise AttributeError( + "Cannot use time and calibration marginalization simultaneously for regeneration at the moment!" + "The matrix manipulation has not been tested.") + + if self.calibration_marginalization: + new_calibration = self.generate_calibration_sample_from_marginalized_likelihood( + signal_polarizations=signal_polarizations) + self.parameters['recalib_index'] = new_calibration if self.time_marginalization: new_time = self.generate_time_sample_from_marginalized_likelihood( signal_polarizations=signal_polarizations) @@ -381,6 +487,38 @@ class GravitationalWaveTransient(Likelihood): self.parameters['phase'] = new_phase return self.parameters.copy() + def generate_calibration_sample_from_marginalized_likelihood( + self, signal_polarizations=None): + """ + Generate a single sample from the posterior distribution for the set of calibration response curves when + explicitly marginalizing over the calibration uncertainty. + + Parameters + ---------- + signal_polarizations: dict, optional + Polarizations modes of the template. + + Returns + ------- + new_calibration: dict + Sample set from the calibration posterior + """ + if 'recalib_index' in self.parameters: + self.parameters.pop('recalib_index') + self.parameters.update(self.get_sky_frame_parameters()) + if signal_polarizations is None: + signal_polarizations = \ + self.waveform_generator.frequency_domain_strain(self.parameters) + + log_like = self.get_calibration_log_likelihoods(signal_polarizations=signal_polarizations) + + calibration_post = np.exp(log_like - max(log_like)) + calibration_post /= np.sum(calibration_post) + + new_calibration = np.random.choice(self.number_of_response_curves, p=calibration_post) + + return new_calibration + def generate_time_sample_from_marginalized_likelihood( self, signal_polarizations=None): """ @@ -483,6 +621,7 @@ class GravitationalWaveTransient(Likelihood): if signal_polarizations is None: signal_polarizations = \ self.waveform_generator.frequency_domain_strain(self.parameters) + d_inner_h, h_inner_h = self._calculate_inner_products(signal_polarizations) d_inner_h_dist = ( @@ -562,12 +701,17 @@ class GravitationalWaveTransient(Likelihood): d_inner_h_ref = np.abs(d_inner_h_ref) else: d_inner_h_ref = np.real(d_inner_h_ref) + return self._interp_dist_margd_loglikelihood( d_inner_h_ref, h_inner_h_ref) def phase_marginalized_likelihood(self, d_inner_h, h_inner_h): d_inner_h = self._bessel_function_interped(abs(d_inner_h)) - return d_inner_h - h_inner_h / 2 + + if self.calibration_marginalization and self.time_marginalization: + return d_inner_h - np.outer(h_inner_h, np.ones(np.shape(d_inner_h)[1])) / 2 + else: + return d_inner_h - h_inner_h / 2 def time_marginalized_likelihood(self, d_inner_h_tc_array, h_inner_h): if self.distance_marginalization: @@ -585,6 +729,81 @@ class GravitationalWaveTransient(Likelihood): time_prior_array = self.priors['geocent_time'].prob(times) * self._delta_tc return logsumexp(log_l_tc_array, b=time_prior_array) + def time_and_calibration_marginalized_likelihood(self, d_inner_h_array, h_inner_h): + times = self._times + if self.jitter_time: + times = self._times + self.parameters['time_jitter'] + + _time_prior = self.priors['geocent_time'] + time_mask = np.logical_and((times >= _time_prior.minimum), (times <= _time_prior.maximum)) + times = times[time_mask] + time_probs = self.priors['geocent_time'].prob(times) * self._delta_tc + + d_inner_h_array = d_inner_h_array[:, time_mask] + h_inner_h = h_inner_h + + if self.distance_marginalization: + log_l_array = self.distance_marginalized_likelihood( + d_inner_h=d_inner_h_array, h_inner_h=h_inner_h) + elif self.phase_marginalization: + log_l_array = self.phase_marginalized_likelihood( + d_inner_h=d_inner_h_array, + h_inner_h=h_inner_h) + else: + log_l_array = np.real(d_inner_h_array) - np.outer(h_inner_h, np.ones(np.shape(d_inner_h_array)[1])) / 2 + + prior_array = np.outer(time_probs, 1. / self.number_of_response_curves * np.ones(len(h_inner_h))).T + + return logsumexp(log_l_array, b=prior_array) + + def get_calibration_log_likelihoods(self, signal_polarizations=None): + self.parameters.update(self.get_sky_frame_parameters()) + if signal_polarizations is None: + signal_polarizations =\ + self.waveform_generator.frequency_domain_strain(self.parameters) + + d_inner_h = 0. + optimal_snr_squared = 0. + complex_matched_filter_snr = 0. + d_inner_h_array = np.zeros(self.number_of_response_curves, dtype=np.complex128) + optimal_snr_squared_array = np.zeros(self.number_of_response_curves, dtype=np.complex128) + + for interferometer in self.interferometers: + per_detector_snr = self.calculate_snrs( + waveform_polarizations=signal_polarizations, + interferometer=interferometer) + + d_inner_h += per_detector_snr.d_inner_h + optimal_snr_squared += np.real(per_detector_snr.optimal_snr_squared) + complex_matched_filter_snr += per_detector_snr.complex_matched_filter_snr + d_inner_h_array += per_detector_snr.d_inner_h_array + optimal_snr_squared_array += per_detector_snr.optimal_snr_squared_array + + if self.distance_marginalization: + log_l_cal_array = self.distance_marginalized_likelihood( + d_inner_h=d_inner_h_array, h_inner_h=optimal_snr_squared_array) + elif self.phase_marginalization: + log_l_cal_array = self.phase_marginalized_likelihood( + d_inner_h=d_inner_h_array, + h_inner_h=optimal_snr_squared_array) + else: + log_l_cal_array = np.real(d_inner_h_array - optimal_snr_squared_array / 2) + + return log_l_cal_array + + def calibration_marginalized_likelihood(self, d_inner_h_calibration_array, h_inner_h): + if self.distance_marginalization: + log_l_cal_array = self.distance_marginalized_likelihood( + d_inner_h=d_inner_h_calibration_array, h_inner_h=h_inner_h) + elif self.phase_marginalization: + log_l_cal_array = self.phase_marginalized_likelihood( + d_inner_h=d_inner_h_calibration_array, + h_inner_h=h_inner_h) + else: + log_l_cal_array = np.real(d_inner_h_calibration_array - h_inner_h / 2) + + return logsumexp(log_l_cal_array) - np.log(self.number_of_response_curves) + def _setup_rho(self, d_inner_h, optimal_snr_squared): optimal_snr_squared_ref = (optimal_snr_squared.real * self.parameters['luminosity_distance'] ** 2 / @@ -735,6 +954,58 @@ class GravitationalWaveTransient(Likelihood): self.time_prior_array = \ self.priors['geocent_time'].prob(self._times) * self._delta_tc + def _setup_calibration_marginalization(self, calibration_lookup_table): + self.calibration_draws = {} + self.calibration_abs_draws = {} + self.calibration_parameter_draws = {} + for interferometer in self.interferometers: + + # Force the priors + calibration_priors = PriorDict() + for key in self.priors.keys(): + if 'recalib' in key and interferometer.name in key: + calibration_priors[key] = copy.copy(self.priors[key]) + self.priors[key] = DeltaFunction(0.0) + + # If there is no entry in the lookup table, make an empty one + if interferometer.name not in calibration_lookup_table.keys(): + calibration_lookup_table[interferometer.name] =\ + f'{interferometer.name}_calibration_file.h5' + + # If the interferometer lookup table file exists, generate the curves from it + if os.path.exists(calibration_lookup_table[interferometer.name]): + self.calibration_draws[interferometer.name] =\ + calibration.read_calibration_file( + calibration_lookup_table[interferometer.name], self.interferometers.frequency_array, + self.number_of_response_curves, self.starting_index) + + else: # generate the fake curves + self.calibration_parameter_draws[interferometer.name] =\ + pd.DataFrame(calibration_priors.sample(self.number_of_response_curves)) + + self.calibration_draws[interferometer.name] = \ + np.zeros((self.number_of_response_curves, len(interferometer.frequency_array)), dtype=complex) + + for i in tqdm(range(self.number_of_response_curves)): + self.calibration_draws[interferometer.name][i, :] =\ + interferometer.calibration_model.get_calibration_factor( + interferometer.frequency_array, + prefix='recalib_{}_'.format(interferometer.name), + **self.calibration_parameter_draws[interferometer.name].iloc[i]) + + calibration.write_calibration_file( + calibration_lookup_table[interferometer.name], + self.interferometers.frequency_array, + self.calibration_draws[interferometer.name], + self.calibration_parameter_draws[interferometer.name]) + + interferometer.calibration_model = calibration.Recalibrate() + + _mask = interferometer.frequency_mask + self.calibration_draws[interferometer.name] = self.calibration_draws[interferometer.name][:, _mask] + self.calibration_abs_draws[interferometer.name] =\ + np.abs(self.calibration_draws[interferometer.name])**2 + @property def interferometers(self): return self._interferometers @@ -821,6 +1092,7 @@ class GravitationalWaveTransient(Likelihood): time_marginalization=self.time_marginalization, phase_marginalization=self.phase_marginalization, distance_marginalization=self.distance_marginalization, + calibration_marginalization=self.calibration_marginalization, waveform_generator_class=self.waveform_generator.__class__, waveform_arguments=self.waveform_generator.waveform_arguments, frequency_domain_source_model=self.waveform_generator.frequency_domain_source_model, @@ -1072,7 +1344,9 @@ class ROQGravitationalWaveTransient(GravitationalWaveTransient): return self._CalculatedSNRs( d_inner_h=np.nan_to_num(-np.inf), optimal_snr_squared=0, complex_matched_filter_snr=np.nan_to_num(-np.inf), - d_inner_h_squared_tc_array=None) + d_inner_h_squared_tc_array=None, + d_inner_h_array=None, + optimal_snr_squared_array=None) d_inner_h_tc_array = np.einsum( 'i,ji->j', np.conjugate(h_plus_linear + h_cross_linear), @@ -1092,7 +1366,9 @@ class ROQGravitationalWaveTransient(GravitationalWaveTransient): return self._CalculatedSNRs( d_inner_h=d_inner_h, optimal_snr_squared=optimal_snr_squared, complex_matched_filter_snr=complex_matched_filter_snr, - d_inner_h_squared_tc_array=d_inner_h_squared_tc_array) + d_inner_h_squared_tc_array=d_inner_h_squared_tc_array, + d_inner_h_array=None, + optimal_snr_squared_array=None) @staticmethod def _closest_time_indices(time, samples): diff --git a/containers/Dockerfile-test-suite-python37 b/containers/Dockerfile-test-suite-python37 new file mode 100644 index 0000000000000000000000000000000000000000..b1d3762e9cac4771b82fc6f7c88e2f121e26b654 --- /dev/null +++ b/containers/Dockerfile-test-suite-python37 @@ -0,0 +1,76 @@ +LABEL name="bilby Base Enterprise Linux 7" \ +maintainer="Gregory Ashton <gregory.ashton@ligo.org>" \ +date="20190104" + +ENV PATH /opt/conda/bin:$PATH + +# Install backend +RUN apt-get update --fix-missing \ +&& apt-get install -y libglib2.0-0 libxext6 libsm6 libxrender1 libgl1-mesa-glx \ +dh-autoreconf build-essential libarchive-dev wget curl git libhdf5-serial-dev + +# Install python3.7 +RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.5.11-Linux-x86_64.sh -O ~/miniconda.sh && \ +/bin/bash ~/miniconda.sh -b -p /opt/conda && \ +rm ~/miniconda.sh && \ +/opt/conda/bin/conda clean -tipsy && \ +ln -s /opt/conda/etc/profile.d/conda.sh /etc/profile.d/conda.sh && \ +echo ". /opt/conda/etc/profile.d/conda.sh" >> ~/.bashrc && \ +echo "conda activate base" >> ~/.bashrc + +# Update pip and setuptools +RUN pip install --upgrade pip \ +&& LC_ALL=C pip install --upgrade setuptools + +# Install conda-installable programs +RUN conda install -y numpy scipy matplotlib pandas + +# Install conda-forge-installable programs +RUN conda install -c conda-forge deepdish arviz + +# Install requirements +RUN pip install future \ +pycondor>=0.5 \ +configargparse \ +flake8 \ +mock \ +pipenv \ +coverage \ +pytest-cov \ +coverage-badge + +# Install documentation requirements +RUN pip install sphinx numpydoc nbsphinx sphinx_rtd_theme sphinx-tabs + +# Install additional bilby requirements +RUN pip install corner lalsuite astropy gwpy theano healpy tables + +# Install samplers +RUN pip install cpnest dynesty emcee nestle ptemcee pymc3 pymultinest kombine ultranest dnest4 + +# Install pymultinest requirements +RUN apt-get install -y libblas3 libblas-dev liblapack3 liblapack-dev \ +libatlas3-base libatlas-base-dev cmake build-essential gfortran + +# Install pymultinest +RUN git clone https://github.com/farhanferoz/MultiNest.git \ +&& (cd MultiNest/MultiNest_v3.11_CMake/multinest && mkdir build && cd build && cmake .. && make) + +ENV LD_LIBRARY_PATH $HOME/MultiNest/MultiNest_v3.11_CMake/multinest/lib: + +# Install Polychord +RUN git clone https://github.com/PolyChord/PolyChordLite.git \ +&& (cd PolyChordLite && python setup.py --no-mpi install) + +# Install PTMCMCSampler +RUN git clone https://github.com/jellis18/PTMCMCSampler.git \ +&& (cd PTMCMCSampler && python setup.py install) + +# Add the ROQ data to the image +RUN mkdir roq_basis \ + && cd roq_basis \ + && wget https://git.ligo.org/lscsoft/ROQ_data/raw/master/IMRPhenomPv2/4s/B_linear.npy \ + && wget https://git.ligo.org/lscsoft/ROQ_data/raw/master/IMRPhenomPv2/4s/B_quadratic.npy \ + && wget https://git.ligo.org/lscsoft/ROQ_data/raw/master/IMRPhenomPv2/4s/fnodes_linear.npy \ + && wget https://git.ligo.org/lscsoft/ROQ_data/raw/master/IMRPhenomPv2/4s/fnodes_quadratic.npy \ + && wget https://git.ligo.org/lscsoft/ROQ_data/raw/master/IMRPhenomPv2/4s/params.dat \ diff --git a/examples/gw_examples/injection_examples/calibration_marginalization_example.py b/examples/gw_examples/injection_examples/calibration_marginalization_example.py new file mode 100644 index 0000000000000000000000000000000000000000..73279aadbf4435d59d51ce08b33d59569e475028 --- /dev/null +++ b/examples/gw_examples/injection_examples/calibration_marginalization_example.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +""" +Tutorial to demonstrate running parameter estimation with calibration +uncertainties marginalized over using a finite set of realizations. +""" + +import numpy as np +import bilby +from copy import copy +import matplotlib.pyplot as plt + +# Set the duration and sampling frequency of the data segment +# that we're going to create and inject the signal into. +duration = 4. +sampling_frequency = 2048. + +# Specify the output directory and the name of the simulation. +outdir = 'outdir' +label = 'calibration_marginalization' +bilby.core.utils.setup_logger(outdir=outdir, label=label) + +# Set up a random seed for result reproducibility. This is optional! +np.random.seed(170817) + +# We are going to inject a binary black hole waveform. We first establish a +# dictionary of parameters that includes all of the different waveform +# parameters, including masses of the two black holes (mass_1, mass_2), +# spins of both black holes (a, tilt, phi), etc. +injection_parameters = dict( + mass_1=36., mass_2=29., a_1=0.4, a_2=0.3, tilt_1=0.5, tilt_2=1.0, + phi_12=1.7, phi_jl=0.3, luminosity_distance=2000., theta_jn=0.4, psi=2.659, + phase=1.3, geocent_time=1126259642.413, ra=1.375, dec=-1.2108) +start_time = injection_parameters['geocent_time'] - duration + 2 + +# Fixed arguments passed into the source model +waveform_arguments = dict(waveform_approximant='IMRPhenomPv2', + reference_frequency=50.) + +# Create the waveform_generator using a LAL BinaryBlackHole source function +waveform_generator = bilby.gw.WaveformGenerator( + duration=duration, sampling_frequency=sampling_frequency, + frequency_domain_source_model=bilby.gw.source.lal_binary_black_hole, + parameters=injection_parameters, waveform_arguments=waveform_arguments) +waveform_generator_rew = copy(waveform_generator) + +# Set up interferometers. In this case we'll use three interferometers +# (LIGO-Hanford (H1), LIGO-Livingston (L1), and Virgo (V1)). +# These default to their design sensitivity +ifos = bilby.gw.detector.InterferometerList(['H1', 'L1', 'V1']) +for ifo in ifos: + injection_parameters.update({ + 'recalib_{}_amplitude_{}'.format(ifo.name, ii): 0.0 for ii in range(10)}) + injection_parameters.update({ + 'recalib_{}_phase_{}'.format(ifo.name, ii): 0.0 for ii in range(10)}) + ifo.calibration_model = bilby.gw.calibration.CubicSpline( + prefix='recalib_{}_'.format(ifo.name), + minimum_frequency=ifo.minimum_frequency, + maximum_frequency=ifo.maximum_frequency, n_points=10) +ifos.set_strain_data_from_power_spectral_densities( + sampling_frequency=sampling_frequency, duration=duration, start_time=start_time) +ifos.inject_signal(parameters=injection_parameters, + waveform_generator=waveform_generator) +ifos_rew = copy(ifos) + +# Set up prior, which is a dictionary +# Here we fix the injected cbc parameters (except the distance) +# to the injected values. +priors = injection_parameters.copy() +priors['luminosity_distance'] = bilby.prior.Uniform( + injection_parameters['luminosity_distance'] - 1000, injection_parameters['luminosity_distance'] + 1000, + name='luminosity_distance', latex_label='$d_L$') +for key in injection_parameters: + if 'recalib' in key: + priors[key] = injection_parameters[key] + +# Convert to prior dictionary to replace the floats with delta function priors +priors = bilby.core.prior.PriorDict(priors) +priors_rew = copy(priors) + +# Initialise the likelihood by passing in the interferometer data (IFOs) and +# the waveform generator. Here we assume there is no calibration uncertainty +likelihood = bilby.gw.GravitationalWaveTransient( + interferometers=ifos, waveform_generator=waveform_generator, priors=priors) + +# Run sampler. In this case we're going to use the `dynesty` sampler +result = bilby.run_sampler( + likelihood=likelihood, priors=priors, sampler='dynesty', npoints=500, + injection_parameters=injection_parameters, outdir=outdir, label=label) + +# Setting the log likelihood to actually be the log likelihood and not the log likelihood ratio... +# This is used the for reweighting +result.posterior['log_likelihood'] = result.posterior['log_likelihood'] + result.log_noise_evidence + +# Setting the priors we want on the calibration response curve parameters - as an example. +for name in ['recalib_H1_amplitude_1', 'recalib_H1_amplitude_4']: + priors_rew[name] = bilby.prior.Gaussian( + mu=0, sigma=0.03, name=name, latex_label='H1 $A_{}$'.format(name[-1])) + +# Setting up the calibration marginalized likelihood. +# We save the calibration response curve files into the output directory under {ifo.name}_calibration_file.h5 +cal_likelihood = bilby.gw.GravitationalWaveTransient( + interferometers=ifos_rew, waveform_generator=waveform_generator_rew, + calibration_marginalization=True, priors=priors_rew, + number_of_response_curves=100, + calibration_lookup_table={ifos[i].name: f'{outdir}/{ifos[i].name}_calibration_file.h5' for i in range(len(ifos))}) + +# Plot the magnitude of the curves to be used in the marginalization +plt.semilogx(ifos[0].frequency_array[ifos[0].frequency_mask][0:-1], + cal_likelihood.calibration_draws[ifos[0].name][:, 0:-1].T) +plt.xlim(20, 1024) +plt.ylabel('Magnitude') +plt.xlabel('Frequency [Hz]') +plt.savefig(f"{outdir}/calibration_draws.pdf") +plt.clf() + +# Reweight the posterior samples from a distribution with no calibration uncertainty to one with uncertainty. +# This method utilizes rejection sampling which can be inefficient at drawing samples at higher SNRs. +result_rew = bilby.core.result.reweight(result, + new_likelihood=cal_likelihood, + conversion_function=bilby.gw.conversion.generate_all_bbh_parameters) + +# Plot distance posterior with and without the calibration +for res, label in zip([result, result_rew], ["No calibration uncertainty", "Calibration uncertainty"]): + plt.hist(res.posterior['luminosity_distance'], label=label, bins=50, histtype='step', density=True) +plt.legend() +plt.xlabel('Luminosity distance [Mpc]') +plt.savefig(f"{outdir}/luminosity_distance_posterior.pdf") +plt.clf() + +plt.hist( + result_rew.posterior['recalib_index'], + bins=np.linspace(0, cal_likelihood.number_of_response_curves - 1, cal_likelihood.number_of_response_curves), + density=True) +plt.xlim(0, cal_likelihood.number_of_response_curves - 1) +plt.xlabel('Calibration index') +plt.savefig(f"{outdir}/calibration_index_histogram.pdf") diff --git a/requirements.txt b/requirements.txt index 50a9de636a5c35d515d796d8d9da423232bbaf50..05d86e2f5459f636abf631452a48535d12d4393f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ mock dill tqdm h5py +tables diff --git a/test/gw/likelihood_test.py b/test/gw/likelihood_test.py index 5e5c719e7d5f7a9e70d2bace433390c36e0bb73c..8f8bbcfc150af3d39db1f47304a95fea217e0e17 100644 --- a/test/gw/likelihood_test.py +++ b/test/gw/likelihood_test.py @@ -164,12 +164,13 @@ class TestGWTransient(unittest.TestCase): expected = ( "GravitationalWaveTransient(interferometers={},\n\twaveform_generator={},\n\t" "time_marginalization={}, distance_marginalization={}, phase_marginalization={}, " - "priors={})".format( + "calibration_marginalization={}, priors={})".format( self.interferometers, self.waveform_generator, False, False, False, + False, self.prior, ) ) @@ -211,6 +212,7 @@ class TestGWTransient(unittest.TestCase): time_marginalization=False, phase_marginalization=False, distance_marginalization=False, + calibration_marginalization=False, waveform_generator_class=self.waveform_generator.__class__, waveform_arguments=self.waveform_generator.waveform_arguments, frequency_domain_source_model=self.waveform_generator.frequency_domain_source_model,