Skip to content

FEAT: add option to crop whitened time series#1030

Open
ColmTalbot wants to merge 23 commits into
bilby-dev:mainfrom
ColmTalbot:truncated-likelihood
Open

FEAT: add option to crop whitened time series#1030
ColmTalbot wants to merge 23 commits into
bilby-dev:mainfrom
ColmTalbot:truncated-likelihood

Conversation

@ColmTalbot

@ColmTalbot ColmTalbot commented Dec 23, 2025

Copy link
Copy Markdown
Collaborator

This PR implements the proposed modification to the likelihood from https://iopscience.iop.org/article/10.1088/1361-6382/ae1ac7/meta.

The main new feature is adding a crop_duration to the InterferometerStrainData and some accompanying utility code.

I modified how the GravitationalWaveTransient and BasicGravitationalWaveTransient call the data. I did not have to change the reference likelihood tests, so there is good evidence that this doesn't impact the numerical values when not using the cropping.

I suspect there is a performance penalty due to repeatedly whitening the data. There should be a way to cache this calculation, it just requires some cleverness about when the time/frequency masks might have changed.

  • I'm not sure at this time how this should interact with accelerated likelihood, so I've left them alone.
  • I also haven't validated how this interacts with time/calibration marginalization.

@ColmTalbot ColmTalbot added enhancement New feature or request >100 lines detector likelihood to discuss To be discussed on an upcoming call labels Jan 20, 2026
@ColmTalbot ColmTalbot force-pushed the truncated-likelihood branch from f39871f to 5fbc454 Compare January 21, 2026 15:17
Comment thread test/gw/detector/interferometer_test.py Outdated
Comment on lines +631 to +639
@property
def frequency_mask(self):
frequencies = bilby.core.utils.series.create_frequency_series(
duration=self.ifo.cropped_duration, sampling_frequency=self.sampling_frequency
)
return (self.ifo.minimum_frequency <= frequencies) & (
frequencies <= self.ifo.maximum_frequency
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably have a utility function that does this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@ColmTalbot ColmTalbot force-pushed the truncated-likelihood branch from 0b74e2b to 0972d62 Compare January 23, 2026 16:53
@ColmTalbot ColmTalbot requested a review from mj-will January 26, 2026 08:32

@GregoryAshton GregoryAshton left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good and I'm supportive, just a few things I wanted to check.

Comment thread bilby/gw/detector/interferometer.py Outdated
Comment thread bilby/gw/detector/interferometer.py
Comment thread bilby/gw/likelihood/base.py
Comment thread bilby/gw/likelihood/base.py
Comment thread bilby/gw/likelihood/base.py
Comment thread bilby/gw/likelihood/base.py
Comment thread bilby/gw/detector/interferometer.py Outdated
Comment thread bilby/gw/detector/interferometer.py Outdated
@ColmTalbot ColmTalbot added this to the 3.0.0 milestone Jan 29, 2026
@ColmTalbot ColmTalbot force-pushed the truncated-likelihood branch 2 times, most recently from b8f4b97 to c7e762a Compare February 5, 2026 14:38
Comment thread bilby/gw/detector/interferometer.py Outdated
else:
return self.whiten_and_crop(frequency_series=frequency_series)

def whiten_and_crop(self, frequency_series : np.array) -> np.array:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like this procedure is more fundamental than suggested by its being a method of the interferometer class. The nice thing about using the gwutils SNR functions was that you could calculate SNRs using them without necessarily initializing an interferometer object. Is it possible to pull this out to the utils and just call it here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread bilby/gw/utils.py Outdated
return np.nan_to_num(whitened) * frequency_mask * (4 / duration)**0.5


def whiten_and_crop(frequency_series, amplitude_spectral_density, frequency_mask, time_mask, duration):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the name state what input it accepts? E.g. like 'frequency_domain_whiten' to make it clear it is acting on the frequency-domain input?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can make this change, but it's pretty clear from the signature and docstring.

@GregoryAshton GregoryAshton left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more comments after another look.

* np.sqrt(np.sum(self.frequency_mask)) / frequency_window_factor
* np.sqrt(np.sum(self.frequency_mask))
/ frequency_window_factor
* self.time_mask.mean()**0.5

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this use both np.sqrt and **0.5. It would probably be easier to read if it was consistent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I was trying to avoid explicitly using numpy, but that's unavoidable since we need an fft and sqrt is sometimes meaningfully faster.

Suggested change
* self.time_mask.mean()**0.5
* np.sqrt(self.time_mask.mean())

The frequency series, whitened by the ASD
"""
return frequency_series / (self.amplitude_spectral_density_array * np.sqrt(self.duration / 4))
if self.crop_duration == 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens (or can) crop_duration be "None" or None etc? This would silently fail and then trigger the else clause, likely also erroring, but could be confusing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would fail in the crop_duration.setter in the InterferometerStrainData with a reasonably explicit error message.

)
_mask = interferometer.frequency_mask

if 'recalib_index' in parameters:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the signal no longer require masking here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear, is this tested?

@asb5468 asb5468 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more general questions/comments:

  1. In https://arxiv.org/pdf/2508.11091, there are two options presented for how to treat the template, "Signal models can then either be generated in the frequency domain with frequency spacing 1/T and whitened before subtracting from the whitened and cropped frequency-domain data or generated with the full duration T + 2∆ and processed using the same procedure as the data." Do I understand correctly that this MR has implemented the first option?
  2. The code itself seems fine, but it's hard to tell definitively whether there are any unintended consequences, probably because I'm not familiar with all the review tests, and right now they are failing. Have you done any more extensive test runs that you could share, like a GW150914 analysis with the new likelihood with no cropping to make sure the results are unchanged?

duration=self.strain_data.duration)
whitened_signal = self.whiten_frequency_series(signal)
whitened_data = self.whitened_frequency_domain_strain
return (whitened_signal.T * whitened_data.conj()).sum()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the whitened signal need to be transposed? In the function below, template_template_inner_product, it is not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this is a typo, it likely wouldn't do anything for one dimensional arrays which I think we have an implicit expectation this is, but I'll remove it.

* np.sqrt(np.sum(self.frequency_mask)) / frequency_window_factor
* np.sqrt(np.sum(self.frequency_mask))
/ frequency_window_factor
* self.time_mask.mean()**0.5

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this extra factor of self.time_mask.mean()**0.5 come from? Can you point to a specific equation in https://arxiv.org/pdf/2508.11091?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for myself later, it is (10), this is $\sqrt{\beta}$.

A list of notches
crop_duration: float | tuple
The duration of data to crop at the beginning/end of the segment
to avoid whitening artifacts. If a float, that duration is excluded

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring here should add the units for this argument. I'm assuming it's seconds?

@property
def crop_duration(self):
"""
The duration of data to crop at the beginning/end of the segment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the units of this property to the docstring. Also, crop_duration and cropped_duration are really similar and I'm leery of them getting mixed up. Perhaps crop_duration could be renamed to crop_time?

cropped_frequencies
)

self._frequency_mask_updated = True

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like self._frequency_mask_updated now controls whether both frequency_mask and cropped_frequency_mask are updated. Is there ever a situation where a user would want to update one but not the other?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, I feel like we should require that they are kept synchronized.

Comment on lines -283 to +282
signal[_mask] *= self.calibration_draws[interferometer.name][int(parameters['recalib_index'])]
signal *= self.calibration_draws[interferometer.name][int(parameters['recalib_index'])]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this no longer being applied only to the masked frequencies?

Comment thread bilby/gw/likelihood/base.py Outdated
d_inner_h_array = 4 / self.waveform_generator.duration * np.fft.fft(
d_inner_h_integrand[0:-1], axis=0
).T
d_inner_h_array = np.fft.fft(d_inner_h_integrand[:-1], axis=0).T

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the 4/T normalization no longer applied here?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, is it because it's already applied within interferometer.whitened_frequency_domain_strain and whitened_signal?

with self.assertRaises(ValueError):
self.ifo.inject_signal(injection_polarizations=None, parameters=None)

def test_optimal_snr_squared(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this test removed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

detector enhancement New feature or request likelihood to discuss To be discussed on an upcoming call >100 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants