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