Matched filter and signal-to-noise for a periodic template
While playing with the find_inspiral.py
optimal match filter
script from the LIGO Open Science
Center I rewrote it for a simpler purpose of a
purely periodic gravitational wave, similar to those emitted by slightly deformed
rotating neutron stars. This example shows the simplest matched filter - the
Fourier transform - and
defines the signal-to-noise ratio from the matched filter (using the
actual signal as a filter), to an signal-to-noise estimate.
Sample data and the signal may be generated as follows:
"""
Gaussian noise with a sinusoidal signal added
"""
import numpy as np
import matplotlib.pyplot as plt
fs = 4096 # sampling rate [Hz]
T = 4 # duration [s]
amp = 0.1 # amplitude of the sinusoid
ome = 13 # frequency of the signal
N = T*fs # total number of points
# time interval spaced with 1/fs
t = np.arange(0, T, 1./fs)
# white noise
noise = np.random.normal(size=t.shape)
# sinusoidal signal with amplitude amp
template = amp*np.sin(ome*2*np.pi*t)
# data: signal (template) with added noise
data = template + noise
plt.figure()
plt.plot(t, data, '-', color="grey")
plt.plot(t, template, '-', color="white", linewidth=2)
plt.xlim(0, T)
plt.xlabel('time')
plt.ylabel('data = signal + noise')
plt.grid(True)
plt.savefig("gaussian_noise_w_signal.png", format="png", bbox_inches="tight")
The resulting figure is
The signal is hidden so deep in the noise that it’s not at all apparent that it actually is there. Fortunarely, we have several techniques to try and find out whether the signal is present in the noisy data or not. One of the most powerful is the matched filter method. The case of a sinusoidal signal is informative, because the matched filter is just the Fourier transform.
"""
Discrete Fourier transform of the signal in noise
"""
# FFT of the template and the data (not normalized)
template_fft = np.fft.fft(template)
data_fft = np.fft.fft(data)
# Sampling frequencies up to the Nyquist limit (fs/2)
sample_freq = np.fft.fftfreq(t.shape[-1], 1./fs)
# FT power
plt.figure()
plt.plot(sample_freq, np.abs(template_fft)/np.sqrt(fs), color="red", alpha=0.5, linewidth=4)
plt.plot(sample_freq, np.abs(data_fft)//np.sqrt(fs), color="grey")
# taking positive spectrum only: plt.xlim(0, np.max(sample_freq))
# peak closeup:
plt.xlim(0, 4*ome)
plt.xlabel('Frequency bins')
plt.ylabel('FT power')
plt.grid(True)
plt.savefig("fft_gaussian_noise_w_signal.png", format="png", bbox_inches="tight")
The method np.fft.fftfreq
conveniently produces the frequency bins in cycles per unit of the sample spacing up to the half of the sampling frequency (the Nyquist frequency). We get
One may also have a look at the spectrum density of the data and compare it with the pure signal. Instad of the power spectral density, the amplitude spectral density is used in the GW data analysis, because what is measured is the GW strain amplitude. ASD is the square root of the PSD.
"""
Power and amplitude spectral density using plt.psd
"""
# Power spectrum density of the data and the signal template
power_data, freq_psd = plt.psd(data, Fs=fs, NFFT=fs, visible=False)
power, freq = plt.psd(template, Fs=fs, NFFT=fs, visible=False)
plt.figure()
# sqrt(power_data) - amplitude spectral density (ASD)
plt.loglog(freq_psd, np.sqrt(power_data), 'gray')
plt.loglog(freq, np.sqrt(power), color="red", alpha=0.5, linewidth=3)
# range is from 0 to the Nyquist frequency
plt.xlim(0, fs/2)
plt.xlabel('Frequency (Hz)')
plt.ylabel('Amplitude spectral density (ASD)')
plt.grid(True)
plt.savefig("amplitude_spectrum.png", format="png", bbox_inches="tight")
The plot shows the amplitude spectral density of the data (mostly noise)
and the signal (in red) with a peak at frequency corresponding to the
chosen frequency ome=13
.
Let’s check now if we can recover the signal with the properly implemented matched filter using the FFT. The relevant few lines of code are
"""
Optimal matched filter
"""
# Matching the FFT frequency bins to PSD frequency bins
# (in the region where is no signal)
power_vec = np.interp(sample_freq, freq_psd[2*ome:], power_data[2*ome:])
# Applying the matched filter (template is the signal)
matched_filter = 2*np.fft.ifft(data_fft * template_fft.conjugate()/power_vec)
SNR_matched = np.sqrt(np.abs(matched_filter)/fs)
# Optimal filter
optimal_filter = 2*np.fft.ifft(template_fft * template_fft.conjugate()/power_vec)
SNR_optimal = np.sqrt(np.abs(optimal_filter)/fs)
# Estimate of the signal-to-noise
SNR_estimate = amp*np.sqrt(T)/np.sqrt(np.average(power_vec))
print "SNR_estimate", SNR_estimate, "SNR_optimal", np.max(SNR_optimal), "SNR_matched", np.max(SNR_matched)
The output depends on the realization of noise. The SNR can be estimated by approximating the output of the matched filter.
Let’s define the scalar product $(x\lvert y)$ using the Fourier transform:
where the $*$ symbol denotes complex conjugation, $\Re$ is the real part of the integral, and $\tilde{x}(f)$ and $\tilde{y}(f)$ are the Fourier transform of the time-domain data series:
The inverse Fourier transform is
$S_n(f)$ is the one-sided power spectral density of the detector’s noise. For a stable detector we may assume the that $S_n(f)\approx S_0=const.$ over the data span. Then, using the Parseval’s theorem, we have
For the additive noise process, the data $s(t)$ is defined as the sum of the signal and the noise:
The matched filter output of the data stream $s(t)$ with a filter template $h_{templ.}(t)$ (correlation of the data with a possible signal with the model of this signal) is defined using the scalar product
The optimal signal to noise ratio is defined as
For a periodic signal $h(t) = h_0\cos(\phi(t) + \phi_0)$, we will assume that the data span $T_0$ is much longer than the period of the wave, $P_0 = 1/f_0$, and that the phase can be expanded in the series $\phi(t)=\Sigma_{k} a_k t^{k+1}$. Also
for integers $n>0$. Integrating the $\rho^2$ for $h(t) = h_0\cos(\phi(t) + \phi_0)$ gives approximately
The estimate for the optimal signal to noise ratio for a periodic signal of the amplitude $h_0$ is therefore
For this data and template packed in the hdf5
format,
I get
SNR_estimate 10.1863371439 SNR_optimal 11.5740920133 SNR_matched 11.5128482391
Here is how to read in the hdf5
:
import h5py
# Read in data and template
dataFile = h5py.File('data_and_template.hdf5', 'r')
data = dataFile['datawsignal'][...]
template = dataFile['template'][...]
dataFile.close()
Check out this gist for the whole script.