lisaanalysistools 1.0.0__cp312-cp312-macosx_10_9_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lisaanalysistools might be problematic. Click here for more details.

Files changed (37) hide show
  1. lisaanalysistools-1.0.0.dist-info/LICENSE +201 -0
  2. lisaanalysistools-1.0.0.dist-info/METADATA +80 -0
  3. lisaanalysistools-1.0.0.dist-info/RECORD +37 -0
  4. lisaanalysistools-1.0.0.dist-info/WHEEL +5 -0
  5. lisaanalysistools-1.0.0.dist-info/top_level.txt +2 -0
  6. lisatools/__init__.py +0 -0
  7. lisatools/_version.py +4 -0
  8. lisatools/analysiscontainer.py +438 -0
  9. lisatools/cutils/detector.cpython-312-darwin.so +0 -0
  10. lisatools/datacontainer.py +292 -0
  11. lisatools/detector.py +410 -0
  12. lisatools/diagnostic.py +976 -0
  13. lisatools/glitch.py +193 -0
  14. lisatools/sampling/__init__.py +0 -0
  15. lisatools/sampling/likelihood.py +882 -0
  16. lisatools/sampling/moves/__init__.py +0 -0
  17. lisatools/sampling/moves/gbgroupstretch.py +53 -0
  18. lisatools/sampling/moves/gbmultipletryrj.py +1287 -0
  19. lisatools/sampling/moves/gbspecialgroupstretch.py +671 -0
  20. lisatools/sampling/moves/gbspecialstretch.py +1836 -0
  21. lisatools/sampling/moves/mbhspecialmove.py +286 -0
  22. lisatools/sampling/moves/placeholder.py +16 -0
  23. lisatools/sampling/moves/skymodehop.py +110 -0
  24. lisatools/sampling/moves/specialforegroundmove.py +564 -0
  25. lisatools/sampling/prior.py +508 -0
  26. lisatools/sampling/stopping.py +320 -0
  27. lisatools/sampling/utility.py +324 -0
  28. lisatools/sensitivity.py +888 -0
  29. lisatools/sources/__init__.py +0 -0
  30. lisatools/sources/emri/__init__.py +1 -0
  31. lisatools/sources/emri/tdiwaveform.py +72 -0
  32. lisatools/stochastic.py +291 -0
  33. lisatools/utils/__init__.py +0 -0
  34. lisatools/utils/constants.py +40 -0
  35. lisatools/utils/multigpudataholder.py +730 -0
  36. lisatools/utils/pointeradjust.py +106 -0
  37. lisatools/utils/utility.py +240 -0
@@ -0,0 +1,976 @@
1
+ import warnings
2
+ from types import ModuleType, NoneType
3
+ from typing import Optional, Any, Tuple, List
4
+
5
+ from numpy.typing import ArrayLike
6
+ import matplotlib.pyplot as plt
7
+
8
+ from eryn.utils import TransformContainer
9
+
10
+ import numpy as np
11
+
12
+ try:
13
+ import cupy as cp
14
+
15
+ except (ModuleNotFoundError, ImportError):
16
+ import numpy as cp
17
+
18
+ pass
19
+
20
+ from .sensitivity import get_sensitivity, SensitivityMatrix
21
+ from .datacontainer import DataResidualArray
22
+ from .utils.utility import get_array_module
23
+
24
+
25
+ def inner_product(
26
+ sig1: np.ndarray | list | DataResidualArray,
27
+ sig2: np.ndarray | list | DataResidualArray,
28
+ dt: Optional[float] = None,
29
+ df: Optional[float] = None,
30
+ f_arr: Optional[float] = None,
31
+ psd: Optional[str | NoneType | np.ndarray | SensitivityMatrix] = "LISASens",
32
+ psd_args: Optional[tuple] = (),
33
+ psd_kwargs: Optional[dict] = {},
34
+ normalize: Optional[bool | str] = False,
35
+ complex: Optional[bool] = False,
36
+ ) -> float | complex:
37
+ """Compute the inner product between two signals weighted by a psd.
38
+
39
+ The inner product between time series :math:`a(t)` and :math:`b(t)` is
40
+
41
+ .. math::
42
+
43
+ \langle a | b \\rangle = 2\int_{f_\\text{min}}^{f_\\text{max}} \\frac{\\tilde{a}(f)^*\\tilde{b}(f) + \\tilde{a}(f)\\tilde{b}(f)^*}{S_n(f)} df\ \ ,
44
+
45
+ where :math:`\\tilde{a}(f)` is the Fourier transform of :math:`a(t)` and :math:`S_n(f)` is the one-sided Power Spectral Density of the noise.
46
+
47
+ The inner product can be left complex using the ``complex`` kwarg.
48
+
49
+ **GPU Capability**: Pass CuPy arrays rather than NumPy arrays.
50
+
51
+ Args:
52
+ sig1: First signal to use for the inner product.
53
+ Can be time-domain or frequency-domain.
54
+ Must be 1D ``np.ndarray``, list of 1D ``np.ndarray``s, or 2D ``np.ndarray``
55
+ across channels with shape ``(nchannels, data length)``.
56
+ sig2: Second signal to use for the inner product.
57
+ Can be time-domain or frequency-domain.
58
+ Must be 1D ``np.ndarray``, list of 1D ``np.ndarray``s, or 2D ``np.ndarray``
59
+ across channels with shape ``(nchannels, data length)``.
60
+ dt: Time step in seconds. If provided, assumes time-domain signals.
61
+ df: Constant frequency spacing. This will assume a frequency domain signal with constant frequency spacing.
62
+ f_arr: Array of specific frequencies at which the signal is given.
63
+ psd: Indicator of what psd to use. If a ``str``, this will be passed as the ``sens_fn`` kwarg to :func:`get_sensitivity`.
64
+ If ``None``, it will be an array of ones. Or, you can pass a 1D ``np.ndarray`` of psd values that must be the same length
65
+ as the frequency domain signals.
66
+ psd_args: Arguments to pass to the psd function if ``type(psd) == str``.
67
+ psd_kwargs: Keyword arguments to pass to the psd function if ``type(psd) == str``.
68
+ normalize: Normalize the inner product. If ``True``, it will normalize the square root of the product of individual signal inner products.
69
+ You can also pass ``"sig1"`` or ``"sig2"`` to normalize with respect to one signal.
70
+ complex: If ``True``, return the complex value of the inner product rather than just its real-valued part.
71
+
72
+ Returns:
73
+ Inner product value.
74
+
75
+ """
76
+ # initial input checks and setup
77
+ sig1 = DataResidualArray(sig1, dt=dt, f_arr=f_arr, df=df)
78
+ sig2 = DataResidualArray(sig2, dt=dt, f_arr=f_arr, df=df)
79
+
80
+ if sig1.nchannels != sig2.nchannels:
81
+ raise ValueError(
82
+ f"Signal 1 has {sig1.nchannels} channels. Signal 2 has {sig2.nchannels} channels. Must be the same."
83
+ )
84
+
85
+ xp = get_array_module(sig1[0])
86
+
87
+ # checks
88
+ for i in range(sig1.nchannels):
89
+ if not type(sig1[0]) == type(sig1[i]) and type(sig1[0]) == type(sig2[i]):
90
+ raise ValueError(
91
+ "Array in sig1, index 0 sets array module. Not all arrays match that module type (Numpy or Cupy)"
92
+ )
93
+
94
+ if sig1.data_length != sig2.data_length:
95
+ raise ValueError(
96
+ "The two signals are two different lengths. Must be the same length."
97
+ )
98
+
99
+ freqs = sig1.f_arr
100
+
101
+ # get psd weighting
102
+ if not isinstance(psd, SensitivityMatrix):
103
+ psd = SensitivityMatrix(freqs, [psd], *psd_args, **psd_kwargs)
104
+
105
+ operational_sets = []
106
+
107
+ if psd.ndim == 3:
108
+ assert psd.shape[0] == psd.shape[1] == sig1.shape[0] == sig2.shape[0]
109
+
110
+ for i in range(psd.shape[0]):
111
+ for j in range(i, psd.shape[1]):
112
+ factor = 1.0 if i == j else 2.0
113
+ operational_sets.append(
114
+ dict(factor=factor, sig1_ind=i, sig2_ind=j, psd_ind=(i, j))
115
+ )
116
+
117
+ elif psd.ndim == 2 and psd.shape[0] > 1:
118
+ assert psd.shape[0] == sig1.shape[0] == sig2.shape[0]
119
+ for i in range(psd.shape[0]):
120
+ operational_sets.append(dict(factor=1.0, sig1_ind=i, sig2_ind=i, psd_ind=i))
121
+
122
+ elif psd.ndim == 2 and psd.shape[0] == 1:
123
+ for i in range(sig1.shape[0]):
124
+ operational_sets.append(dict(factor=1.0, sig1_ind=i, sig2_ind=i, psd_ind=0))
125
+
126
+ else:
127
+ raise ValueError("# TODO")
128
+
129
+ if complex:
130
+ func = lambda x: x
131
+ else:
132
+ func = xp.real
133
+
134
+ # initialize
135
+ out = 0.0
136
+ x = freqs
137
+
138
+ # account for hp and hx if included in time domain signal
139
+ for op_set in operational_sets:
140
+ factor = op_set["factor"]
141
+
142
+ temp1 = sig1[op_set["sig1_ind"]]
143
+ temp2 = sig2[op_set["sig2_ind"]]
144
+ psd_tmp = psd[op_set["psd_ind"]]
145
+
146
+ ind_start = 1 if np.isnan(psd_tmp[0]) else 0
147
+
148
+ y = (
149
+ func(temp1[ind_start:].conj() * temp2[ind_start:]) / psd_tmp[ind_start:]
150
+ ) # assumes right summation rule
151
+ # df is sunk into trapz
152
+ tmp_out = factor * 4 * xp.trapz(y, x=x[ind_start:])
153
+ out += tmp_out
154
+
155
+ # normalize the inner produce
156
+ normalization_value = 1.0
157
+ if normalize is True:
158
+ norm1 = inner_product(
159
+ sig1,
160
+ sig1,
161
+ psd=psd,
162
+ normalize=False,
163
+ )
164
+ norm2 = inner_product(
165
+ sig2,
166
+ sig2,
167
+ psd=psd,
168
+ normalize=False,
169
+ )
170
+
171
+ normalization_value = np.sqrt(norm1 * norm2)
172
+
173
+ elif isinstance(normalize, str):
174
+ if normalize == "sig1":
175
+ sig_to_normalize = sig1
176
+
177
+ elif normalize == "sig2":
178
+ sig_to_normalize = sig2
179
+
180
+ else:
181
+ raise ValueError(
182
+ "If normalizing with respect to sig1 or sig2, normalize kwarg must either be 'sig1' or 'sig2'."
183
+ )
184
+
185
+ normalization_value = inner_product(
186
+ sig_to_normalize,
187
+ sig_to_normalize,
188
+ psd=psd,
189
+ normalize=False,
190
+ )
191
+
192
+ elif normalize is not False:
193
+ raise ValueError("Normalize must be True, False, 'sig1', or 'sig2'.")
194
+
195
+ out /= normalization_value
196
+ return out
197
+
198
+
199
+ def residual_source_likelihood_term(
200
+ data_res_arr: DataResidualArray, **kwargs: dict
201
+ ) -> float | complex:
202
+ """Calculate the source term in the Likelihood for a data residual (d - h).
203
+
204
+ The source term in the likelihood is given by,
205
+
206
+ .. math::
207
+
208
+ \\log{\\mathcal{L}}_\\text{src} = -\\frac{1}{2}\\langle \\vec{d} - \\vec{h} | \\vec{d} - \\vec{h}\\rangle.
209
+
210
+ Args:
211
+ data_res_arr: Data residual.
212
+ **kwargs: Keyword arguments to pass to :func:`inner_product`.
213
+
214
+ Returns:
215
+ Source term Likelihood value.
216
+
217
+ """
218
+ kwargs["normalize"] = False
219
+ ip_val = inner_product(data_res_arr, data_res_arr, **kwargs)
220
+ return -1 / 2.0 * ip_val
221
+
222
+
223
+ def noise_likelihood_term(psd: SensitivityMatrix) -> float:
224
+ """Calculate the noise term in the Likelihood.
225
+
226
+ The noise term in the likelihood is given by,
227
+
228
+ .. math::
229
+
230
+ \\log{\\mathcal{L}}_n = -\\sum \\log{\\vec{S}_n}.
231
+
232
+ Args:
233
+ psd: Sensitivity information.
234
+
235
+ Returns:
236
+ Noise term Likelihood value.
237
+
238
+ """
239
+ fix = np.isnan(psd[:]) | np.isinf(psd[:])
240
+ assert np.sum(fix) == np.prod(psd.shape[:-1]) or np.sum(fix) == 0
241
+ nl_val = -1.0 / 2.0 * np.sum(np.log(psd[~fix]))
242
+ return nl_val
243
+
244
+
245
+ def residual_full_source_and_noise_likelihood(
246
+ data_res_arr: DataResidualArray,
247
+ psd: str | NoneType | np.ndarray | SensitivityMatrix,
248
+ **kwargs: dict,
249
+ ) -> float | complex:
250
+ """Calculate the full Likelihood including noise and source terms.
251
+
252
+ The noise term is calculated with :func:`noise_likelihood_term`.
253
+
254
+ The source term is calcualted with :func:`residual_source_likelihood_term`.
255
+
256
+ Args:
257
+ data_res_arr: Data residual.
258
+ psd: Sensitivity information.
259
+ **kwargs: Keyword arguments to pass to :func:`inner_product`.
260
+
261
+ Returns:
262
+ Full Likelihood value.
263
+
264
+ """
265
+ if not isinstance(psd, SensitivityMatrix):
266
+ # TODO: maybe adjust so it can take a list just like Sensitivity matrix
267
+ psd = SensitivityMatrix(data_res_arr.f_arr, [psd], **kwargs)
268
+
269
+ # remove key
270
+ for key in "psd", "psd_args", "psd_kwargs":
271
+ if key in kwargs:
272
+ kwargs.pop(key)
273
+
274
+ rslt = residual_source_likelihood_term(data_res_arr, psd=psd, **kwargs)
275
+
276
+ nlt = noise_likelihood_term(psd)
277
+ return nlt + rslt
278
+
279
+
280
+ def data_signal_source_likelihood_term(
281
+ data_arr: DataResidualArray, sig_arr: DataResidualArray, **kwargs: dict
282
+ ) -> float | complex:
283
+ """Calculate the source term in the Likelihood for separate signal and data.
284
+
285
+ The source term in the likelihood is given by,
286
+
287
+ .. math::
288
+
289
+ \\log{\\mathcal{L}}_\\text{src} = -\\frac{1}{2}\\left(\\langle \\vec{d} | \\vec{d}\\rangle + \\langle \\vec{h} | \\vec{h}\\rangle - 2\\langle \\vec{d} | \\vec{h}\\rangle \\right)\ \ .
290
+
291
+ Args:
292
+ data_arr: Data.
293
+ sig_arr: Signal.
294
+ **kwargs: Keyword arguments to pass to :func:`inner_product`.
295
+
296
+ Returns:
297
+ Source term Likelihood value.
298
+
299
+ """
300
+ kwargs["normalize"] = False
301
+ d_h = inner_product(data_arr, sig_arr, **kwargs)
302
+ h_h = inner_product(sig_arr, sig_arr, **kwargs)
303
+ d_d = inner_product(data_arr, data_arr, **kwargs)
304
+ return -1 / 2.0 * (d_d + h_h - 2 * d_h)
305
+
306
+
307
+ def data_signal_full_source_and_noise_likelihood(
308
+ data_arr: DataResidualArray,
309
+ sig_arr: DataResidualArray,
310
+ psd: str | NoneType | np.ndarray | SensitivityMatrix,
311
+ **kwargs: dict,
312
+ ) -> float | complex:
313
+ """Calculate the full Likelihood including noise and source terms.
314
+
315
+ Here, the signal is treated separate from the data.
316
+
317
+ The noise term is calculated with :func:`noise_likelihood_term`.
318
+
319
+ The source term is calcualted with :func:`data_signal_source_likelihood_term`.
320
+
321
+ Args:
322
+ data_arr: Data.
323
+ sig_arr: Signal.
324
+ psd: Sensitivity information.
325
+ **kwargs: Keyword arguments to pass to :func:`inner_product`.
326
+
327
+ Returns:
328
+ Full Likelihood value.
329
+
330
+ """
331
+ if not isinstance(psd, SensitivityMatrix):
332
+ # TODO: maybe adjust so it can take a list just like Sensitivity matrix
333
+ psd = SensitivityMatrix(data_arr.f_arr, [psd], **kwargs)
334
+
335
+ # remove key
336
+ for key in "psd", "psd_args", "psd_kwargs":
337
+ if key in kwargs:
338
+ kwargs.pop(key)
339
+
340
+ rslt = data_signal_source_likelihood_term(data_arr, sig_arr, psd=psd, **kwargs)
341
+
342
+ nlt = noise_likelihood_term(psd)
343
+
344
+ return nlt + rslt
345
+
346
+
347
+ def snr(
348
+ sig1: np.ndarray | list | DataResidualArray,
349
+ *args: Any,
350
+ data: Optional[np.ndarray | list | DataResidualArray] = None,
351
+ **kwargs: Any,
352
+ ) -> float:
353
+ """Compute the snr between two signals weighted by a psd.
354
+
355
+ The signal-to-noise ratio of a signal is :math:`\\sqrt{\\langle a|a\\rangle}`.
356
+
357
+ This will be the optimal SNR if ``data==None``. If a data array is given, it will be the observed
358
+ SNR: :math:`\\langle d|a\\rangle/\\sqrt{\\langle a|a\\rangle}`.
359
+
360
+ **GPU Capability**: Pass CuPy arrays rather than NumPy arrays.
361
+
362
+ Args:
363
+ sig1: Signal to use as the templatefor the SNR.
364
+ Can be time-domain or frequency-domain.
365
+ Must be 1D ``np.ndarray``, list of 1D ``np.ndarray``s, or 2D ``np.ndarray``
366
+ across channels with shape ``(nchannels, data length)``.
367
+ *args: Arguments to pass to :func:`inner_product`.
368
+ data: Data becomes the ``sig2`` argument to :func:`inner_product`.
369
+ **kwargs: Keyword arguments to pass to :func:`inner_product`.
370
+
371
+ Returns:
372
+ Optimal or detected SNR value (depending on ``data`` kwarg).
373
+
374
+ """
375
+
376
+ # get optimal SNR
377
+ opt_snr = np.sqrt(inner_product(sig1, sig1, *args, **kwargs).real)
378
+ if data is None:
379
+ return opt_snr
380
+
381
+ else:
382
+ # if inputed data, calculate detected SNR
383
+ det_snr = inner_product(sig1, data, *args, **kwargs).real / opt_snr
384
+ return det_snr
385
+
386
+
387
+ def h_var_p_eps(
388
+ step: float,
389
+ waveform_model: callable,
390
+ params: ArrayLike,
391
+ index: int,
392
+ parameter_transforms: Optional[TransformContainer] = None,
393
+ waveform_args: Optional[tuple] = (),
394
+ waveform_kwargs: Optional[dict] = {},
395
+ ) -> np.ndarray: # TODO: check this
396
+ """Calculate the waveform with a perturbation step of the variable V[i]
397
+
398
+ Args:
399
+ waveform_model: Callable function to the waveform generator with signature ``(*params, **waveform_kwargs)``.
400
+ params: Source parameters that are over derivatives (not in fill dict of parameter transforms)
401
+ step: Absolute step size for variable of interest.
402
+ index: Index to parameter of interest.
403
+ parameter_transforms: `TransformContainer <https://mikekatz04.github.io/Eryn/html/user/utils.html#eryn.utils.TransformContainer>`_ object to transform from the derivative parameter basis
404
+ to the waveform parameter basis. This class can also fill in fixed parameters where the derivatives are not being taken.
405
+ waveform_args: args (beyond parameters) for the waveform generator.
406
+ waveform_kwargs: kwargs for the waveform generation.
407
+
408
+ Returns:
409
+ Perturbation to the waveform in the given parameter. Will always be 2D array with shape ``(num channels, data length)``
410
+
411
+ """
412
+ params_p_eps = params.copy()
413
+ params_p_eps[index] += step
414
+
415
+ if parameter_transforms is not None:
416
+ # transform
417
+ params_p_eps = parameter_transforms.transform_base_parameters(params_p_eps)
418
+
419
+ args_in = tuple(params_p_eps) + tuple(waveform_args)
420
+ dh = waveform_model(*args_in, **waveform_kwargs)
421
+
422
+ # adjust output based on waveform model output
423
+ # needs to be 2D array
424
+ if (isinstance(dh, np.ndarray) or isinstance(dh, cp.ndarray)) and dh.ndim == 1:
425
+ xp = get_array_module(dh)
426
+ dh = xp.atleast_2d(dh)
427
+
428
+ elif isinstance(dh, list):
429
+ xp = get_array_module(dh[0])
430
+ dh = xp.asarray(dh)
431
+
432
+ return dh
433
+
434
+
435
+ def dh_dlambda(
436
+ eps: float,
437
+ *args: tuple,
438
+ more_accurate: Optional[bool] = True,
439
+ **kwargs: dict,
440
+ ) -> np.ndarray:
441
+ """Derivative of the waveform
442
+
443
+ Calculate the derivative of the waveform with precision of order (step^4)
444
+ with respect to the variable V in the i direction.
445
+
446
+ Args:
447
+ eps: Absolute **derivative** step size for variable of interest.
448
+ *args: Arguments passed to :func:`h_var_p_eps`.
449
+ more_accurate: If ``True``, run a more accurate derivate requiring 2x more waveform generations.
450
+ **kwargs: Keyword arguments passed to :func:`h_var_p_eps`.
451
+
452
+ Returns:
453
+ Numerical derivative of the waveform with respect to a varibale of interest. Will be 2D array
454
+ with shape ``(num channels, data length)``.
455
+
456
+ """
457
+ if more_accurate:
458
+ # Derivative of the Waveform
459
+ # up
460
+ h_I_up_2eps = h_var_p_eps(2 * eps, *args, **kwargs)
461
+ h_I_up_eps = h_var_p_eps(eps, *args, **kwargs)
462
+ # down
463
+ h_I_down_2eps = h_var_p_eps(-2 * eps, *args, **kwargs)
464
+ h_I_down_eps = h_var_p_eps(-eps, *args, **kwargs)
465
+
466
+ # make sure they are all the same length
467
+ ind_max = np.min(
468
+ [
469
+ h_I_up_2eps.shape[1],
470
+ h_I_up_eps.shape[1],
471
+ h_I_down_2eps.shape[1],
472
+ h_I_down_eps.shape[1],
473
+ ]
474
+ )
475
+
476
+ # error scales as eps^4
477
+ dh_I = (
478
+ -h_I_up_2eps[:, :ind_max]
479
+ + h_I_down_2eps[:, :ind_max]
480
+ + 8 * (h_I_up_eps[:, :ind_max] - h_I_down_eps[:, :ind_max])
481
+ ) / (12 * eps)
482
+
483
+ else:
484
+ # Derivative of the Waveform
485
+ # up
486
+ h_I_up_eps = h_var_p_eps(eps, *args, **kwargs)
487
+ # down
488
+ h_I_down_eps = h_var_p_eps(-eps, *args, **kwargs)
489
+
490
+ ind_max = np.min([h_I_up_eps.shape[1], h_I_down_eps.shape[1]])
491
+
492
+ # TODO: check what error scales as.
493
+ dh_I = (h_I_up_eps[:, :ind_max] - h_I_down_eps[:, :ind_max]) / (2 * eps)
494
+ # Time thta it takes for one variable: approx 5 minutes
495
+
496
+ return dh_I
497
+
498
+
499
+ def info_matrix(
500
+ eps: float | np.ndarray,
501
+ waveform_model: callable,
502
+ params: ArrayLike,
503
+ deriv_inds: Optional[ArrayLike] = None,
504
+ inner_product_kwargs: Optional[dict] = {},
505
+ return_derivs: Optional[bool] = False,
506
+ **kwargs: dict,
507
+ ) -> np.ndarray | Tuple[np.ndarray, list]:
508
+ """Calculate Information Matrix.
509
+
510
+ This calculates the information matrix for a given waveform model at a given set of parameters.
511
+ The inverse of the information matrix gives the covariance matrix.
512
+
513
+ This is also referred to as the Fisher information matrix, but @MichaelKatz has chosen to leave out the name because of `this <https://www.newstatesman.com/long-reads/2020/07/ra-fisher-and-science-hatred>`_.
514
+
515
+ The info matrix is given by:
516
+
517
+ .. math::
518
+
519
+ M_{ij} = \\langle h_i | h_j \\rangle \\text{ with } h_i = \\frac{\\partial h}{\\partial \\lambda_i}.
520
+
521
+ Args:
522
+ eps: Absolute **derivative** step size for variable of interest. Can be provided as a ``float`` value that applies
523
+ to all variables or an array, one for each parameter being evaluated in the information matrix.
524
+ waveform_model: Callable function to the waveform generator with signature ``(*params, **waveform_kwargs)``.
525
+ params: Source parameters.
526
+ deriv_inds: Subset of parameters of interest for which to calculate the information matrix, by index.
527
+ If ``None``, it will be ``np.arange(len(params))``.
528
+ inner_product_kwargs: Keyword arguments for the inner product function.
529
+ return_derivs: If ``True``, also returns computed numerical derivatives.
530
+ **kwargs: Keyword arguments passed to :func:`dh_dlambda`.
531
+
532
+ Returns:
533
+ If ``not return_derivs``, this will be the information matrix as a numpy array. If ``return_derivs is True``,
534
+ it will be a tuple with the first entry as the information matrix and the second entry as the partial derivatives.
535
+
536
+ """
537
+
538
+ # setup initial information
539
+ num_params = len(params)
540
+
541
+ if deriv_inds is None:
542
+ deriv_inds = np.arange(num_params)
543
+
544
+ num_info_params = len(deriv_inds)
545
+
546
+ if isinstance(eps, float):
547
+ eps = np.full_like(params, eps)
548
+
549
+ # collect derivatives over the variables of interest
550
+ dh = []
551
+ for i, eps_i in zip(deriv_inds, eps):
552
+ # derivative up
553
+ temp = dh_dlambda(eps_i, waveform_model, params, i, **kwargs)
554
+ dh.append(temp)
555
+
556
+ # calculate the components of the symmetric matrix
557
+ info = np.zeros((num_info_params, num_info_params))
558
+ for i in range(num_info_params):
559
+ for j in range(i, num_info_params):
560
+ info[i][j] = inner_product(
561
+ [dh[i][k] for k in range(len(dh[i]))],
562
+ [dh[j][k] for k in range(len(dh[i]))],
563
+ **inner_product_kwargs,
564
+ )
565
+ info[j][i] = info[i][j]
566
+
567
+ if return_derivs:
568
+ return info, dh
569
+ else:
570
+ return info
571
+
572
+
573
+ def covariance(
574
+ *info_mat_args: tuple,
575
+ info_mat: Optional[np.ndarray] = None,
576
+ diagonalize: Optional[bool] = False,
577
+ return_info_mat: Optional[bool] = False,
578
+ precision: Optional[bool] = False,
579
+ **info_mat_kwargs: dict,
580
+ ) -> np.ndarray | list:
581
+ """Calculate covariance matrix for a set of EMRI parameters, computing the information matrix if not supplied.
582
+
583
+ Args:
584
+ *info_mat_args: Set of arguments to pass to :func:`info_matrix`. Not required if ``info_mat`` is not ``None``.
585
+ info_mat: Pre-computed information matrix. If supplied, this matrix will be inverted.
586
+ diagonalize: If ``True``, diagonalizes the covariance matrix.
587
+ return_info_mat: If ``True``, also returns the computed information matrix.
588
+ precision: If ``True``, uses 500-dps precision to compute the information matrix inverse (requires `mpmath <https://mpmath.org>`_). This is typically a good idea as the information matrix can be highly ill-conditioned.
589
+ **info_mat_kwargs: Keyword arguments to pass to :func:`info_matrix`.
590
+
591
+ Returns:
592
+ Covariance matrix. If ``return_info_mat is True``. A list will be returned with the covariance as the first
593
+ entry and the information matrix as the second entry. If ``return_derivs is True`` (keyword argument to :func:`info_matrix`),
594
+ then another entry will be added to the list for the derivatives.
595
+
596
+ """
597
+
598
+ if info_mat is None:
599
+ info_mat = info_matrix(*info_mat_args, **info_mat_kwargs)
600
+
601
+ # parse output properly
602
+ if "return_derivs" in info_mat_kwargs and info_mat_kwargs["return_derivs"]:
603
+ return_derivs = True
604
+ info_mat, dh = info_mat
605
+
606
+ else:
607
+ return_derivs = False
608
+
609
+ # attempt to import and setup mpmath if precision required
610
+ if precision:
611
+ try:
612
+ import mpmath as mp
613
+
614
+ mp.mp.dps = 500
615
+ except ModuleNotFoundError:
616
+ print("mpmath module not installed. Defaulting to low precision...")
617
+ precision = False
618
+
619
+ if precision:
620
+ hp_info_mat = mp.matrix(info_mat.tolist())
621
+ U, S, V = mp.svd_r(hp_info_mat) # singular value decomposition
622
+ temp = mp.diag([val ** (-1) for val in S]) # get S**-1
623
+ temp2 = V.T * temp * U.T # construct pseudo-inverse
624
+ cov = np.array(temp2.tolist(), dtype=np.float64)
625
+ else:
626
+ cov = np.linalg.pinv(info_mat)
627
+
628
+ if diagonalize:
629
+ # get eigeninformation
630
+ eig_vals, eig_vecs = get_eigeninfo(cov, high_precision=precision)
631
+
632
+ # diagonal cov now
633
+ cov = np.dot(np.dot(eig_vecs.T, cov), eig_vecs)
634
+
635
+ # just requesting covariance
636
+ if True not in [return_info_mat, return_derivs]:
637
+ return cov
638
+
639
+ # if desiring more information, create a list capable of variable size
640
+ returns = [cov]
641
+
642
+ # add information matrix
643
+ if return_info_mat:
644
+ returns.append(info_mat)
645
+
646
+ # add derivatives
647
+ if return_derivs:
648
+ returns.append(dh)
649
+
650
+ return returns
651
+
652
+
653
+ def plot_covariance_corner(
654
+ params: np.ndarray,
655
+ cov: np.ndarray,
656
+ nsamp: Optional[int] = 25000,
657
+ fig: Optional[plt.Figure] = None,
658
+ **kwargs: dict,
659
+ ) -> plt.Figure:
660
+ """Construct a corner plot for a given covariance matrix.
661
+
662
+ The `corner <https://corner.readthedocs.io/en/latest/>`_ module is required for this.
663
+
664
+ Args:
665
+ params: The set of parameters used for the event (the mean vector of the covariance matrix).
666
+ cov: Covariance matrix from which to construct the corner plot.
667
+ nsamp: Number of samples to draw from the multivariate distribution.
668
+ fig: Matplotlib :class:`plt.Figure` object. Use this if passing an existing corner plot figure.
669
+ **kwargs: Keyword arguments for the corner plot - see the module documentation for more info.
670
+
671
+ Returns:
672
+ The corner plot figure.
673
+
674
+ """
675
+
676
+ # TODO: add capability for ChainConsumer?
677
+ try:
678
+ import corner
679
+ except ModuleNotFoundError:
680
+ raise ValueError(
681
+ "Attempting to plot using the corner module, but it is not installed."
682
+ )
683
+
684
+ # generate fake samples from the covariance distribution
685
+ samp = np.random.multivariate_normal(params, cov, size=nsamp)
686
+
687
+ # make corner plot
688
+ fig = corner.corner(samp, **kwargs)
689
+ return fig
690
+
691
+
692
+ def plot_covariance_contour(
693
+ params: np.ndarray,
694
+ cov: np.ndarray,
695
+ horizontal_index: int,
696
+ vertical_index: int,
697
+ nsamp: Optional[int] = 25000,
698
+ ax: Optional[plt.Axes] = None,
699
+ **kwargs: dict,
700
+ ) -> plt.Axes | Tuple[plt.Figure, plt.Axes]:
701
+ """Construct a contour plot for a given covariance matrix on a single axis object.
702
+
703
+ The `corner <https://corner.readthedocs.io/en/latest/>`_ module is required for this.
704
+
705
+ Args:
706
+ params: The set of parameters used for the event (the mean vector of the covariance matrix).
707
+ cov: Covariance matrix from which to construct the corner plot.
708
+ horizontal_index: Parameter index to plot along the horizontal axis of the contour plot.
709
+ vertical_index: Parameter index to plot along the vertical axis of the contour plot.
710
+ nsamp: Number of samples to draw from the multivariate distribution.
711
+ fig: Matplotlib :class:`plt.Figure` object. Use this if passing an existing corner plot figure.
712
+ **kwargs: Keyword arguments for the corner plot - see the module documentation for more info.
713
+
714
+ Returns:
715
+ If ``ax`` is provided, the return will be that ax object. If it is not provided, then a
716
+ Matplotlib Figure and Axes obejct is created and returned as a tuple: ``(plt.Figure, plt.Axes)``.
717
+
718
+ """
719
+
720
+ # TODO: add capability for ChainConsumer?
721
+ try:
722
+ import corner
723
+ except ModuleNotFoundError:
724
+ raise ModuleNotFoundError(
725
+ "Attempting to plot using the corner module, but it is not installed."
726
+ )
727
+
728
+ if ax is None:
729
+ fig, ax = plt.subplots(1, 1)
730
+ else:
731
+ fig = None
732
+
733
+ # generate fake samples from the covariance distribution
734
+ samp = np.random.multivariate_normal(params, cov, size=nsamp)
735
+
736
+ x = samp[:, horizontal_index]
737
+ y = samp[:, vertical_index]
738
+
739
+ # make corner plot
740
+ corner.hist2d(x, y, ax=ax, **kwargs)
741
+
742
+ if fig is None:
743
+ return ax
744
+ else:
745
+ return (fig, ax)
746
+
747
+
748
+ def get_eigeninfo(
749
+ arr: np.ndarray, high_precision: Optional[bool] = False
750
+ ) -> Tuple[np.ndarray, np.ndarray]:
751
+ """Performs eigenvalue decomposition and returns the eigenvalues and right-eigenvectors for the supplied fisher/covariance matrix.
752
+
753
+ Args:
754
+ arr: Input matrix for which to perform eigenvalue decomposition.
755
+ high_precision: If ``True``, use 500-dps precision to ensure accurate eigenvalue decomposition
756
+ (requires `mpmath <https://mpmath.org>`_ to be installed). Defaults to False.
757
+
758
+ Returns:
759
+ Tuple containing Eigenvalues and right-Eigenvectors for the supplied array, constructed such that evects[:,k] corresponds to evals[k].
760
+
761
+
762
+ """
763
+
764
+ if high_precision:
765
+ try:
766
+ import mpmath as mp
767
+
768
+ mp.mp.dps = 500
769
+ except ModuleNotFoundError:
770
+ print("mpmath is not installed - using low-precision eigen decomposition.")
771
+ high_precision = False
772
+
773
+ if high_precision:
774
+ hp_arr = mp.matrix(arr.tolist())
775
+ # get eigenvectors
776
+ E, EL, ER = mp.eig(hp_arr, left=True, right=True)
777
+
778
+ # convert back
779
+ evals = np.array(E, dtype=np.float64)
780
+ evects = np.array(ER.tolist(), dtype=np.float64)
781
+
782
+ return evals, evects
783
+
784
+ else:
785
+ evals, evects = np.linalg.eig(arr)
786
+ return evals, evects
787
+
788
+
789
+ def cutler_vallisneri_bias(
790
+ waveform_model_true: callable,
791
+ waveform_model_approx: callable,
792
+ params: np.ndarray,
793
+ eps: float | np.ndarray,
794
+ input_diagnostics: Optional[dict] = None,
795
+ info_mat: Optional[np.ndarray] = None,
796
+ deriv_inds: Optional[ArrayLike] = None,
797
+ return_derivs: Optional[bool] = False,
798
+ return_cov: Optional[bool] = False,
799
+ parameter_transforms: Optional[TransformContainer] = None,
800
+ waveform_true_args: Optional[tuple] = (),
801
+ waveform_true_kwargs: Optional[dict] = {},
802
+ waveform_approx_args: Optional[tuple] = (),
803
+ waveform_approx_kwargs: Optional[dict] = {},
804
+ inner_product_kwargs: Optional[dict] = {},
805
+ ) -> list:
806
+ """Calculate the Cutler-Vallisneri bias.
807
+
808
+ # TODO: add basic math
809
+
810
+ Args:
811
+ waveform_model_true: Callable function to the **true** waveform generator with signature ``(*params, **waveform_kwargs)``.
812
+ waveform_model_approx: Callable function to the **approximate** waveform generator with signature ``(*params, **waveform_kwargs)``.
813
+ params: Source parameters.
814
+ eps: Absolute **derivative** step size. See :func:`info_matrix`.
815
+ input_diagnostics: Dictionary including the diagnostic information if it is precomputed. Dictionary must include
816
+ keys ``"cov"`` (covariance matrix, output of :func:`covariance`), ``"h_true"`` (the **true** waveform),
817
+ and ``"dh"`` (derivatives of the waveforms, list of outputs from :func:`dh_dlambda`).
818
+ info_mat: Pre-computed information matrix. If supplied, this matrix will be inverted to find the covariance.
819
+ deriv_inds: Subset of parameters of interest. See :func:`info_matrix`.
820
+ return_derivs: If ``True``, also returns computed numerical derivatives.
821
+ return_cov: If ``True``, also returns computed covariance matrix.
822
+ parameter_transforms: `TransformContainer <https://mikekatz04.github.io/Eryn/html/user/utils.html#eryn.utils.TransformContainer>`_ object. See :func:`info_matrix`.
823
+ waveform_true_args: Arguments for the **true** waveform generator.
824
+ waveform_true_kwargs: Keyword arguments for the **true** waveform generator.
825
+ waveform_approx_args: Arguments for the **approximate** waveform generator.
826
+ waveform_approx_kwargs: Keyword arguments for the **approximate** waveform generator.
827
+ inner_product_kwargs: Keyword arguments for the inner product function.
828
+
829
+ Returns:
830
+ List of return information. By default, it is ``[systematic error, bias]``.
831
+ If ``return_derivs`` or ``return_cov`` are ``True``, they will be added to the list with derivs added before covs.
832
+
833
+ """
834
+
835
+ if deriv_inds is None:
836
+ deriv_inds = np.arange(len(params))
837
+
838
+ if info_mat is not None and input_diagnostics is not None:
839
+ warnings.warn(
840
+ "Provided info_mat and input_diagnostics kwargs. Ignoring info_mat."
841
+ )
842
+
843
+ # adjust parameters to waveform basis
844
+ params_in = parameter_transforms.transform_base_parameters(params.copy())
845
+
846
+ if input_diagnostics is None:
847
+ # get true waveform
848
+ h_true = waveform_model_true(
849
+ *(tuple(params_in) + tuple(waveform_true_args)), **waveform_true_kwargs
850
+ )
851
+
852
+ # get covariance info and waveform derivatives
853
+ cov, dh = covariance(
854
+ eps,
855
+ waveform_model_true,
856
+ params,
857
+ return_derivs=True,
858
+ deriv_inds=deriv_inds,
859
+ info_mat=info_mat,
860
+ parameter_transforms=parameter_transforms,
861
+ waveform_args=waveform_true_args,
862
+ waveform_kwargs=waveform_true_kwargs,
863
+ inner_product_kwargs=inner_product_kwargs,
864
+ )
865
+
866
+ else:
867
+ # pre-computed info
868
+ cov = input_diagnostics["cov"]
869
+ h_true = input_diagnostics["h_true"]
870
+ dh = input_diagnostics["dh"]
871
+
872
+ # get approximate waveform
873
+ h_approx = waveform_model_approx(
874
+ *(tuple(params_in) + tuple(waveform_approx_args)), **waveform_approx_kwargs
875
+ )
876
+
877
+ # adjust/check waveform outputs
878
+ if isinstance(h_true, np.ndarray) and h_true.ndim == 1:
879
+ h_true = [h_true]
880
+ elif isinstance(h_true, np.ndarray) and h_true.ndim == 2:
881
+ h_true = list(h_true)
882
+
883
+ if isinstance(h_approx, np.ndarray) and h_approx.ndim == 1:
884
+ h_approx = [h_approx]
885
+ elif isinstance(h_approx, np.ndarray) and h_approx.ndim == 2:
886
+ h_approx = list(h_approx)
887
+
888
+ assert len(h_approx) == len(h_true)
889
+ assert np.all(
890
+ np.asarray([len(h_approx[i]) == len(h_true[i]) for i in range(len(h_true))])
891
+ )
892
+
893
+ # difference in the waveforms
894
+ diff = [h_true[i] - h_approx[i] for i in range(len(h_approx))]
895
+
896
+ # systematic err
897
+ syst_vec = np.array(
898
+ [
899
+ inner_product(
900
+ dh[k],
901
+ diff,
902
+ **inner_product_kwargs,
903
+ )
904
+ for k in range(len(deriv_inds))
905
+ ]
906
+ )
907
+
908
+ bias = np.dot(cov, syst_vec)
909
+
910
+ # return list
911
+ returns = [syst_vec, bias]
912
+
913
+ # add anything requested
914
+ if return_cov:
915
+ returns.append(cov)
916
+
917
+ if return_derivs:
918
+ returns.append(dh)
919
+
920
+ return returns
921
+
922
+
923
+ def scale_to_snr(
924
+ target_snr: float,
925
+ sig: np.ndarray | list,
926
+ *snr_args: tuple,
927
+ return_orig_snr: Optional[bool] = False,
928
+ **snr_kwargs: dict,
929
+ ) -> np.ndarray | list | Tuple[np.ndarray | list, float]:
930
+ """Calculate the SNR and scale a signal.
931
+
932
+ Args:
933
+ target_snr: Desired SNR value for the injected signal.
934
+ sig: Signal to adjust. A copy will be made.
935
+ Can be time-domain or frequency-domain.
936
+ Must be 1D ``np.ndarray``, list of 1D ``np.ndarray``s, or 2D ``np.ndarray``
937
+ across channels with shape ``(nchannels, data length)``.
938
+ *snr_args: Arguments to pass to :func:`snr`.
939
+ return_orig_snr: If ``True``, return the original SNR in addition to the adjusted data.
940
+ **snr_kwargs: Keyword arguments to pass to :func:`snr`.
941
+
942
+ Returns:
943
+ Returns the copied input signal adjusted to the target SNR. If ``return_orig_snr is True``, the original
944
+ SNR is added as the second entry of a tuple with the adjusted signal (as the first entry in the tuple).
945
+
946
+ """
947
+ # get the snr and adjustment factor
948
+ snr_out = snr(sig, *snr_args, **snr_kwargs)
949
+ factor = target_snr / snr_out
950
+
951
+ # any changes back to the original signal type
952
+ back_single = False
953
+ back_2d_array = False
954
+
955
+ if isinstance(sig, list) is False and sig.ndim == 1:
956
+ sig = [sig]
957
+ back_single = True
958
+
959
+ elif isinstance(sig, list) is False and sig.ndim == 2:
960
+ sig = list(sig)
961
+ back_2d_array = True
962
+
963
+ # adjust
964
+ out = [sig_i * factor for sig_i in sig]
965
+
966
+ # adjust type back to the input type
967
+ if back_2d_array:
968
+ out = np.asarray(out)
969
+
970
+ if back_single:
971
+ out = out[0]
972
+
973
+ if return_orig_snr:
974
+ return (out, snr_out)
975
+
976
+ return out