feat: Bispectrum update and expansion - #982
Conversation
This commit updates the current stingray bispectrum code which is quite old to be in-line with the modern forms of PowerSpectrum and the like. Several functions related to calculating the bispectrum have been added to fourier.py when there has been no suitable current function. This commit removes the old cumulant way of calculating the bispectrum (which was how it is achieved in matlab). Current functionality includes: calculating the bispectrum and averaged bispectrum, calculating from event lists, light curves, and iterables, basic plots of the magnitude, biphase and bicoherence, and normalisation via the three bicoherence normalisations. Tests will be committed in a separate (following) commit. Current non-functionality: Poisson-noise bias subtraction, bicoherence significance, segment windowing to reduce spectral leakage (was in the old way of calculating the bispectrum). Things that should probably also be done: jellyfish plots of the biphase, waveforms, dynamical bispectrum, 1-D reductions such as summed biphase, skewness, and time asymmetry. A reasonable expansion would be also to compute the cross bispectrum.
This commit adds tests for the newly revised bispectrum class. It generally follows the testing format of other current stingray test suites and generates reference bispectra by brute force to test against.
The jellyfish plot is often used for the auto-bi-spectrum for visualisation purposes, particularly with respect to signalling the harmonic and subharmonic. This implements a jellyfish plot function to Bispectrum.
This commit implements the Poisson noise bias subtraction of Wirnitzer (1985), see Nathan et al (2022) and Maccarone (2013).
Tests implemented for the jellyfish plots as well as the Poisson noise bias subtraction. Jellyfish plots have their own tests as they need to check that the total biphase is in-line with the correct normalized amount.
Saving all of the bispectrum matrix is quite memory intensive, especially for many segments. This commit adds a new save_diagonal option which only retains the auto-bispectra in the matrix, allowing a significant drop in memory usage. This is most useful when one wishes to plot a jellyfish plot.
The cross-bispectrum allows for the probing of multiple energy bands simultaneously and check for coupling between them at given frequencies. While not frequently used currently, this is the more general form of the bispectrum so should form the basis of all future bi-spectral related timing products. This required refactoring Bispectrum to be a special case of CrossBispectrum.
This commit adds the dynamical bispectrum, the higher order version of the dynamical power spectrum. Unlike the DPS, the bicoherence (the most commonly used bispectrum statistic) is only meaningful when averaged over multiple segments. Therefore, we have to a) define both a bin size (resolution of the dynamical spectrum) and a segment size (width of each segment within each bin). Generally, the bicoherence will only be particularly meaningful for more than 10 averaged segments, which the module warns the user about. This is packaged with several different plotting methods (as it's somewhat difficult to visualise a 3-D data cube for humans): trace (tracking bicoherence and biphase over time for a particular coupling of frequencies), montage (full 2-D maps for each time bin), diagonal (the bicoherence along the f1=f2 diagonal with respect to time), and slice (the bicoherence at f1 with f2 with respect to time). Currently, rebin_frequency is not implemented for the full data cube case.
Made sure text is stingray matplotlib version compatible.
This commit adds the statistical bias subtraction which is dependent on the number of averaged segments M from Fackrell (1996). Note that this might technically be the simplified version of: bias(<b^2>) = (2/dof) (1 - b^2)^2 present in other bispectral statistics works such as Benignus (1969) and Elgar and Sebert (1989).
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #982 +/- ##
==========================================
- Coverage 96.23% 94.05% -2.18%
==========================================
Files 48 48
Lines 10099 11039 +940
==========================================
+ Hits 9719 10383 +664
- Misses 380 656 +276 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@bjricketts thanks a ton for this! We've been waiting for good bispectrum code for long. For now, I would like to keep both the new and the old codes, so that we can make a through comparison of the two and maybe take the time to deprecate the old one for a while if it just performs worse (or just keep it as an alternative algorithm if it still gives sensible results on real data). The same goes for the notebooks. |
|
@matteobachetti I've added back the cumulant method to the code (as well as adding some more test coverage of both the new code and the old cumulant method). It's only implemented for the auto-bispectrum for now as I don't know the 3rd order cumulant method well enough to expand it to the cross-bispectrum. It might be easy, but I haven't read up on it enough to know. The speed of the methods are essentially dependent on frequency resolution (see the figure below).
The two methods agree with each other to some stupidly small number (1e-16) so I think both methods are perfectly valid to me. I'll add back the showcase of the cumulant method to the notebook. I'll also add a showcase of using the bispectrum on actual data (GRS1915 RXTE data). I assume I can package the data into the notebook module? |
|
I have also updated the notebook to include an explanation/demonstration of the cumulant method as well as demonstrate it on actual data (see the same notebooks PR). |
|
@bjricketts thanks! For real data, I suggest to create a zendo entry or something equivalent outside the repo, that can be downloaded. This helps keeping the size of the repo clean |
|
@matteobachetti Okay, that is all done! Let me know if you'd like other changes/additions. |
|
Those final two commits should mean that all the workflows should pass minus the doc-links which I haven't changed and are from other notebooks unrelated to this PR. |

Relevant Issue(s)/PR(s)
This is related to and supersedes #640. This will close #640 upon merge.
Provide an overview of the implemented solution or the fix and elaborate on the modifications.
The current bispectrum implementation is based on the 3rd order cumulant, but astronomical applications of the bispectrum generally use the Fourier method to calculate the bispectrum (Maccarone 2013, Nathan et al 2022). This PR replaces the old methodology with that most commonly used in the field as well as expands features such as:
Note: As this is quite a large PR, I'm happy to split this into two PRs, one overhauling the Bispectrum calculation, and the other adding the cross-bispectrum and dynamical counterparts.
Fourier-decomposition bi-spectrum
The new method of calculating the bi-spectrum follows Maccarone (2013):
The light curve is cut into m non-overlapping segments. Each segment i is Fourier-transformed to give complex amplitudes Xᵢ(f). The bispectrum is the segment-averaged triple product
B(f₁, f₂) = ⟨ Xᵢ(f₁) · Xᵢ(f₂) · Xᵢ*(f₁ + f₂) ⟩_i
Each factor is a complex Fourier amplitude with its own phase, so the product carries a total phase φ(f₁) + φ(f₂) − φ(f₁+f₂). The biphase is the argument of B:
β(f₁, f₂) = arg B(f₁, f₂)
Practically speaking, we sample a 2-D grid of Fourier frequencies with
_bispectrum_frequency_grid(n_bin, dt)and obtain:idx1,idx2which are(nf, nf)bin indices of f_1 and f_2, from np.meshgrid(kbins, kbins, indexing="ij"), so matrix[i, j] is indexed (f₁ = freq[i], f₂ = freq[j]).The code only keeps the positive Nyquist triangle for the auto-bi-spectrum (note that this is not the case for the cross-bi-spectrum).
The actual estimation of the bi-spectrum is done via
get_flux_iterable_from_segments(like that of other Fourier objects) and then FFTs of each segment:Rather than storing each 2-D
triple(which would costm × nf × nfcomplex numbers), the loop folds every segment straight into a fixed set of running sums:bispec_sumΣ Tᵢ(complex)B = ΣTᵢ / mand biphasedenom1_sumΣ |Xᵢ(f₁)Xᵢ(f₂)|²denom2_sumΣ |Xᵢ(f₁+f₂)|²abs_triple_sumΣ |Tᵢ|sum_sq_re,sum_sq_imΣ (Re Tᵢ)²,Σ (Im Tᵢ)²Bcos_sum,sin_sumΣ Tᵢ/|Tᵢ|(unit phasors)Because the raw sums are kept, the bicoherence can be re-derived under any normalization after the fact with no re-FFT (
recompute_bicoherence), and time-resolved variants can re-combine the sums across time bins. All the sums are additive over segments, so the aberaging is cheap to compute incrementally. To be honest, you could do this the over way as well where you recompute the FFTs, but I think its mostly a matter of taste.After the pass, with
msegments accumulated:Bispectrum and biphase — directly Maccarone's definitions:
Bicoherence — calculated in
bicoherence_from_sums, which implements the three different normalization conventions from the same sums. The Kim & Powers form Maccarone (2013) uses is|ΣTᵢ|² / (denom1_sum · denom2_sum);sigl_chamounis its square root;hagihirais|ΣTᵢ| / Σ|Tᵢ|. Each is clipped to[0, 1]. The module defaults to the Kim & Powers normalizations.Errors — a biphase is only meaningful with a spread, so two are produced (both zero for
m = 1by construction):bispec_err: the standard error of the mean of the complex triple, from the varianceΣ(Re T)²/m − (Re B)²(and likewise imaginary), divided bym.biphase_err: a circular standard error (Fisher 1993). Averaging angles naively is wrong, so the loop sums unit phasorsTᵢ/|Tᵢ|; their mean resultant lengthr̄ = √(cos_sum² + sin_sum²)/mmeasures phase concentration, and the error is√(−2 ln r̄)/√m. Tightly coupled frequencies (r̄ → 1) get a small error; scattered phases (r̄ → 0) a large one.Finally the redundant region is set to
NaNin every 2-D array, and the raw sums plus diagnostics (m,n,dt,df,nphots,segment_size) are packed into the result table's metadata for the class layer to copy onto the object.Poisson-noise bias subtraction
For photon-counting data the triple product is biased: Poisson noise is white and correlated with itself across the three factors, adding a real offset that Maccarone and later Nathan et al. (2022) treat with the Wirnitzer (1985) correction. When
poisson_subtractis set, each per-segment triple is de-biased before accumulation:where
Nᵢis the segment's photon count. Subtracting per segment (not once at the end) keeps the correction exact under the segment averaging.Jellyfish plots
plot_jellyfish takes the per-segment diagonal triples (bispec_diagonal from save_diagonal, or the diagonal of bispec_all), forms a cumulative sum along segments, and normalizes each path by √(denom1·denom2) on the diagonal so the endpoint radius is the Sigl–Chamoun bi-coherence and its angle is the bi-phase. It draws each diagonal frequency as a path in the complex plane, with optional highlighting of the path nearest f0 (fundamental) and f0/2 (sub-harmonic), plus dashed reference circles at the requested bi-coherence levels.
General plots
As users will frequently wish to plot the biphase, bispectrum magnitude and bicoherence, I have also implemented plot functions for each of these, which return the generated axes for free-hand use (for instance if one wants to make multiple panels).
Cross-bi-spectrum
This is the generic form of Bispectrum and allows for the comparison across multiple data channels. The only real difference here is that the frequency grid must also take into account the negative frequencies as the negative frequencies no longer obey
f_1<->f_2that allows for ignoring of the negative frequencies. It is permitted to input 3 different lightcurves, but if only two are provided, it defaults to the first inputted lightcurve as the 3rd curve (and of course reduces to the auto-bi-spectrum case when only one data object is provided).Dynamical bi-spectrum
I have never seen this used in the literature yet, but I thought I might as well do it while I was here. For the actual calculation of the bi-spectrum, see above. This is implemented as a special case of DynamicalCrossBispectrum which inherits from AveragedCrossBispectrum which inherits from CrossBispectrum. The main inclusions here are the actual calculation of the dynamical bi-spectrum itself and some associated plots for how I think it might be useful (as we're in somewhat "here be dragons" space).
Unlike the dynamical power spectrum, we can't simply take a single bin and compute the bi-spectrum as the bi-coherence is not meaningful without averaging. Therefore, I have implemented such that there is both bin sizes as well as segment sizes (where bin size > segment size). The bicoherence is not meaningful without averaging over about 10 segments, so the code warns the user of this. Otherwise, this is reusing all of the code to calculate the bispectrum. There are a number of helper methods that are essentially redos of DynamicalPowerSpectrum.
There are however 5 plotting options:
b(nu, nu, t)which is closest to a dynamical power spectrum.(f2, time).(f1, f2)bicoherence map at the time bin nearesttand is best used to plot just one time bin.tat fixed coupled frequenciesf1, f2.Finally, there is the
shift_and_addmethod which allows one to track relative frequencies over an observation that would otherwise be smeared out: for instance, a QPO frequency that wanders over time.Memory options
There are two memory options when calculating the bispectrum (and other derived types):
save_allandsave_diagonal.save_allretains the whole data cube, whilesave_diagonalonly saves thef1=f2diagonal, which is useful to measure self-coupling or for producing jellyfish plots.Any other comments?
Full disclosure: the code in this PR was written with considerable help from Claude. That being said, I've reviewed the produced code and design process carefully, and I'm very much willing to be responsible for the code its outputted. I've kept the design style to as close as other modern modules as possible and reused already existing stingray functions as much as possible.
See the new and accompanying notebook showing all the new features off at StingraySoftware/notebooks#130.