Fitting quasi-normal modes in GW150914

We demonstrate how to use ringdown to fit damped sinusoids in data from the first LIGO detection, GW150914. We will make a number of pedagogical plots along the way, the actual fit should take under 2 minutes in a modern desktop computer.

NOTE: this example is inteneded to demonstrate the functionality of the ringdown package using GW150914 as an example; it is not meant to reproduce the result from Isi, et al. (2019): we use a more general polarization model, assume different priors, and apply slightly different data conditioning; therefore, the results should not be expected to be identical Isi et al (2019).

We begin with some standard imports and global settings.

Preliminaries

[1]:
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
[2]:
# disable numpy multithreading to avoid conflicts
# with jax multiprocessing in numpyro
import os
os.environ["OMP_NUM_THREADS"] = "1"
import numpy as np

# import jax and set it up to use double precision
from jax import config
config.update("jax_enable_x64", False)

# import numpyro and set it up to use 4 CPU devices
import numpyro
numpyro.set_host_device_count(4)
numpyro.set_platform('cpu')

# we will use matplotlib, arviz and seaborn for some of the plotting
import matplotlib.pyplot as plt
import arviz as az
import seaborn as sns

# disable some warning shown by importing LALSuite from a notebook
import warnings
warnings.filterwarnings("ignore", "Wswiglal-redir-stdio")

# import ringdown package
import ringdown as rd

# set plotting context
sns.set_context('notebook')
sns.set_palette('colorblind')

Tip

ringdown can run on GPUs! You can activate this through numpyro.set_platform. If running on a GPU, you will likely see significant performance improvements by using single precision, i.e., config.update("jax_enable_x64", False); if doing so, ringdown will automatically attempt to scale quantities so that single precision is safe—but you should always check that this works for your data.

Data

In this example, we will load GW150914 directly from disk. To do this, we first download it from the Gravitational-Wave Open Science Center here. We grab 32s of data in each IFO, sampled at 16 kHz. Ringdown supports reading both frame files and HDF5 files from GWOSC.

Verify data integrity by checking that the MD5 sums are:

MD5(H-H1_GWOSC_16KHZ_R1-1126259447-32.hdf5)= b345645c0488287301824fe617f58dc2
MD5(L-L1_GWOSC_16KHZ_R1-1126259447-32.hdf5)= 5eb246ff21364d38471c95452f012853
[ ]:
!wget -nc https://www.gw-openscience.org/eventapi/html/GWTC-1-confident/GW150914/v3/H-H1_GWOSC_16KHZ_R1-1126259447-32.hdf5
!wget -nc https://www.gw-openscience.org/eventapi/html/GWTC-1-confident/GW150914/v3/L-L1_GWOSC_16KHZ_R1-1126259447-32.hdf5

!openssl md5 H-H1_GWOSC_16KHZ_R1-1126259447-32.hdf5
!openssl md5 L-L1_GWOSC_16KHZ_R1-1126259447-32.hdf5

Tip

Besides custom GWOSC files, ringdown can load any data that is readable by the pandas package; additionally, with the optional dependency GWpy, ringdown can directly read in LIGO-Virgo-KAGRA frame files (*.gwf), as well as discover and load public or proprietary strain data. See docs for rd.Fit.load_data for further info.

Fitting Kerr modes

The first step in a ringdown analysis is creating a Fit object. This object will contain the data and prior information, and will perform the necessary operations to run the model (including data conditioning, if requested).

Broadly speaking, ringdown currently allows for two types of fits based on how the frequencies (\(f\)) and damping rates (\(\gamma\)) of the damped sinusoids are parametrized:

  1. Kerr fits in which the frequencies and damping rates of all modes are set as a function of black hole mass (\(M\)) and spin (\(\chi\)), assuming some mode labeling (e.g., \(\ell=|m|=2,\, n=0\));

  2. Free fits in which the frequencies and damping rates are allowed to vary freely.

An intermediate type of fit can be obtained by allowing for small deviations away from Kerr through the introduction of deviation parameters \(\delta f\) and \(\delta \gamma\), but we will consider this type of beyond-Kerr fit a functional extension of the Kerr fits above.

We will initialize our Fit object by specifying which modes we would like to fit. We will first carry out a Kerr fit containing both the fundamental quadrupolar mode and an overtone.

The fit object expects to receive a list of modes to include in the fit; the most explicit way to specify them is to give tuples of (p, s, l, m, n), where p = +/- 1 indicates prograde/retrograde, s = -2 is the spin weight, l and m are the usual angular quantum numbers, and n is the tone index. The modes are elliptically polarized, so there is no need to specify both + and - m azimuthal numbers. (See Isi & Farr (2021).)

[4]:
fit = rd.Fit(modes=[(1, -2, 2, 2, 0), (1, -2, 2, 2, 1)])

We will also set an analysis time, target sky location and analysis duration. We will use some reference values corresponding to an estimate of the peak of GW150914:

[5]:
fit.set_target(1126259462.4083147, ra=1.95, dec=-1.27, psi=0.82, duration=0.2)

Tip

By default, the target time is assumed to correspond to arrival at geocenter. If you want to specify time of arrival at a specific detector, you can use the reference_ifo argument. For example, the above is the same as

fit.set_target(1126259462.423, ra=1.95, dec=-1.27, psi=0.82, duration=0.2, reference_ifo='H1')

Alternatively, you can also specify detector start times and antenna patterns directly by passing corresponding dictionaries to the set_target function.

The actual target times at each detector will be computed automatically for detectors for which data is available.

Data preparation

One of the main features of the Fit object is its ability to load and manipulate data. In particular, we will usually want to:

  1. filter and downsample data to reduce the number of data points analyzed;

  2. estimate or load the autocovariance function (ACF) of the data

  3. subselect some relevant data based on the target time and duration.

We begin by loading some data. The fit offers a variety of ways for doing this (see TIP above), but in this example we will load them directly from files on disk.

[6]:
fit.load_data('{i}-{ifo}_GWOSC_16KHZ_R1-1126259447-32.hdf5', ifos=['H1', 'L1'], kind='GWOSC')

As a sanity-check, we can plot of the raw strain we just loaded:

[7]:
for ifo, data in fit.data.items():
    data[::4].plot(label=ifo)
plt.xlabel(r'$t_\mathrm{GPS} / \mathrm{s}$');
plt.ylabel(r'$h(t)$');
plt.legend(loc='best');
../_images/examples_GW150914_23_0.png

Now that we’ve got the strain loaded up, we want to condition it a bit. LIGO does not have very good high-frequency sensitivity; also, our modes do not have a lot of high-frequency content. Additionally, LIGO contains a lot of low-frequency noise and our templates do not have a lot of power at low frequencies. Here are estimated noise PSDs for our data:

[8]:
for ifo, data in fit.data.items():
    plt.loglog(data.get_psd().iloc[1:], label=ifo)
plt.xlabel(r'$f / \mathrm{Hz}$');
plt.ylabel(r'$P\left( f \right) / \mathrm{Hz}^{-1}$');
plt.legend(loc='best');
../_images/examples_GW150914_25_0.png

Data conditioning

Because the cost of our analysis scales as \(N^2\) for a data segment of \(N\) samples, and because the noise in LIGO combined with the finite bandwidth of ringdown modes means that the data are uninformative at both very low and very high frequencies, we can bandpass the data, downsample considerably (to eliminate high-frequency content), and truncate to a very short data segment (to eliminate low frequency content). The condition_data method below accomplishes this.

When we downsample, we want to be sure to preserve a sample as close as possible to the requested target time at each detector. One issue we have to deal with is that the signal arrives in the detectors at different times (the time delay between detectors depends on the source location on the sky). The Fit object knows how to handle this for us.

[9]:
fit.condition_data(ds=8, f_min=20)

Warning

The conditioning function has a large number of options to manipulate the data in several ways. You should always make sure that the defaults make sense for your data!

We can visualize the segment of conditioned analysis data as another sanity check:

[10]:
for i, d in fit.analysis_data.items():
    d.plot(label=i)
    plt.axvline(fit.start_times[i], ls='--', alpha=0.5, lw=3, c='gray')
plt.xlabel(r'$t_\mathrm{GPS} / \mathrm{s}$');
plt.ylabel(r'$h(t)$');
plt.legend(loc='best');
../_images/examples_GW150914_31_0.png

The wave arrived first at Livingston, then at Hanford, which is why the time segments are not exactly aligned.

Autocovariance function

A key ingredient in our likelihood calculation is the autocovariance function (ACF) of the noise, which is the time-domain equivalent of the power spectral density (PSD). You can load estimates of the ACF from disk, derive it from a PSD, or estimate it directly from the data. We do the latter below, with some default options.

[11]:
fit.compute_acfs(f_min=20)

We can use the ACFs to whiten the analysis data and plot it once again.

[12]:
wd = fit.whiten(fit.analysis_data)
for i, d in wd.items():
    d.plot(label=i)
plt.xlabel(r'$t_\mathrm{GPS} / \mathrm{s}$');
plt.ylabel(r'whitened $h(t)$');
plt.legend(loc='best');
../_images/examples_GW150914_37_0.png

The signal is visible towards the beginning of the whitened data.

Run fit

We are almost ready to run the fit. Before sampling over the posterior on modes, we need to set up some priors with reasonable choices. We can see all the options available by using querying the valid_model_settings argument.

[13]:
print(fit.valid_model_settings)
['a_scale_max', 'marginalized', 'm_min', 'm_max', 'chi_min', 'chi_max', 'cosi_min', 'cosi_max', 'cosi', 'df_min', 'df_max', 'dg_min', 'dg_max', 'f_min', 'f_max', 'g_min', 'g_max', 'flat_amplitude_prior', 'mode_ordering', 'single_polarization']

These settings determine the prior and type of fit. For a vanilla Kerr fit with generic mode polarizations (not parametrized in terms of a source inclination), we only need to specify an amplitude scale, and a mass range.

[14]:
fit.update_model(a_scale_max=5e-20, m_min=40.0, m_max=140.0, cosi=-1)

We are finally ready to run! This should only take a couple of minutes on a modern desktop computer using CPUs.

[15]:
fit.run()
WARNING:root:running with float32 precision
[16]:
fit.result
[16]:
arviz.InferenceData
    • <xarray.Dataset> Size: 27MB
      Dimensions:     (chain: 4, draw: 1000, mode: 2, ifo: 2, time_index: 410)
      Coordinates:
        * chain       (chain) int64 32B 0 1 2 3
        * draw        (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
        * mode        (mode) |S10 20B b'1,-2,2,2,0' b'1,-2,2,2,1'
        * ifo         (ifo) <U2 16B 'H1' 'L1'
        * time_index  (time_index) int64 3kB 0 1 2 3 4 5 6 ... 404 405 406 407 408 409
      Data variables:
          a_scale     (chain, draw, mode) float32 32kB 3.477e-21 ... 3.297e-21
          chi         (chain, draw) float32 16kB 0.1735 0.8477 ... 0.5773 0.6874
          f           (chain, draw, mode) float32 32kB 236.9 222.7 ... 237.6 232.4
          g           (chain, draw, mode) float32 32kB 330.5 1.009e+03 ... 229.9 699.0
          m           (chain, draw) float32 16kB 54.34 78.9 59.82 ... 64.21 71.66
          a           (chain, draw, mode) float32 32kB 2.994e-21 ... 1.976e-21
          ax_unit     (chain, draw, mode) float32 32kB 0.4851 -2.144 ... 0.7748 1.316
          ay_unit     (chain, draw, mode) float32 32kB 0.0456 0.6439 ... -0.04075
          ellip       (chain, draw, mode) float32 32kB -1.0 -1.0 -1.0 ... -1.0 -1.0
          h_det_mode  (chain, draw, ifo, mode, time_index) float32 26MB 1.26e-21 .....
          phi         (chain, draw, mode) float32 32kB 0.5805 -2.326 ... 0.9118 -1.535
      Attributes:
          created_at:                 2024-10-09T19:14:10.419458+00:00
          arviz_version:              0.18.0
          inference_library:          numpyro
          inference_library_version:  0.14.0

    • <xarray.Dataset> Size: 40kB
      Dimensions:  (chain: 4, draw: 1000)
      Coordinates:
        * chain    (chain) int64 32B 0 1 2 3
        * draw     (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
      Data variables:
          logl_0   (chain, draw) float32 16kB 1.494e+03 1.499e+03 ... 1.493e+03
          logl_1   (chain, draw) float32 16kB 1.441e+03 1.44e+03 ... 1.442e+03
      Attributes:
          created_at:                 2024-10-09T19:14:17.538225+00:00
          arviz_version:              0.18.0
          inference_library:          numpyro
          inference_library_version:  0.14.0

    • <xarray.Dataset> Size: 12kB
      Dimensions:    (chain: 4, draw: 1000)
      Coordinates:
        * chain      (chain) int64 32B 0 1 2 3
        * draw       (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
      Data variables:
          diverging  (chain, draw) bool 4kB False False False ... False False False
      Attributes:
          created_at:                 2024-10-09T19:14:10.449647+00:00
          arviz_version:              0.18.0
          inference_library:          numpyro
          inference_library_version:  0.14.0

    • <xarray.Dataset> Size: 13kB
      Dimensions:       (logl_0_dim_0: 0, logl_1_dim_0: 0, ifo: 2, mode: 2,
                         time_index: 410, time_index_1: 410)
      Coordinates:
        * logl_0_dim_0  (logl_0_dim_0) int64 0B
        * logl_1_dim_0  (logl_1_dim_0) int64 0B
        * ifo           (ifo) <U2 16B 'H1' 'L1'
        * mode          (mode) |S10 20B b'1,-2,2,2,0' b'1,-2,2,2,1'
        * time_index    (time_index) int64 3kB 0 1 2 3 4 5 ... 404 405 406 407 408 409
        * time_index_1  (time_index_1) int64 3kB 0 1 2 3 4 5 ... 405 406 407 408 409
      Data variables:
          logl_0        (logl_0_dim_0) float32 0B
          logl_1        (logl_1_dim_0) float32 0B
          strain        (ifo, time_index) float64 7kB 1.768e-20 ... -3.06e-20
      Attributes:
          created_at:                 2024-10-09T19:14:17.542262+00:00
          arviz_version:              0.18.0
          inference_library:          numpyro
          inference_library_version:  0.14.0

    • <xarray.Dataset> Size: 3MB
      Dimensions:          (ifo: 2, time_index: 410, time_index_1: 410, scale_dim_0: 1)
      Coordinates:
        * ifo              (ifo) <U2 16B 'H1' 'L1'
        * time_index       (time_index) int64 3kB 0 1 2 3 4 5 ... 405 406 407 408 409
        * time_index_1     (time_index_1) int64 3kB 0 1 2 3 4 ... 405 406 407 408 409
        * scale_dim_0      (scale_dim_0) int64 8B 0
      Data variables:
          cholesky_factor  (ifo, time_index, time_index_1) float64 3MB 1.702e-20 .....
          epoch            (ifo) float64 16B 1.126e+09 1.126e+09
          fc               (ifo) float64 16B -0.4509 0.2052
          fp               (ifo) float64 16B 0.5787 -0.5274
          scale            (scale_dim_0) float64 8B 1.0
          time             (ifo, time_index) float64 7kB -2.646e-05 ... 0.1997
      Attributes:
          created_at:                 2024-10-09T19:14:17.548972+00:00
          arviz_version:              0.18.0
          inference_library:          numpyro
          inference_library_version:  0.14.0

The result of our fit is held in fit.result. This is just an arviz.InferenceData object, with some additional ringdown-specific functionality. As such we can use all tools in ArViz easily.

[17]:
az.summary(fit.result, var_names=['a', 'm', 'chi', 'f', 'g'])
[17]:
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
a[b'1,-2,2,2,0'] 0.000 0.000 0.000 0.000 0.000 0.000 2590.0 2327.0 1.0
a[b'1,-2,2,2,1'] 0.000 0.000 0.000 0.000 0.000 0.000 2751.0 2486.0 1.0
m 72.383 8.122 56.953 86.946 0.185 0.131 1855.0 1101.0 1.0
chi 0.683 0.164 0.367 0.910 0.005 0.003 1762.0 929.0 1.0
f[b'1,-2,2,2,0'] 241.321 8.398 226.042 257.261 0.165 0.117 2730.0 2114.0 1.0
f[b'1,-2,2,2,1'] 235.969 9.784 218.280 254.070 0.215 0.152 2333.0 1157.0 1.0
g[b'1,-2,2,2,0'] 226.454 41.814 149.710 304.645 0.996 0.753 1863.0 1026.0 1.0
g[b'1,-2,2,2,1'] 687.530 130.421 448.097 931.308 3.093 2.339 1866.0 1026.0 1.0

We can visualize the result of the fit using a trace plot. This allows us to visualize the chains and diagnose any potential divergences.

[18]:
az.plot_trace(fit.result, var_names=['m', 'chi', 'a']);
../_images/examples_GW150914_50_0.png

We can access the posterior samples directly in fit.result.posterior. We can also obtain a Pandas DataFrame with all chains stacked together. This can be really convenient for plotting and other manipulations.

[19]:
# draw 500 samples from the posterior
fit.result.get_parameter_dataframe(500, latex=True)
[19]:
$M / M_\odot$ $\chi$ $f_{220} / \mathrm{Hz}$ $f_{221} / \mathrm{Hz}$ $\gamma_{220} / \mathrm{Hz}$ $\gamma_{221} / \mathrm{Hz}$ $A_{220}$ $A_{221}$ $\phi_{220}$ $\phi_{221}$ $\epsilon_{220}$ $\epsilon_{221}$
1975 63.744255 0.540083 240.402634 232.453522 271.023010 827.261841 2.028834e-21 1.978116e-21 0.798366 -1.844549 -1.0 -1.0
3331 53.371269 0.003806 228.270950 211.972427 337.275848 1027.417725 2.578548e-21 2.752907e-21 0.477533 -2.331038 -1.0 -1.0
3241 73.982391 0.701359 232.859207 227.951736 221.233810 672.114441 1.674814e-21 1.410554e-21 0.476132 -2.101680 -1.0 -1.0
1453 63.589539 0.471834 231.500549 222.680450 275.225342 840.881165 1.630561e-21 1.525404e-21 0.492855 -1.945385 -1.0 -1.0
1212 69.349838 0.704752 249.137009 243.949631 235.612534 715.690735 1.714517e-21 2.247939e-21 1.348513 -0.894499 -1.0 -1.0
... ... ... ... ... ... ... ... ... ... ... ... ...
782 67.635185 0.553754 228.531662 221.206268 254.636353 777.048218 1.895225e-21 1.964108e-21 0.629840 -1.903309 -1.0 -1.0
229 63.197891 0.504437 237.314835 228.843048 275.359772 840.965393 1.794975e-21 1.633226e-21 0.822161 -1.845080 -1.0 -1.0
2854 84.826508 0.839987 234.419861 231.915436 172.660767 519.961731 1.472406e-21 1.449929e-21 0.786228 -1.568629 -1.0 -1.0
2540 65.534164 0.606795 244.368256 237.496628 259.099792 789.716858 1.888778e-21 2.443565e-21 1.024894 -1.650668 -1.0 -1.0
1686 79.150085 0.791397 237.265060 233.864227 194.870026 589.087463 1.303603e-21 7.788810e-22 0.690618 -1.612360 -1.0 -1.0

500 rows × 12 columns

Plot results

We can make some quick plots of the results using arviz and seaborn. Below we demonstrate a few possible ways of doing this.

Remnant mass and spin

[20]:
az.plot_pair(fit.result, var_names=['m', 'chi'], marginals=True,
             kind='kde', reference_values={'m': 70, 'chi': 0.7});
../_images/examples_GW150914_56_0.png

Mode amplitudes

[21]:
df = fit.result.get_parameter_dataframe()
g = sns.jointplot(x=df['a_220'], y=df['a_221'], kind='kde', fill=True);
g.ax_joint.set_xlabel('$A_{220}$');
g.ax_joint.set_ylabel('$A_{221}$');
../_images/examples_GW150914_58_0.png

Mode frequencies

[22]:
df = fit.result.get_mode_parameter_dataframe(500, latex=True)
key_map = fit.result.get_parameter_key_map(modes=False)

pg = sns.PairGrid(df, vars=[key_map[k] for k in ['f', 'g']],
                  diag_sharey=False, hue='mode')
pg.map_diag(sns.kdeplot);
pg.map_upper(sns.scatterplot);
pg.map_lower(rd.utils.kdeplot, levels=[0.9, 0.5, 0.1]);
../_images/examples_GW150914_60_0.png

Waveform reconstructions

Plot 90% credible interval for the reconstructions at each detector.

[23]:
fig, axs = plt.subplots(1, len(fit.ifos), figsize=(12, 4))
axs = np.atleast_1d(axs)
for mode in fit.result.modes:
    hdet_mode_cls = [fit.result.get_strain_quantile(q, mode=mode) for q in [0.5, 0.05, 0.95]]
    for i, ax in zip(fit.ifos, axs):
        m, l, h = [hcl[i] for hcl in hdet_mode_cls]
        ax.plot(m, label=mode.get_label())
        ax.fill_between(m.index, l, h, alpha=0.5)

hdet_cls = [fit.result.get_strain_quantile(q) for q in [0.5, 0.05, 0.95]]
for i, ax in zip(fit.ifos, axs):
    m, l, h = [hcl[i] for hcl in hdet_cls]

    ax.plot(m, label='total')
    ax.fill_between(m.index, l, h, alpha=0.5)

    ax.set_xlim(fit.start_times[i]- 0.001,
                fit.start_times[i] + 0.025)
    ax.set_xlabel(r'$t_\mathrm{GPS} / \mathrm{s}$')
    ax.set_ylabel(f'$h(t)$ {i}');
    ax.legend();
../_images/examples_GW150914_63_0.png

Signal to noise ratio

[24]:
snrs = fit.result.compute_posterior_snrs(optimal=False, network=True)
sns.kdeplot(snrs);
plt.axvline(np.median(snrs), ls='--')
plt.xlabel('matched-filter network SNR');
../_images/examples_GW150914_65_0.png

Note

This page was generated from a Jupyter notebook that can be downloaded here.