freealg 0.1.11__py3-none-any.whl → 0.7.12__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.
Files changed (59) hide show
  1. freealg/__init__.py +8 -2
  2. freealg/__version__.py +1 -1
  3. freealg/_algebraic_form/__init__.py +12 -0
  4. freealg/_algebraic_form/_branch_points.py +288 -0
  5. freealg/_algebraic_form/_constraints.py +139 -0
  6. freealg/_algebraic_form/_continuation_algebraic.py +706 -0
  7. freealg/_algebraic_form/_decompress.py +641 -0
  8. freealg/_algebraic_form/_decompress2.py +204 -0
  9. freealg/_algebraic_form/_edge.py +330 -0
  10. freealg/_algebraic_form/_homotopy.py +323 -0
  11. freealg/_algebraic_form/_moments.py +448 -0
  12. freealg/_algebraic_form/_sheets_util.py +145 -0
  13. freealg/_algebraic_form/_support.py +309 -0
  14. freealg/_algebraic_form/algebraic_form.py +1232 -0
  15. freealg/_free_form/__init__.py +16 -0
  16. freealg/{_chebyshev.py → _free_form/_chebyshev.py} +75 -43
  17. freealg/_free_form/_decompress.py +993 -0
  18. freealg/_free_form/_density_util.py +243 -0
  19. freealg/_free_form/_jacobi.py +359 -0
  20. freealg/_free_form/_linalg.py +508 -0
  21. freealg/{_pade.py → _free_form/_pade.py} +42 -208
  22. freealg/{_plot_util.py → _free_form/_plot_util.py} +37 -22
  23. freealg/{_sample.py → _free_form/_sample.py} +58 -22
  24. freealg/_free_form/_series.py +454 -0
  25. freealg/_free_form/_support.py +214 -0
  26. freealg/_free_form/free_form.py +1362 -0
  27. freealg/_geometric_form/__init__.py +13 -0
  28. freealg/_geometric_form/_continuation_genus0.py +175 -0
  29. freealg/_geometric_form/_continuation_genus1.py +275 -0
  30. freealg/_geometric_form/_elliptic_functions.py +174 -0
  31. freealg/_geometric_form/_sphere_maps.py +63 -0
  32. freealg/_geometric_form/_torus_maps.py +118 -0
  33. freealg/_geometric_form/geometric_form.py +1094 -0
  34. freealg/_util.py +56 -110
  35. freealg/distributions/__init__.py +7 -1
  36. freealg/distributions/_chiral_block.py +494 -0
  37. freealg/distributions/_deformed_marchenko_pastur.py +726 -0
  38. freealg/distributions/_deformed_wigner.py +386 -0
  39. freealg/distributions/_kesten_mckay.py +29 -15
  40. freealg/distributions/_marchenko_pastur.py +224 -95
  41. freealg/distributions/_meixner.py +47 -37
  42. freealg/distributions/_wachter.py +29 -17
  43. freealg/distributions/_wigner.py +27 -14
  44. freealg/visualization/__init__.py +12 -0
  45. freealg/visualization/_glue_util.py +32 -0
  46. freealg/visualization/_rgb_hsv.py +125 -0
  47. freealg-0.7.12.dist-info/METADATA +172 -0
  48. freealg-0.7.12.dist-info/RECORD +53 -0
  49. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/WHEEL +1 -1
  50. freealg/_decompress.py +0 -180
  51. freealg/_jacobi.py +0 -218
  52. freealg/_support.py +0 -85
  53. freealg/freeform.py +0 -967
  54. freealg-0.1.11.dist-info/METADATA +0 -140
  55. freealg-0.1.11.dist-info/RECORD +0 -24
  56. /freealg/{_damp.py → _free_form/_damp.py} +0 -0
  57. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/AUTHORS.txt +0 -0
  58. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/licenses/LICENSE.txt +0 -0
  59. {freealg-0.1.11.dist-info → freealg-0.7.12.dist-info}/top_level.txt +0 -0
freealg/_util.py CHANGED
@@ -13,137 +13,83 @@
13
13
 
14
14
  import numpy
15
15
  import scipy
16
- from scipy.stats import beta
17
- from scipy.optimize import minimize
18
16
 
19
- __all__ = ['compute_eig', 'beta_kde', 'force_density']
17
+ __all__ = ['resolve_complex_dtype', 'compute_eig', 'subsample_matrix']
20
18
 
21
19
 
22
- # ===========
23
- # compute eig
24
- # ===========
20
+ # =====================
21
+ # resolve complex dtype
22
+ # =====================
25
23
 
26
- def compute_eig(A, lower=False):
24
+ def resolve_complex_dtype(dtype):
27
25
  """
28
- Compute eigenvalues of symmetric matrix.
26
+ Convert a user-supplied dtype name to a NumPy dtype object and fall back
27
+ safely if the requested precision is unavailable.
29
28
  """
30
29
 
31
- eig = scipy.linalg.eigvalsh(A, lower=lower, driver='ev')
30
+ # Normalise the string
31
+ dtype = str(dtype).lower()
32
32
 
33
- return eig
33
+ if not isinstance(numpy.dtype(dtype), numpy.dtype):
34
+ raise ValueError(f'{dtype} is not a recognized numpy dtype.')
35
+ elif not numpy.issubdtype(numpy.dtype(dtype), numpy.complexfloating):
36
+ raise ValueError(f'{dtype} is not a complex dtype.')
34
37
 
38
+ if dtype in {'complex128', '128'}:
39
+ cdtype = numpy.complex128
35
40
 
36
- # ========
37
- # beta kde
38
- # ========
41
+ elif dtype in ['complex256', '256', 'longcomplex', 'clongcomplex']:
39
42
 
40
- def beta_kde(eig, xs, lam_m, lam_p, h):
41
- """
42
- Beta-kernel KDE with automatic guards against NaNs.
43
-
44
- Parameters
45
- ----------
46
- eig : (n,) 1-D array of samples
47
- xs : evaluation grid (must lie within [lam_m, lam_p])
48
- lam_m, lam_p : float, support endpoints (lam_m < lam_p)
49
- h : bandwidth in rescaled units (0 < h < 1)
50
-
51
- Returns
52
- -------
53
- pdf : ndarray same length as xs
54
- """
43
+ complex256_found = False
44
+ for name in ['complex256', 'clongcomplex']:
45
+ if hasattr(numpy, name):
46
+ cdtype = getattr(numpy, name)
47
+ complex256_found = True
55
48
 
56
- span = lam_p - lam_m
57
- if span <= 0:
58
- raise ValueError("lam_p must be larger than lam_m")
49
+ if not complex256_found:
50
+ raise RuntimeWarning(
51
+ 'NumPy on this platform has no 256-bit complex type. ' +
52
+ 'Falling back to complex128.')
53
+ cdtype = numpy.complex128
59
54
 
60
- # map samples and grid to [0,1]
61
- u = (eig - lam_m) / span
62
- t = (xs - lam_m) / span
55
+ else:
56
+ raise ValueError('Unsupported dtype.')
63
57
 
64
- if u.min() < 0 or u.max() > 1:
65
- mask = (u > 0) & (u < 1)
66
- u = u[mask]
58
+ return cdtype
67
59
 
68
- pdf = numpy.zeros_like(xs, dtype=float)
69
- n = len(u)
70
60
 
71
- # tiny positive number to keep shape parameters >0
72
- eps = 1e-6
73
- for ui in u:
74
- a = max(ui / h + 1.0, eps)
75
- b = max((1.0 - ui) / h + 1.0, eps)
76
- pdf += beta.pdf(t, a, b)
61
+ # ===========
62
+ # compute eig
63
+ # ===========
64
+
65
+ def compute_eig(A, lower=False):
66
+ """
67
+ Compute eigenvalues of symmetric matrix.
68
+ """
77
69
 
78
- pdf /= n * span # renormalise
79
- pdf[(t < 0) | (t > 1)] = 0.0 # exact zeros outside
70
+ eig = scipy.linalg.eigvalsh(A, lower=lower, driver='ev')
80
71
 
81
- return pdf
72
+ return eig
82
73
 
83
74
 
84
- # =============
85
- # force density
86
- # =============
75
+ # ================
76
+ # subsample matrix
77
+ # ================
87
78
 
88
- def force_density(psi0, support, approx, grid, alpha=0.0, beta=0.0):
79
+ def subsample_matrix(matrix, submatrix_size, seed=None):
89
80
  """
90
- Starting from psi0 (raw projection), solve
91
- min 0.5 ||psi - psi0||^2
92
- s.t. F_pos psi >= 0 (positivity on grid)
93
- psi[0] = psi0[0] (mass)
94
- f(lam_m)·psi = 0 (zero at left edge)
95
- f(lam_p)·psi = 0 (zero at right edge)
81
+ Generate a random subsample of a larger matrix
96
82
  """
97
83
 
98
- lam_m, lam_p = support
99
-
100
- # Objective and gradient
101
- def fun(psi):
102
- return 0.5 * numpy.dot(psi-psi0, psi-psi0)
103
-
104
- def grad(psi):
105
- return psi - psi0
106
-
107
- # Constraints:
108
- constraints = []
109
-
110
- # Enforce positivity
111
- constraints.append({'type': 'ineq',
112
- 'fun': lambda psi: approx(grid, psi)})
113
-
114
- # Enforce unit mass
115
- constraints.append({
116
- 'type': 'eq',
117
- 'fun': lambda psi: numpy.trapz(approx(grid, psi), grid) - 1.0
118
- })
119
-
120
- # Enforce zero at left edge
121
- if beta <= 0.0 and beta > -0.5:
122
- constraints.append({
123
- 'type': 'eq',
124
- 'fun': lambda psi: approx(numpy.array([lam_m]), psi)[0]
125
- })
126
-
127
- # Enforce zero at right edge
128
- if alpha <= 0.0 and alpha > -0.5:
129
- constraints.append({
130
- 'type': 'eq',
131
- 'fun': lambda psi: approx(numpy.array([lam_p]), psi)[0]
132
- })
133
-
134
- # Solve a small quadratic programming
135
- res = minimize(fun, psi0, jac=grad,
136
- constraints=constraints,
137
- # method='trust-constr',
138
- method='SLSQP',
139
- options={'maxiter': 1000, 'ftol': 1e-9, 'eps': 1e-8})
140
-
141
- psi = res.x
142
-
143
- # Normalize first mode to unit mass
144
- x = numpy.linspace(lam_m, lam_p, 1000)
145
- rho = approx(x, psi)
146
- mass = numpy.trapezoid(rho, x)
147
- psi[0] = psi[0] / mass
148
-
149
- return psi
84
+ if matrix.shape[0] != matrix.shape[1]:
85
+ raise ValueError("Matrix must be square")
86
+
87
+ n = matrix.shape[0]
88
+ if submatrix_size > n:
89
+ raise ValueError("Submatrix size cannot exceed matrix size")
90
+
91
+ rng = numpy.random.default_rng(seed)
92
+ idx = rng.choice(n, size=submatrix_size, replace=False)
93
+ idx = numpy.sort(idx) # optional, preserves original ordering
94
+
95
+ return matrix[numpy.ix_(idx, idx)]
@@ -11,5 +11,11 @@ from ._wigner import Wigner
11
11
  from ._kesten_mckay import KestenMcKay
12
12
  from ._wachter import Wachter
13
13
  from ._meixner import Meixner
14
+ from ._chiral_block import ChiralBlock
15
+ from ._deformed_wigner import DeformedWigner
16
+ from ._deformed_marchenko_pastur import DeformedMarchenkoPastur
17
+ from ._compound_poisson import CompoundPoisson
14
18
 
15
- __all__ = ['MarchenkoPastur', 'Wigner', 'KestenMcKay', 'Wachter', 'Meixner']
19
+ __all__ = ['MarchenkoPastur', 'Wigner', 'KestenMcKay', 'Wachter', 'Meixner',
20
+ 'ChiralBlock', 'DeformedWigner', 'DeformedMarchenkoPastur',
21
+ 'CompoundPoisson']
@@ -0,0 +1,494 @@
1
+ # SPDX-FileCopyrightText: Copyright 2026, 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
+ # Import
12
+ # ======
13
+
14
+ import numpy
15
+ import collections
16
+ from .._geometric_form._elliptic_functions import ellipj
17
+ from .._geometric_form._continuation_genus1 import mobius_z
18
+
19
+ __all__ = ['ChiralBlock']
20
+
21
+
22
+ # ============
23
+ # Chiral Block
24
+ # ============
25
+
26
+ class ChiralBlock(object):
27
+ """
28
+ Twisted chiral block model.
29
+
30
+ Parameters
31
+ ----------
32
+
33
+ alpha : float
34
+ beta : float
35
+ """
36
+
37
+ # ====
38
+ # init
39
+ # ====
40
+
41
+ def __init__(self, alpha, beta, c):
42
+ """
43
+ Initialization.
44
+ """
45
+
46
+ self.alpha = alpha
47
+ self.beta = beta
48
+ self.c = c
49
+
50
+ # =======
51
+ # density
52
+ # =======
53
+
54
+ def density(self, x):
55
+ """
56
+ Absolutely continous density, and the atom.
57
+ """
58
+
59
+ # Parameters
60
+ alpha = self.alpha
61
+ beta = self.beta
62
+ c = self.c
63
+
64
+ t = (x - alpha) * (x - beta)
65
+
66
+ Delta_t = t * t - 2.0 * (c + 1.0) * t + (c - 1.0) * (c - 1.0)
67
+ s = numpy.sqrt(numpy.maximum(0.0, -Delta_t))
68
+
69
+ sgn = numpy.sign(x - alpha)
70
+ sgn = numpy.where(sgn == 0.0, 1.0, sgn)
71
+ sd = 1j * s * sgn
72
+
73
+ A = t + (c - 1.0)
74
+
75
+ xa = x - alpha
76
+ xa_safe = numpy.where(xa == 0.0, numpy.nan, xa)
77
+
78
+ u = (-A + sd) / (2.0 * c * xa_safe)
79
+ den = (t - c + 1.0) + sd
80
+ v = -2.0 * xa_safe / den
81
+
82
+ m = (c / (1.0 + c)) * u + (1.0 / (1.0 + c)) * v
83
+ rho = m.imag / numpy.pi
84
+
85
+ rho = numpy.where(Delta_t < 0.0, rho, 0.0)
86
+ rho = numpy.where(numpy.isfinite(rho), rho, 0.0)
87
+ rho = numpy.maximum(rho, 0.0)
88
+
89
+ # Atom location and weight
90
+ if numpy.abs(c - 1.0) < 1e-4:
91
+ atom_loc = None
92
+ atom_w = None
93
+ elif c > 1.0:
94
+ atom_loc = alpha
95
+ atom_w = (c - 1.0) / (c + 1.0)
96
+ elif c < 1.0:
97
+ atom_loc = beta
98
+ atom_w = (1.0 - c) / (c + 1.0)
99
+
100
+ return rho, atom_loc, atom_w
101
+
102
+ # ===========
103
+ # sqrt like t
104
+ # ===========
105
+
106
+ def _sqrt_like_t(self, delta, t):
107
+ """
108
+ """
109
+
110
+ s = numpy.sqrt(delta)
111
+ flip = numpy.real(t * numpy.conjugate(s)) < 0.0
112
+ s = numpy.where(flip, -s, s)
113
+ return s
114
+
115
+ # =========
116
+ # stieltjes
117
+ # =========
118
+
119
+ def stieltjes(self, z, alt_branch=False):
120
+ """
121
+ Physical Stieltjes transform
122
+ """
123
+
124
+ # Parameters
125
+ alpha = self.alpha
126
+ beta = self.beta
127
+ c = self.c
128
+
129
+ t = (z - alpha) * (z - beta)
130
+ delta = t * t - 2.0 * (c + 1.0) * t + (c - 1.0) * (c - 1.0)
131
+
132
+ s = self._sqrt_like_t(delta, t)
133
+
134
+ A = t + (c - 1.0)
135
+
136
+ za = z - alpha
137
+ za_safe = numpy.where(za == 0.0, numpy.nan, za)
138
+
139
+ u_p = (-A + s) / (2.0 * c * za_safe)
140
+ u_m = (-A - s) / (2.0 * c * za_safe)
141
+
142
+ den_p = (t - c + 1.0) + s
143
+ den_m = (t - c + 1.0) - s
144
+
145
+ v_p = -2.0 * za_safe / den_p
146
+ v_m = -2.0 * za_safe / den_m
147
+
148
+ m_p = (c / (1.0 + c)) * u_p + (1.0 / (1.0 + c)) * v_p
149
+ m_m = (c / (1.0 + c)) * u_m + (1.0 / (1.0 + c)) * v_m
150
+
151
+ mask_p = numpy.imag(z) >= 0.0
152
+ pick_p = \
153
+ numpy.where(mask_p, numpy.imag(m_p) >= 0.0, numpy.imag(m_p) <= 0.0)
154
+
155
+ m1 = numpy.where(pick_p, m_p, m_m)
156
+ m2 = numpy.where(pick_p, m_m, m_p)
157
+
158
+ if alt_branch:
159
+ return m2
160
+ return m1
161
+
162
+ # =======
163
+ # support
164
+ # =======
165
+
166
+ def support(self):
167
+ """
168
+ Support
169
+ """
170
+
171
+ # Parameters
172
+ alpha = self.alpha
173
+ beta = self.beta
174
+ c = self.c
175
+
176
+ s = numpy.sqrt(c)
177
+ t_min = (s - 1.0) * (s - 1.0)
178
+ t_max = (s + 1.0) * (s + 1.0)
179
+
180
+ d = (alpha - beta) * (alpha - beta)
181
+
182
+ r_min = numpy.sqrt(d + 4.0 * t_min)
183
+ r_max = numpy.sqrt(d + 4.0 * t_max)
184
+
185
+ a1 = 0.5 * (alpha + beta - r_max)
186
+ b1 = 0.5 * (alpha + beta - r_min)
187
+ a2 = 0.5 * (alpha + beta + r_min)
188
+ b2 = 0.5 * (alpha + beta + r_max)
189
+
190
+ return [(a1, b1), (a2, b2)]
191
+
192
+ # ==================
193
+ # stieltjes on torus
194
+ # ==================
195
+
196
+ def stieltjes_on_torus(self, u, lam, a1, b1, a2, b2):
197
+ """
198
+ Exact m on the torus (no fit), continuous, by:
199
+ 1) computing the two exact candidates mA(z(u)) and mB(z(u)),
200
+ 2) selecting a continuous branch on the torus via BFS continuation,
201
+ 3) applying an optimal "half-cycle swap" along the phi-direction
202
+ (choosing the cut location automatically) to ensure global
203
+ consistency without breaking periodicity (fixes the equator-circle
204
+ issue).
205
+
206
+ Usage:
207
+ mT_exact = eval_m_on_torus_exact(u, lam, a1, b1, a2, b2, alpha,
208
+ beta, c)
209
+ """
210
+
211
+ # ---------------------------
212
+ # core (drop seam duplicates)
213
+ # ---------------------------
214
+
215
+ uc = u[:-1, :-1]
216
+ nphi, ntheta = uc.shape
217
+
218
+ # ----------------------------
219
+ # map u -> z via X = lam sn^2
220
+ # ----------------------------
221
+
222
+ sn, cn, dn, _ = ellipj(uc, lam)
223
+ Xc = lam * (sn * sn)
224
+ zc = mobius_z(Xc, a1, b1, a2, b2)
225
+
226
+ # -------------------------------
227
+ # exact branch candidates at z(u)
228
+ # -------------------------------
229
+
230
+ mA = self.stieltjes(zc, alt_branch=False) # candidate A
231
+ mB = self.stieltjes(zc, alt_branch=True) # candidate B
232
+
233
+ finA = numpy.isfinite(mA)
234
+ finB = numpy.isfinite(mB)
235
+
236
+ # output core and chosen flags
237
+ mC = numpy.full_like(mA, numpy.nan, dtype=complex)
238
+
239
+ # 0->A, 1->B, -1 unset
240
+ chosen = numpy.full((nphi, ntheta), -1, dtype=numpy.int8)
241
+
242
+ # -----------------------------------
243
+ # seed: find a point with both finite
244
+ # -----------------------------------
245
+
246
+ if finA[0, 0] and finB[0, 0]:
247
+ i0, j0 = 0, 0
248
+ else:
249
+ idx = numpy.argwhere(finA & finB)
250
+ if idx.size == 0:
251
+ raise RuntimeError("No points where both branches are finite.")
252
+ i0, j0 = idx[0]
253
+
254
+ # deterministic seed choice (any deterministic rule is fine)
255
+ # prefer candidate whose Im(m) roughly matches sign of Im(z)
256
+ if numpy.imag(zc[i0, j0]) >= 0:
257
+ pickA = (numpy.imag(mA[i0, j0]) >= numpy.imag(mB[i0, j0]))
258
+ else:
259
+ pickA = (numpy.imag(mA[i0, j0]) <= numpy.imag(mB[i0, j0]))
260
+
261
+ chosen[i0, j0] = 0 if pickA else 1
262
+ mC[i0, j0] = mA[i0, j0] if pickA else mB[i0, j0]
263
+
264
+ # ----------------------------------------------
265
+ # BFS continuation on torus (periodic neighbors)
266
+ # ----------------------------------------------
267
+
268
+ q = collections.deque([(i0, j0)])
269
+ while q:
270
+ i, j = q.popleft()
271
+ ref = mC[i, j]
272
+ if not numpy.isfinite(ref):
273
+ continue
274
+
275
+ nbrs = [((i - 1) % nphi, j),
276
+ ((i + 1) % nphi, j),
277
+ (i, (j - 1) % ntheta),
278
+ (i, (j + 1) % ntheta)]
279
+
280
+ for ii, jj in nbrs:
281
+ if chosen[ii, jj] != -1:
282
+ continue
283
+
284
+ a_ok = finA[ii, jj]
285
+ b_ok = finB[ii, jj]
286
+
287
+ if not a_ok and not b_ok:
288
+ chosen[ii, jj] = 2
289
+ mC[ii, jj] = numpy.nan
290
+ continue
291
+
292
+ if a_ok and not b_ok:
293
+ chosen[ii, jj] = 0
294
+ mC[ii, jj] = mA[ii, jj]
295
+ q.append((ii, jj))
296
+ continue
297
+
298
+ if b_ok and not a_ok:
299
+ chosen[ii, jj] = 1
300
+ mC[ii, jj] = mB[ii, jj]
301
+ q.append((ii, jj))
302
+ continue
303
+
304
+ # both finite: choose closer to already-selected neighbor
305
+ # (continuation)
306
+ da = abs(mA[ii, jj] - ref)
307
+ db = abs(mB[ii, jj] - ref)
308
+ if da <= db:
309
+ chosen[ii, jj] = 0
310
+ mC[ii, jj] = mA[ii, jj]
311
+ else:
312
+ chosen[ii, jj] = 1
313
+ mC[ii, jj] = mB[ii, jj]
314
+ q.append((ii, jj))
315
+
316
+ # ----------------------------------------------------------------
317
+ # Step 3: choose the correct "half-cycle swap" cut automatically
318
+ #
319
+ # Build the "other-sheet" field mOther (swap at every point)
320
+ # and then choose a contiguous block of phi-rows of length L=nphi/2
321
+ # to swap, with cut location k chosen to minimize the two boundary
322
+ # jumps.
323
+ #
324
+ # This fixes the "entire equator circle wrong" issue.
325
+ # -----------------------------------------------------------------
326
+
327
+ if nphi % 2 != 0:
328
+ # If odd, we still do a near-half swap; but nphi is typically even.
329
+ L = nphi // 2
330
+ else:
331
+ L = nphi // 2
332
+
333
+ # swapped-everywhere alternative (only valid where chosen is 0/1)
334
+ mOther = numpy.full_like(mC, numpy.nan, dtype=complex)
335
+ ok0 = (chosen == 0)
336
+ ok1 = (chosen == 1)
337
+ mOther[ok0] = mB[ok0]
338
+ mOther[ok1] = mA[ok1]
339
+
340
+ # boundary cost between row i and i+1 (mod nphi) when row i uses mC (0)
341
+ # and row i+1 uses mOther (1) and when row i uses mOther (1) and row
342
+ # i+1 uses mC (0).
343
+ def boundary_cost_rowpair(Arow, Brow):
344
+ d = Arow - Brow
345
+ ok = numpy.isfinite(d)
346
+ return numpy.median(numpy.abs(d[ok])) if numpy.any(ok) \
347
+ else numpy.inf
348
+
349
+ # row i uses mC, row i+1 uses mOther
350
+ c01 = numpy.full(nphi, numpy.inf, dtype=float)
351
+
352
+ # row i uses mOther, row i+1 uses mC
353
+ c10 = numpy.full(nphi, numpy.inf, dtype=float)
354
+
355
+ for i in range(nphi):
356
+ ip = (i + 1) % nphi
357
+ c01[i] = boundary_cost_rowpair(mC[i, :], mOther[ip, :])
358
+ c10[i] = boundary_cost_rowpair(mOther[i, :], mC[ip, :])
359
+
360
+ # For a swap-block starting at k (rows k..k+L-1 swapped),
361
+ # the two cut boundaries are:
362
+ # b1 = k-1 : (unswapped -> swapped) uses c01[b1]
363
+ # b2 = k+L-1: (swapped -> unswapped) uses c10[b2]
364
+ best_k = 0
365
+ best_cost = numpy.inf
366
+ for k in range(nphi):
367
+ b1 = (k - 1) % nphi
368
+ b2 = (k + L - 1) % nphi
369
+ cost = c01[b1] + c10[b2]
370
+ if cost < best_cost:
371
+ best_cost = cost
372
+ best_k = k
373
+
374
+ # apply that optimal contiguous swap block
375
+ swap_rows = numpy.zeros(nphi, dtype=bool)
376
+ for t in range(L):
377
+ swap_rows[(best_k + t) % nphi] = True
378
+
379
+ mC2 = mC.copy()
380
+ mC2[swap_rows, :] = mOther[swap_rows, :]
381
+ mC = mC2
382
+
383
+ # -----------------------------
384
+ # rewrap seams to match u shape
385
+ # -----------------------------
386
+
387
+ mT = numpy.empty_like(u, dtype=complex)
388
+ mT[:-1, :-1] = mC
389
+ mT[-1, :-1] = mC[0, :]
390
+ mT[:-1, -1] = mC[:, 0]
391
+ mT[-1, -1] = mC[0, 0]
392
+
393
+ return mT
394
+
395
+ # ======
396
+ # matrix
397
+ # ======
398
+
399
+ def matrix(self, size, seed=None):
400
+ """
401
+ Generate matrix with the spectral density of the distribution.
402
+
403
+ Parameters
404
+ ----------
405
+
406
+ size : int
407
+ Total size :math:`N = n + m` of the returned matrix.
408
+
409
+ seed : int, default=None
410
+ Seed for random number generator.
411
+
412
+ Returns
413
+ -------
414
+
415
+ A : numpy.ndarray
416
+ Symmetric matrix of shape :math:`N \\times N`.
417
+
418
+ Notes
419
+ -----
420
+
421
+ Generate a :math:`(n+m) x (n+m)` matrix
422
+
423
+ .. math::
424
+
425
+ H =
426
+ \\begin{bmatrix}
427
+ \\alpha \\mathbf{I}_n & (1/\\sqrt{m})) \\mathbf{X} \\
428
+ (1/\\sqrt{m})) \\mathbf{X}^{\\intercal} & \\beta \\mathbf{I}_m
429
+ \\end{bmatrix}
430
+
431
+
432
+ where :math:`\\mathbf{X}` has i.i.d. :math:`N(0,1)` entries and
433
+ :math:`n/m` approximates :math:`c`.
434
+
435
+ Examples
436
+ --------
437
+
438
+ .. code-block::python
439
+
440
+ >>> from freealg.distributions import MarchenkoPastur
441
+ >>> mp = MarchenkoPastur(1/50)
442
+ >>> A = mp.matrix(2000)
443
+ """
444
+
445
+ N = int(size)
446
+ if N <= 1:
447
+ raise ValueError("size must be an integer >= 2.")
448
+
449
+ # Unpack parameters
450
+ alpha = float(self.alpha)
451
+ beta = float(self.beta)
452
+ c = float(self.c)
453
+
454
+ rng = numpy.random.default_rng(seed)
455
+
456
+ # Choose n,m so that n/m approx c and n+m = N.
457
+ # Solve n = c m and n + m = N -> m = N/(c+1), n = cN/(c+1).
458
+ m = int(round(N / (c + 1.0)))
459
+ m = max(1, min(N - 1, m))
460
+ n = N - m
461
+
462
+ # Optionally refine to get ratio closer to c (cheap local search).
463
+ # This keeps deterministic behavior.
464
+ best_n = n
465
+ best_m = m
466
+ best_err = abs((n / float(m)) - c)
467
+ for dm in (-2, -1, 0, 1, 2):
468
+ mm = m + dm
469
+ if mm <= 0 or mm >= N:
470
+ continue
471
+ nn = N - mm
472
+ err = abs((nn / float(mm)) - c)
473
+ if err < best_err:
474
+ best_err = err
475
+ best_n = nn
476
+ best_m = mm
477
+ n = best_n
478
+ m = best_m
479
+
480
+ # Draw X (n x m) with i.i.d. entries
481
+ X = rng.standard_normal((n, m))
482
+
483
+ # Assemble H
484
+ H = numpy.zeros((N, N), dtype=numpy.float64)
485
+
486
+ H[:n, :n] = alpha * numpy.eye(n, dtype=numpy.float64)
487
+ H[n:, n:] = beta * numpy.eye(m, dtype=numpy.float64)
488
+
489
+ s = 1.0 / numpy.sqrt(float(m))
490
+ B = s * X
491
+ H[:n, n:] = B
492
+ H[n:, :n] = B.T
493
+
494
+ return H