freealg 0.0.3__py3-none-any.whl → 0.1.1__py3-none-any.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.
@@ -0,0 +1,568 @@
1
+ # SPDX-FileCopyrightText: Copyright 2025, Siavash Ameli <sameli@berkeley.edu>
2
+ # SPDX-License-Identifier: BSD-3-Clause
3
+ # SPDX-FileType: SOURCE
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify it
6
+ # under the terms of the license found in the LICENSE.txt file in the root
7
+ # directory of this source tree.
8
+
9
+
10
+ # =======
11
+ # Imports
12
+ # =======
13
+
14
+ import numpy
15
+ from scipy.interpolate import interp1d
16
+ from .._plot_util import plot_density, plot_hilbert, plot_stieltjes, \
17
+ plot_stieltjes_on_disk, plot_samples
18
+
19
+ try:
20
+ from scipy.integrate import cumtrapz
21
+ except ImportError:
22
+ from scipy.integrate import cumulative_trapezoid as cumtrapz
23
+
24
+ __all__ = ['Wachter']
25
+
26
+
27
+ # =======
28
+ # Wachter
29
+ # =======
30
+
31
+ class Wachter(object):
32
+ """
33
+ Wachter distribution.
34
+
35
+ Parameters
36
+ ----------
37
+
38
+ a : float
39
+ Parameter :math:`a` of the distribution. See Notes.
40
+
41
+ b : float
42
+ Parameter :math:`b` of the distribution. See Notes.
43
+
44
+ Methods
45
+ -------
46
+
47
+ density
48
+ Spectral density of distribution.
49
+
50
+ hilbert
51
+ Hilbert transform of distribution.
52
+
53
+ stieltjes
54
+ Stieltjes transform of distribution.
55
+
56
+ sample
57
+ Sample from distribution.
58
+
59
+ matrix
60
+ Generate matrix with its empirical spectral density of distribution
61
+
62
+ Notes
63
+ -----
64
+
65
+ The Marchenko-Pastur distribution has the absolutely-continuous density
66
+
67
+ .. math::
68
+
69
+ \\mathrm{d} \\rho(x) =
70
+ \\frac{(a + b) )
71
+ \\sqrt{(\\lambda_{+} - x) (x - \\lambda_{-})}}{2 \\pi x (1 - x)}
72
+ \\mathbf{1}_{x \\in [\\lambda_{-}, \\lambda_{+}]} \\mathrm{d}{x}
73
+
74
+ where :math:`a, b` are the shape parameters of the distributon. The edges
75
+ of the support are
76
+
77
+ .. math::
78
+
79
+ \\lambda_{\\pm} = \\left( \\frac{\\sqrt{b} \\pm \\sqrt{a (a+b-1)}}
80
+ {a+b} \\right)^2.
81
+
82
+ References
83
+ ----------
84
+
85
+ .. [1] Wachter, K. W. (1978). The strong limits of random matrix spectra
86
+ for sample matrices of independent elements. The Annals of
87
+ Probability, 6(1), 1-18
88
+
89
+ Examples
90
+ --------
91
+
92
+ .. code-block:: python
93
+
94
+ >>> from freealg.distributions import Wachter
95
+ >>> wa = Wachter(2, 3)
96
+ """
97
+
98
+ # ====
99
+ # init
100
+ # ====
101
+
102
+ def __init__(self, a, b):
103
+ """
104
+ Initialization.
105
+ """
106
+
107
+ self.a = a
108
+ self.b = b
109
+ self.lam_p = ((numpy.sqrt(b) + numpy.sqrt(a * (a+b-1))) / (a + b))**2
110
+ self.lam_m = ((numpy.sqrt(b) - numpy.sqrt(a * (a+b-1))) / (a + b))**2
111
+ self.support = (self.lam_m, self.lam_p)
112
+
113
+ # =======
114
+ # density
115
+ # =======
116
+
117
+ def density(self, x=None, plot=False, latex=False, save=False):
118
+ """
119
+ Density of distribution.
120
+
121
+ Parameters
122
+ ----------
123
+
124
+ x : numpy.array, default=None
125
+ The locations where density is evaluated at. If `None`, an interval
126
+ slightly larger than the support interval of the spectral density
127
+ is used.
128
+
129
+ rho : numpy.array, default=None
130
+ Density. If `None`, it will be computed.
131
+
132
+ plot : bool, default=False
133
+ If `True`, density is plotted.
134
+
135
+ latex : bool, default=False
136
+ If `True`, the plot is rendered using LaTeX. This option is
137
+ relevant only if ``plot=True``.
138
+
139
+ save : bool, default=False
140
+ If not `False`, the plot is saved. If a string is given, it is
141
+ assumed to the save filename (with the file extension). This option
142
+ is relevant only if ``plot=True``.
143
+
144
+ Returns
145
+ -------
146
+
147
+ rho : numpy.array
148
+ Density.
149
+
150
+ Examples
151
+ --------
152
+
153
+ .. code-block::python
154
+
155
+ >>> from freealg.distributions import Wachter
156
+ >>> wa = Wachter(2, 3)
157
+ >>> rho = wa.density(plot=True)
158
+
159
+ .. image:: ../_static/images/plots/wa_density.png
160
+ :align: center
161
+ :class: custom-dark
162
+ """
163
+
164
+ # Create x if not given
165
+ if x is None:
166
+ radius = 0.5 * (self.lam_p - self.lam_m)
167
+ center = 0.5 * (self.lam_p + self.lam_m)
168
+ scale = 1.25
169
+ x_min = numpy.floor(center - radius * scale)
170
+ x_max = numpy.ceil(center + radius * scale)
171
+ x = numpy.linspace(x_min, x_max, 500)
172
+
173
+ rho = numpy.zeros_like(x)
174
+ mask = numpy.logical_and(x > self.lam_m, x < self.lam_p)
175
+
176
+ rho[mask] = ((self.a + self.b) /
177
+ (2.0 * numpy.pi * x[mask] * (1.0 - x[mask]))) * \
178
+ numpy.sqrt((self.lam_p - x[mask]) * (x[mask] - self.lam_m))
179
+
180
+ if plot:
181
+ plot_density(x, rho, label='', latex=latex, save=save)
182
+
183
+ return rho
184
+
185
+ # =======
186
+ # hilbert
187
+ # =======
188
+
189
+ def hilbert(self, x=None, plot=False, latex=False, save=False):
190
+ """
191
+ Hilbert transform of the distribution.
192
+
193
+ Parameters
194
+ ----------
195
+
196
+ x : numpy.array, default=None
197
+ The locations where Hilbert transform is evaluated at. If `None`,
198
+ an interval slightly larger than the support interval of the
199
+ spectral density is used.
200
+
201
+ plot : bool, default=False
202
+ If `True`, Hilbert transform is plotted.
203
+
204
+ latex : bool, default=False
205
+ If `True`, the plot is rendered using LaTeX. This option is
206
+ relevant only if ``plot=True``.
207
+
208
+ save : bool, default=False
209
+ If not `False`, the plot is saved. If a string is given, it is
210
+ assumed to the save filename (with the file extension). This option
211
+ is relevant only if ``plot=True``.
212
+
213
+ Returns
214
+ -------
215
+
216
+ hilb : numpy.array
217
+ Hilbert transform.
218
+
219
+ Examples
220
+ --------
221
+
222
+ .. code-block::python
223
+
224
+ >>> from freealg.distributions import Wachter
225
+ >>> wa = Wachter(2, 3)
226
+ >>> hilb = wa.hilbert(plot=True)
227
+
228
+ .. image:: ../_static/images/plots/wa_hilbert.png
229
+ :align: center
230
+ :class: custom-dark
231
+ """
232
+
233
+ # Create x if not given
234
+ if x is None:
235
+ radius = 0.5 * (self.lam_p - self.lam_m)
236
+ center = 0.5 * (self.lam_p + self.lam_m)
237
+ scale = 1.25
238
+ x_min = numpy.floor(center - radius * scale)
239
+ x_max = numpy.ceil(center + radius * scale)
240
+ x = numpy.linspace(x_min, x_max, 500)
241
+
242
+ def _P(x):
243
+ return 1.0 - self.a + (self.a + self.b - 2.0) * x
244
+
245
+ def _Q(x):
246
+ return x * (1.0 - x)
247
+
248
+ P = _P(x)
249
+ Q = _Q(x)
250
+ Delta2 = P**2 - 4.0 * Q
251
+ Delta = numpy.sqrt(numpy.maximum(Delta2, 0))
252
+ sign = numpy.sign(P)
253
+ hilb = (P - sign * Delta) / (2.0 * Q)
254
+
255
+ # using negative sign convention
256
+ hilb = -hilb
257
+
258
+ if plot:
259
+ plot_hilbert(x, hilb, support=self.support, latex=latex, save=save)
260
+
261
+ return hilb
262
+
263
+ # =======================
264
+ # m mp numeric vectorized
265
+ # =======================
266
+
267
+ def _m_mp_numeric_vectorized(self, z, alt_branch=False, tol=1e-8):
268
+ """
269
+ Stieltjes transform (principal or secondary branch)
270
+ for Marchenko–Pastur distribution on upper half-plane.
271
+ """
272
+
273
+ sign = -1 if alt_branch else 1
274
+ A = z * (1.0 - z)
275
+ B = 1.0 - self.a + (self.a + self.b - 2.0) * z
276
+ D = B**2 - 4 * A
277
+ sqrtD = numpy.sqrt(D)
278
+ m1 = (-B + sqrtD) / (2 * A)
279
+ m2 = (-B - sqrtD) / (2 * A)
280
+
281
+ # pick correct branch only for non‑masked entries
282
+ upper = z.imag >= 0
283
+ branch = numpy.empty_like(m1)
284
+ branch[upper] = numpy.where(sign*m1[upper].imag > 0, m1[upper],
285
+ m2[upper])
286
+ branch[~upper] = numpy.where(sign*m1[~upper].imag < 0, m1[~upper],
287
+ m2[~upper])
288
+ m = branch
289
+
290
+ return m
291
+
292
+ # ============
293
+ # m mp reflect
294
+ # ============
295
+
296
+ def _m_mp_reflect(self, z, alt_branch=False):
297
+ """
298
+ Analytic continuation using Schwarz reflection.
299
+ """
300
+
301
+ mask_p = z.imag >= 0.0
302
+ mask_n = z.imag < 0.0
303
+
304
+ m = numpy.zeros_like(z)
305
+
306
+ f = self._m_mp_numeric_vectorized
307
+ m[mask_p] = f(z[mask_p], alt_branch=False)
308
+ m[mask_n] = f(z[mask_n], alt_branch=alt_branch)
309
+
310
+ return m
311
+
312
+ # =========
313
+ # stieltjes
314
+ # =========
315
+
316
+ def stieltjes(self, x=None, y=None, plot=False, on_disk=False, latex=False,
317
+ save=False):
318
+ """
319
+ Stieltjes transform of distribution.
320
+
321
+ Parameters
322
+ ----------
323
+
324
+ x : numpy.array, default=None
325
+ The x axis of the grid where the Stieltjes transform is evaluated.
326
+ If `None`, an interval slightly larger than the support interval of
327
+ the spectral density is used.
328
+
329
+ y : numpy.array, default=None
330
+ The y axis of the grid where the Stieltjes transform is evaluated.
331
+ If `None`, a grid on the interval ``[-1, 1]`` is used.
332
+
333
+ plot : bool, default=False
334
+ If `True`, Stieltjes transform is plotted.
335
+
336
+ on_disk : bool, default=False
337
+ If `True`, the Stieltjes transform is mapped on unit disk. This
338
+ option relevant only if ``plot=True``.
339
+
340
+ latex : bool, default=False
341
+ If `True`, the plot is rendered using LaTeX. This option is
342
+ relevant only if ``plot=True``.
343
+
344
+ save : bool, default=False
345
+ If not `False`, the plot is saved. If a string is given, it is
346
+ assumed to the save filename (with the file extension). This option
347
+ is relevant only if ``plot=True``.
348
+
349
+ Returns
350
+ -------
351
+
352
+ m1 : numpy.array
353
+ Stieltjes transform on principal branch.
354
+
355
+ m12 : numpy.array
356
+ Stieltjes transform on secondary branch.
357
+
358
+ Examples
359
+ --------
360
+
361
+ .. code-block:: python
362
+
363
+ >>> from freealg.distributions import Wachter
364
+ >>> wa = Wachter(2, 3)
365
+ >>> m1, m2 = wa.stieltjes(plot=True)
366
+
367
+ .. image:: ../_static/images/plots/wa_stieltjes.png
368
+ :align: center
369
+ :class: custom-dark
370
+
371
+ Plot on unit disk using Cayley transform:
372
+
373
+ .. code-block:: python
374
+
375
+ >>> m1, m2 = mp.stieltjes(plot=True, on_disk=True)
376
+
377
+ .. image:: ../_static/images/plots/wa_stieltjes_disk.png
378
+ :align: center
379
+ :class: custom-dark
380
+ """
381
+
382
+ if (plot is True) and (on_disk is True):
383
+ n_r = 1000
384
+ n_t = 1000
385
+ r_min, r_max = 0, 2.5
386
+ t_min, t_max = 0, 2.0 * numpy.pi
387
+ r = numpy.linspace(r_min, r_max, n_r)
388
+ t = numpy.linspace(t_min, t_max, n_t + 1)[:-1]
389
+ grid_r, grid_t = numpy.meshgrid(r, t)
390
+
391
+ grid_x_D = grid_r * numpy.cos(grid_t)
392
+ grid_y_D = grid_r * numpy.sin(grid_t)
393
+ zeta = grid_x_D + 1j * grid_y_D
394
+
395
+ # Cayley transform mapping zeta on D to z on H
396
+ z_H = 1j * (1 + zeta) / (1 - zeta)
397
+
398
+ m1_D = self._m_mp_reflect(z_H, alt_branch=False)
399
+ m2_D = self._m_mp_reflect(z_H, alt_branch=True)
400
+
401
+ plot_stieltjes_on_disk(r, t, m1_D, m2_D, support=self.support,
402
+ latex=latex, save=save)
403
+
404
+ return m1_D, m2_D
405
+
406
+ # Create x if not given
407
+ if x is None:
408
+ radius = 0.5 * (self.lam_p - self.lam_m)
409
+ center = 0.5 * (self.lam_p + self.lam_m)
410
+ scale = 2.0
411
+ x_min = numpy.floor(2.0 * (center - 2.0 * radius * scale)) / 2.0
412
+ x_max = numpy.ceil(2.0 * (center + 2.0 * radius * scale)) / 2.0
413
+ x = numpy.linspace(x_min, x_max, 500)
414
+
415
+ # Create y if not given
416
+ if y is None:
417
+ y = numpy.linspace(-1, 1, 400)
418
+
419
+ x_grid, y_grid = numpy.meshgrid(x, y)
420
+ z = x_grid + 1j * y_grid # shape (Ny, Nx)
421
+
422
+ m1 = self._m_mp_reflect(z, alt_branch=False)
423
+ m2 = self._m_mp_reflect(z, alt_branch=True)
424
+
425
+ if plot:
426
+ plot_stieltjes(x, y, m1, m2, support=self.support, latex=latex,
427
+ save=save)
428
+
429
+ return m1, m2
430
+
431
+ # ======
432
+ # sample
433
+ # ======
434
+
435
+ def sample(self, size, x_min=None, x_max=None, plot=False, latex=False,
436
+ save=False):
437
+ """
438
+ Sample from distribution.
439
+
440
+ Parameters
441
+ ----------
442
+
443
+ size : int
444
+ Size of sample.
445
+
446
+ x_min : float, default=None
447
+ Minimum of sample values. If `None`, the left edge of the support
448
+ is used.
449
+
450
+ x_max : float, default=None
451
+ Maximum of sample values. If `None`, the right edge of the support
452
+ is used.
453
+
454
+ plot : bool, default=False
455
+ If `True`, samples histogram is plotted.
456
+
457
+ latex : bool, default=False
458
+ If `True`, the plot is rendered using LaTeX. This option is
459
+ relevant only if ``plot=True``.
460
+
461
+ save : bool, default=False
462
+ If not `False`, the plot is saved. If a string is given, it is
463
+ assumed to the save filename (with the file extension). This option
464
+ is relevant only if ``plot=True``.
465
+
466
+ Returns
467
+ -------
468
+
469
+ s : numpy.ndarray
470
+ Samples.
471
+
472
+ Notes
473
+ -----
474
+
475
+ This method uses inverse transform sampling.
476
+
477
+ Examples
478
+ --------
479
+
480
+ .. code-block::python
481
+
482
+ >>> from freealg.distributions import Wachter
483
+ >>> wa = Wachter(2, 3)
484
+ >>> s = wa.sample(2000)
485
+
486
+ .. image:: ../_static/images/plots/wa_samples.png
487
+ :align: center
488
+ :class: custom-dark
489
+ """
490
+
491
+ if x_min is None:
492
+ x_min = self.lam_m
493
+
494
+ if x_max is None:
495
+ x_max = self.lam_p
496
+
497
+ # Grid and PDF
498
+ xs = numpy.linspace(x_min, x_max, size)
499
+ pdf = self.density(xs)
500
+
501
+ # CDF (using cumulative trapezoidal rule)
502
+ cdf = cumtrapz(pdf, xs, initial=0)
503
+ cdf /= cdf[-1] # normalize CDF to 1
504
+
505
+ # Inverse CDF interpolator
506
+ inv_cdf = interp1d(cdf, xs, bounds_error=False,
507
+ fill_value=(x_min, x_max))
508
+
509
+ # Sample and map
510
+ u = numpy.random.rand(size)
511
+ samples = inv_cdf(u)
512
+
513
+ if plot:
514
+ radius = 0.5 * (self.lam_p - self.lam_m)
515
+ center = 0.5 * (self.lam_p + self.lam_m)
516
+ scale = 1.25
517
+ x_min = numpy.floor(center - radius * scale)
518
+ x_max = numpy.ceil(center + radius * scale)
519
+ x = numpy.linspace(x_min, x_max, 500)
520
+ rho = self.density(x)
521
+ plot_samples(x, rho, x_min, x_max, samples, latex=latex, save=save)
522
+
523
+ return samples
524
+
525
+ # ======
526
+ # matrix
527
+ # ======
528
+
529
+ def matrix(self, size):
530
+ """
531
+ Generate matrix with the spectral density of the distribution.
532
+
533
+ Parameters
534
+ ----------
535
+
536
+ size : int
537
+ Size :math:`n` of the matrix.
538
+
539
+ Returns
540
+ -------
541
+
542
+ Sx : numpy.ndarray
543
+ A matrix of the size :math:`n \\times n`.
544
+
545
+ Sy : numpy.ndarray
546
+ A matrix of the size :math:`n \\times n`.
547
+
548
+ Examples
549
+ --------
550
+
551
+ .. code-block::python
552
+
553
+ >>> from freealg.distributions import Wachter
554
+ >>> wa = Wachter(2, 3)
555
+ >>> A = wa.matrix(2000)
556
+ """
557
+
558
+ n = size
559
+ m1 = int(self.a * n)
560
+ m2 = int(self.b * n)
561
+
562
+ X = numpy.random.randn(n, m1)
563
+ Y = numpy.random.randn(n, m2)
564
+
565
+ Sx = X @ X.T
566
+ Sy = Y @ Y.T
567
+
568
+ return Sx, Sy