bs-python-utils 0.0.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,957 @@
1
+ """
2
+ Contains various `numpy` utility programs.
3
+ """
4
+
5
+ import sys
6
+ from math import cos, exp, floor, log, pi, sqrt
7
+ from typing import Any, Callable, Iterable, cast
8
+
9
+ import numpy as np
10
+ from numpy.polynomial import Polynomial
11
+
12
+ from bs_python_utils.bsutils import bs_error_abort, print_stars
13
+
14
+ # some useful types
15
+ TwoArrays = tuple[np.ndarray, np.ndarray]
16
+ ThreeArrays = tuple[np.ndarray, np.ndarray, np.ndarray]
17
+ FourArrays = tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
18
+ SixArrays = tuple[
19
+ np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray
20
+ ]
21
+
22
+
23
+ # Numpy parallel RNG
24
+ def generate_RNG_streams(
25
+ nsim: int, initial_seed: int = 13091962
26
+ ) -> list[np.random.Generator]:
27
+ """
28
+ return `nsim` RNGs
29
+
30
+ Args:
31
+ nsim: number of RNGs we want
32
+ initial_seed: any large integer
33
+
34
+ Returns:
35
+ `nsim` streams
36
+
37
+ Example:
38
+ streams = generate_RNG_streams(10, 575856896)
39
+ x = streams[i].normal(scale=s, size=(nmarkets, nproducts))
40
+ """
41
+ ss = np.random.SeedSequence(initial_seed)
42
+ # Spawn off child SeedSequences to pass to child processes.
43
+ child_seeds = ss.spawn(nsim)
44
+ streams = [np.random.default_rng(s) for s in child_seeds]
45
+ return streams
46
+
47
+
48
+ def ecdf(x: np.ndarray) -> np.ndarray:
49
+ """Evaluate the empirical cdf at each point in sample
50
+
51
+ Args:
52
+ x: 1-dim array `(nobs)`
53
+
54
+ Returns:
55
+ A 1-dim array `(nobs)` with the values of the empirical cdf at `x`, from 1/`nobs` to 1
56
+
57
+ """
58
+ if x.ndim != 1:
59
+ print_stars(f"ecdf: x should have 1 dimension, not {x.ndim}")
60
+ sys.exit()
61
+ nx = x.size
62
+ order_x = np.argsort(x)
63
+ ecdf_val = np.zeros(nx)
64
+ for i_order, n_order in enumerate(order_x):
65
+ ecdf_val[n_order] = (i_order + 1.0) / nx
66
+ return ecdf_val
67
+
68
+
69
+ def inv_ecdf(v: np.ndarray, q: np.ndarray | float) -> np.ndarray | float:
70
+ """Evaluate the empirical `q`-quantiles of the sample `v`
71
+ in a way that is consistent with `ecdf`.
72
+
73
+ Args:
74
+ v: 1-dim array `(nobs)` of the data points
75
+ q: 1-dim array `(nobs)` of quantiles or float
76
+
77
+ Returns:
78
+ A 1-dim array `(nobs)` with the values of the `q`-quantiles of `v`, or just the one quantile
79
+
80
+ """
81
+ if v.ndim != 1:
82
+ bs_error_abort(f"v should have 1 dimension, not {v.ndim}")
83
+ nv = v.size
84
+ sorted_v = np.zeros(nv + 2)
85
+ sorted_v[1 : (nv + 1)] = np.sort(v)
86
+ sorted_v[0] = 2.0 * sorted_v[1] - sorted_v[2] # added to extend for q < 1/nv
87
+ sorted_v[nv + 1] = sorted_v[nv] # added to extend for q = 1
88
+ if isinstance(q, float):
89
+ q = np.array([q])
90
+ q_floor = floor(nv * q)
91
+ val_q = sorted_v[q_floor] + (nv * q - q_floor) * (
92
+ sorted_v[q_floor + 1] - sorted_v[q_floor]
93
+ )
94
+ return cast(float, val_q)
95
+ elif isinstance(q, np.ndarray):
96
+ q_floor = np.floor(nv * q).astype(int)
97
+ vals_q = sorted_v[q_floor] + (nv * q - q_floor) * (
98
+ sorted_v[q_floor + 1] - sorted_v[q_floor]
99
+ )
100
+ return cast(np.ndarray, vals_q)
101
+
102
+
103
+ def nprepeat_col(v: np.ndarray, n: int) -> np.ndarray:
104
+ """
105
+ create a matrix with `n` columns equal to `v`
106
+
107
+ Args:
108
+ v: a 1-dim array of size `m`
109
+ n: the number of columns requested
110
+
111
+ Returns:
112
+ a 2-dim array of shape `(m, n)`
113
+ """
114
+ return np.repeat(v[:, np.newaxis], n, axis=1)
115
+
116
+
117
+ def nprepeat_row(v: np.ndarray, m: int) -> np.ndarray:
118
+ """
119
+ create a matrix with `m` rows equal to `v`
120
+
121
+ Args:
122
+ v: a 1-dim array of size `n`
123
+ m: the number of rows requested
124
+
125
+ Returns:
126
+ a 2-dim array of shape `(m, n)`
127
+ """
128
+ return np.repeat(v[np.newaxis, :], m, axis=0)
129
+
130
+
131
+ def npmaxabs(arr: np.ndarray) -> float:
132
+ """
133
+ maximum absolute value in an array
134
+
135
+ Args:
136
+ arr: any Numpy array
137
+
138
+ Returns:
139
+ the largest element in absolute value
140
+ """
141
+ return cast(float, np.max(np.abs(arr)))
142
+
143
+
144
+ def rice_stderr(
145
+ y: np.ndarray, x: np.ndarray, is_sorted: bool = False
146
+ ) -> np.ndarray | float:
147
+ """
148
+ computes the Rice local estimators of the standard error of y | x
149
+
150
+ Args:
151
+ y: vector of y-values
152
+ x: vector of x-values
153
+ is_sorted: set it to `True` if `x` is in increasing order
154
+
155
+ Returns:
156
+ an array of the same size with the stderr(y | x)
157
+ """
158
+ n = check_vector(x)
159
+ ny = check_vector(y)
160
+ if ny != n:
161
+ bs_error_abort("x and y should have the same size")
162
+
163
+ if not is_sorted:
164
+ # need to sort by increasing value of x
165
+ order_x = np.argsort(x)
166
+ ys = y[order_x]
167
+ else:
168
+ ys = y
169
+
170
+ variance_estimator = np.zeros(n)
171
+
172
+ # we average over neighbors
173
+ n_neighbors = int(sqrt(float(n)) / 2.0)
174
+ facd = 1.0 / (2.0 * n_neighbors)
175
+ n_neighbors2 = n_neighbors // 2
176
+
177
+ # for the first observations
178
+ yleft = ys[:n_neighbors2]
179
+ dy = yleft[1:] - yleft[:-1]
180
+ variance_estimator[:n_neighbors2] = np.sum(dy * dy) * facd
181
+
182
+ # for the middle of the sample
183
+ minus_nn2 = n - n_neighbors2
184
+ for ix in range(n_neighbors2, minus_nn2):
185
+ ix_neighbors = slice(ix - n_neighbors2, ix + n_neighbors2)
186
+ yx = ys[ix_neighbors]
187
+ dy = yx[1:] - yx[:-1]
188
+ variance_estimator[ix] = np.sum(dy * dy) * facd
189
+
190
+ # and for the last observations
191
+ yright = ys[minus_nn2:]
192
+ dy = yright[1:] - yright[:-1]
193
+ variance_estimator[minus_nn2:] = np.sum(dy * dy) * facd
194
+
195
+ stderr_estimator = np.sqrt(variance_estimator)
196
+
197
+ return stderr_estimator
198
+
199
+
200
+ def nplog(
201
+ arr: np.ndarray,
202
+ eps: float = 1e-30,
203
+ deriv: int = 0,
204
+ verbose: bool = False,
205
+ ) -> np.ndarray | TwoArrays | ThreeArrays:
206
+ """
207
+ `C^2` extension of `\\ln(a)` below `eps`, perhaps with derivatives
208
+
209
+ Args:
210
+ arr: any Numpy array
211
+ eps: lower bound
212
+ deriv: if 1, compute derivative, if 2, second derivative
213
+ verbose: prints debugging info
214
+
215
+ Returns:
216
+ `\\ln(a)` `C^2`-extended below `eps`, perhaps with derivatives
217
+ """
218
+ if deriv not in [0, 1, 2]:
219
+ bs_error_abort(f"deriv can only be 0, 1, or 2; not {deriv}")
220
+ if np.min(arr) > eps:
221
+ if deriv == 0:
222
+ return cast(np.ndarray, np.log(arr))
223
+ elif deriv == 1:
224
+ return cast(TwoArrays, (np.log(arr), 1.0 / arr))
225
+ # deriv == 2
226
+ return cast(ThreeArrays, (np.log(arr), 1.0 / arr, -1.0 / (arr * arr)))
227
+ else:
228
+ logarreps = np.log(np.maximum(arr, eps))
229
+ darr = 1.0 - arr / eps
230
+ logarr_smaller = log(eps) - darr * (1.0 + darr / 2.0)
231
+ if verbose:
232
+ n_small_args = np.sum(arr < eps)
233
+ if n_small_args > 0:
234
+ finals = "s" if n_small_args > 1 else ""
235
+ print(
236
+ f"nplog: {n_small_args} argument{finals} smaller than {eps}: mini ="
237
+ f" {np.min(arr)}"
238
+ )
239
+ logeps = np.where(arr > eps, logarreps, logarr_smaller)
240
+ if deriv == 0:
241
+ return logeps
242
+ arreps = np.maximum(arr, eps)
243
+ der_logarreps = 1.0 / arreps
244
+ der_logarr_smaller = (1.0 + darr) / eps
245
+ dlogeps = np.where(arr > eps, der_logarreps, der_logarr_smaller)
246
+ if deriv == 1:
247
+ return cast(TwoArrays, (logeps, dlogeps))
248
+ # deriv == 2
249
+ der2_logarreps = -1.0 / (arreps * arreps)
250
+ der2_logarr_smaller = np.full(arr.shape, -1.0 / (eps * eps))
251
+ d2logeps = np.where(arr > eps, der2_logarreps, der2_logarr_smaller)
252
+ return cast(ThreeArrays, (logeps, dlogeps, d2logeps))
253
+
254
+
255
+ def npexp(
256
+ arr: np.ndarray,
257
+ bigx: float = 50.0,
258
+ lowx: float = -50.0,
259
+ deriv: int = 0,
260
+ verbose: bool = False,
261
+ ) -> np.ndarray | TwoArrays | ThreeArrays:
262
+ """
263
+ `C^2` extension of `\\exp(a)` above `bigx` and below `lowx`,
264
+ perhaps with derivatives
265
+
266
+ Args:
267
+ arr: any Numpy array
268
+ bigx: upper bound
269
+ lowx: lower bound
270
+ deriv: if 1, compute derivative, if 2, second derivative
271
+ verbose: prints debugging info
272
+
273
+
274
+ Returns:
275
+ `\\exp(a)` `C^2`-extended above `bigx` and below `lowx`,
276
+ perhaps with derivatives
277
+ """
278
+ if deriv not in [0, 1, 2]:
279
+ bs_error_abort(f"deriv can only be 0, 1, or 2; not {deriv}")
280
+ min_arr, max_arr = np.min(arr), np.max(arr)
281
+ if max_arr <= bigx and min_arr >= lowx:
282
+ exparr = np.exp(arr)
283
+ if deriv == 0:
284
+ return cast(np.ndarray, exparr)
285
+ elif deriv == 1:
286
+ return cast(TwoArrays, (exparr, exparr))
287
+ # deriv == 2
288
+ return cast(ThreeArrays, (exparr, exparr, exparr))
289
+ else: # some large and/or small arguments
290
+ exparr = np.exp(np.maximum(np.minimum(arr, bigx), lowx))
291
+ print(f"{exparr=}")
292
+ ebigx = exp(bigx)
293
+ elowx = exp(lowx)
294
+ darrb = arr - bigx
295
+ darrl = lowx - arr
296
+ exparr_larger = ebigx * (1.0 + darrb * (1.0 + 0.5 * darrb))
297
+ exparr_smaller = elowx * (1.0 - darrl * (1.0 - 0.5 * darrl))
298
+ if verbose:
299
+ n_large_args = np.sum(arr > bigx)
300
+ finals = "s" if n_large_args > 1 else ""
301
+ print(
302
+ f"npexp: {n_large_args} argument{finals} larger than {bigx}:\n"
303
+ f"maxi = {np.max(arr)}"
304
+ )
305
+ n_small_args = np.sum(arr < lowx)
306
+ finals = "s" if n_small_args > 1 else ""
307
+ print(
308
+ f"npexp: {n_small_args} argument{finals} smaller than {lowx}:\n"
309
+ f"mini = {np.min(arr)}"
310
+ )
311
+ expval = exparr
312
+ print(expval)
313
+ expval = np.where(arr > bigx, exparr_larger, expval)
314
+ expval = np.where(arr < lowx, exparr_smaller, expval)
315
+ if deriv == 0:
316
+ return cast(np.ndarray, expval)
317
+ dexpval = exparr
318
+ dexparr_larger = ebigx * (1.0 + darrb)
319
+ dexparr_smaller = elowx * (1.0 - darrl)
320
+ dexpval = np.where(arr > bigx, dexparr_larger, dexpval)
321
+ dexpval = np.where(arr < lowx, dexparr_smaller, dexpval)
322
+ if deriv == 1:
323
+ return cast(TwoArrays, (expval, dexpval))
324
+ # deriv == 2
325
+ d2expval = exparr
326
+ return cast(ThreeArrays, (expval, dexpval, d2expval))
327
+
328
+
329
+ def _nppow_arrays(
330
+ a: np.ndarray, b: np.ndarray, deriv: int
331
+ ) -> np.ndarray | ThreeArrays | SixArrays:
332
+ """implements nppow when a and b are conformal arrays"""
333
+ avec = a.ravel()
334
+ bvec = b.ravel()
335
+ a_pow_b = avec**bvec
336
+ a_pow_br = a_pow_b.reshape(a.shape)
337
+ if deriv == 0:
338
+ return cast(np.ndarray, a_pow_br)
339
+ der_wrt_a = a_pow_b * bvec / avec
340
+ log_avec = nplog(avec)
341
+ der_wrt_b = a_pow_b * log_avec
342
+ derivs1 = (der_wrt_a.reshape(a.shape), der_wrt_b.reshape(a.shape))
343
+ if deriv == 1:
344
+ return cast(ThreeArrays, (a_pow_br, *derivs1))
345
+ # deriv == 2
346
+ a_pow_b1 = a_pow_b / avec
347
+ b1 = bvec - 1.0
348
+ der2_wrt_aa = bvec * b1 * a_pow_b1 / avec
349
+ der2_wrt_ab = a_pow_b1 * (1.0 + bvec * log_avec)
350
+ der2_wrt_bb = a_pow_b * log_avec * log_avec
351
+ derivs2 = (
352
+ der2_wrt_aa.reshape(a.shape),
353
+ der2_wrt_ab.reshape(a.shape),
354
+ der2_wrt_bb.reshape(a.shape),
355
+ )
356
+ return cast(SixArrays, (a_pow_br, *derivs1, *derivs2))
357
+
358
+
359
+ def nppow(
360
+ a: np.ndarray, b: int | float | np.ndarray, deriv: int = 0
361
+ ) -> np.ndarray | ThreeArrays | SixArrays:
362
+ """
363
+ evaluates a**b element-by-element, perhaps with derivatives
364
+
365
+ Args:
366
+ a: an array
367
+ b: if an array, should have the same shape as `a`
368
+ deriv: if 1, compute derivative, if 2, second derivative
369
+
370
+ Returns:
371
+ an array of the same shape as `a`
372
+ """
373
+ if isinstance(b, float):
374
+ mina = np.min(a)
375
+ if mina < 0.0:
376
+ bs_error_abort("All elements of a must be positive!")
377
+
378
+ if isinstance(b, (int, float)):
379
+ a_pow_b = a**b
380
+ if deriv == 0:
381
+ return a_pow_b
382
+ log_a = np.log(a)
383
+ derivs1 = (b * a_pow_b / a, a_pow_b * log_a)
384
+ if deriv == 1:
385
+ return cast(ThreeArrays, (a_pow_b, *derivs1))
386
+ b1 = b - 1.0
387
+ a_pow_b1 = a_pow_b / a
388
+ # deriv == 2
389
+ derivs2 = (
390
+ b * b1 * a_pow_b1 / a,
391
+ a_pow_b1 * (1.0 + b * log_a),
392
+ a_pow_b * log_a * log_a,
393
+ )
394
+ return cast(SixArrays, (a_pow_b, *derivs1, *derivs2))
395
+ else:
396
+ if a.shape != b.shape:
397
+ bs_error_abort("b is not a number or an array of the same shape as a!")
398
+ return _nppow_arrays(a, b, deriv)
399
+
400
+
401
+ def nppad_beg_zeros(v: np.ndarray, n: int) -> np.ndarray:
402
+ """
403
+ pad the beginning of a 1-dim array with zeros to increase its size to `n`, if needed
404
+
405
+ Args:
406
+ v: 1-dim array of size `(nv)`
407
+ n: size requested
408
+
409
+ Returns:
410
+ padded array if `nv` < `n`, otherwise `v`
411
+ """
412
+ nv = check_vector(v)
413
+ if nv < n:
414
+ return np.pad(v, (n - nv, 0))
415
+ else:
416
+ return v
417
+
418
+
419
+ def nppad_end_zeros(v: np.ndarray, n: int) -> np.ndarray:
420
+ """
421
+ pad the end of a 1-dim array with zeros to increase its size to `n`, if needed
422
+
423
+ Args:
424
+ v: 1-dim array of size `(nv)`
425
+ n: size requested
426
+
427
+ Returns:
428
+ padded array if `nv` < `n`, else `v`
429
+ """
430
+ nv = check_vector(v)
431
+ if nv < n:
432
+ return np.pad(v, (0, n - nv))
433
+ else:
434
+ return v
435
+
436
+
437
+ def nppad2_end_zeros(mat: np.ndarray, m: int, n: int) -> np.ndarray:
438
+ """
439
+ pad the ends of a 2-dim array with zeros to increase its size to `(m,n)`, if needed
440
+
441
+ Args:
442
+ mat: 2-dim array
443
+ m: number of rows requested
444
+ n: number of columns requested
445
+
446
+ Returns:
447
+ padded array, where needed
448
+ """
449
+ nrows, ncols = check_matrix(mat)
450
+ max_rows = max(m, nrows)
451
+ max_cols = max(n, ncols)
452
+ if nrows < max_rows and ncols < max_cols: # pad both dimensions
453
+ pmat = np.zeros((m, n))
454
+ pmat[:nrows, :ncols] = mat
455
+ return pmat
456
+ elif nrows < max_rows: # pad rows
457
+ pmat = np.zeros((m, ncols))
458
+ pmat[:nrows, :] = mat
459
+ return pmat
460
+ elif ncols < max_cols: # pad columns
461
+ pmat = np.zeros((nrows, n))
462
+ pmat[:, :ncols] = mat
463
+ return pmat
464
+ else: # no need for padding
465
+ return mat
466
+
467
+
468
+ def bsgrid(v: np.ndarray, w: np.ndarray) -> np.ndarray:
469
+ """
470
+ make a two-dimensional matrix of all pairs of elements of the vectors `v` and `w`
471
+
472
+ Args:
473
+ v: basis vector, size m
474
+ w: basis vector, size n
475
+
476
+ Returns:
477
+ an array of shape `(m n, 2)`
478
+ """
479
+ m = check_vector(v)
480
+ n = check_vector(w)
481
+ m, n = v.size, w.size
482
+ v1 = np.repeat(v, n)
483
+ v2 = np.tile(w, m)
484
+ return np.column_stack((v1, v2))
485
+
486
+ """
487
+ This is a Python function that tests whether a given input is a vector and returns its size if
488
+ successful.
489
+
490
+ :param v: `v` is a numpy array that is expected to be a vector
491
+ :type v: np.ndarray
492
+ :param fun_name: `fun_name` is an optional parameter that represents the name of the calling
493
+ function. If provided, it will be used in error messages to indicate which function caused the
494
+ error. If not provided, the error message will not include the function name
495
+ :type fun_name: Optional[str]
496
+ """
497
+
498
+
499
+ def check_vector(v: Any, fun_name: str = None) -> int:
500
+ """
501
+ test that `v` is a vector; aborts otherwise
502
+
503
+ Args:
504
+ v: a vector, we hope
505
+ fun_name: name of the calling function
506
+
507
+ Returns:
508
+ the size if successful
509
+ """
510
+ fun_str = ["" if fun_name is None else fun_name + ":"]
511
+ if not isinstance(v, np.ndarray):
512
+ bs_error_abort(f"{fun_str} v should be a Numpy array")
513
+ v = cast(np.ndarray, v)
514
+ ndims_v = v.ndim
515
+ if ndims_v != 1:
516
+ bs_error_abort(f"{fun_str} v should have one dimension, not {ndims_v}")
517
+ return cast(int, v.size)
518
+
519
+
520
+ def check_matrix(x: Any, fun_name: str = None) -> tuple[int, int]:
521
+ """
522
+ test that `x` is a matrix; aborts otherwise
523
+
524
+ Args:
525
+ x: a matrix, we hope
526
+ fun_name: name of the calling function
527
+
528
+ Returns:
529
+ the shape if successful
530
+ """
531
+ fun_str = ["" if fun_name is None else fun_name + ":"]
532
+ if not isinstance(x, np.ndarray):
533
+ bs_error_abort(f"{fun_str} Xx should be a Numpy array")
534
+ x = cast(np.ndarray, x)
535
+ ndims_x = x.ndim
536
+ if ndims_x != 2:
537
+ bs_error_abort(f"{fun_str} x should have two dimensions, not {ndims_x}")
538
+ return cast(tuple[int, int], x.shape)
539
+
540
+
541
+ def check_vector_or_matrix(x: Any, fun_name: str = None) -> int:
542
+ """
543
+ test that `x` is a vector or a matrix; aborts otherwise
544
+
545
+ Args:
546
+ x: a vector or matrix, we hope
547
+ fun_name: name of the calling function
548
+
549
+ Returns:
550
+ the number of dimensions of `x` (1 or 2)
551
+ """
552
+ fun_str = ["" if fun_name is None else fun_name + ":"]
553
+ if not isinstance(x, np.ndarray):
554
+ bs_error_abort(f"{fun_str} X should be a Numpy array")
555
+ x = cast(np.ndarray, x)
556
+ ndims_x = x.ndim
557
+ if ndims_x != 1 and ndims_x != 2:
558
+ bs_error_abort(f"{fun_str} x should have at most two dimensions, not {ndims_x}")
559
+ return cast(int, ndims_x)
560
+
561
+
562
+ def bs_sqrt_pdmatrix(m: np.ndarray) -> np.ndarray:
563
+ """
564
+ square root of a positive definite matrix
565
+
566
+ Args:
567
+ m: a positive definite matrix
568
+
569
+ Returns:
570
+ the square root of the matrix
571
+ """
572
+ _ = check_square(m, "bs_sqrt_pdmatrix")
573
+ eigval, eigvec = np.linalg.eigh(m)
574
+ eigval = np.maximum(eigval, 0.0)
575
+ eigval_sqrt = np.sqrt(eigval)
576
+ eigval_sqrt_diag = np.diag(eigval_sqrt)
577
+ res = eigvec @ eigval_sqrt_diag @ eigvec.T
578
+ return cast(np.ndarray, res)
579
+
580
+
581
+ def check_square(A: Any, fun_name: str | None) -> int:
582
+ """
583
+ test that an object used in `fun_name` is a square matrix
584
+
585
+ Args:
586
+ A: square matrix, we hope
587
+ fun_name: the name of the calling function
588
+
589
+ Returns:
590
+ the number of rows and columns of `A`
591
+ """
592
+ fun_str = ["" if fun_name is None else fun_name + ":"]
593
+ if not isinstance(A, np.ndarray):
594
+ bs_error_abort(f"{fun_str} A should be a Numpy array")
595
+ A = cast(np.ndarray, A)
596
+ if A.ndim == 2:
597
+ n, nv = A.shape
598
+ if nv != n:
599
+ bs_error_abort(f"{fun_str} The matrix A should be square, not {A.shape}")
600
+ else:
601
+ bs_error_abort(f"{fun_name} A should have two dimensions, not {A.ndim}")
602
+ return cast(int, n)
603
+
604
+
605
+ def check_tensor(x: Any, n_dims: int, fun_name: str | None) -> tuple[int, ...]:
606
+ """
607
+ test that `x` is an `n_dims` dimensional array; aborts otherwise
608
+
609
+ Args:
610
+ x: an `n_dims` dimensional array, we hope
611
+ fun_name: name of the calling function
612
+
613
+ Returns:
614
+ the shape if successful
615
+ """
616
+ fun_str = ["" if fun_name is None else fun_name + ":"]
617
+ if not isinstance(x, np.ndarray):
618
+ bs_error_abort(f"{fun_str} x should be a Numpy array")
619
+ x = cast(np.ndarray, x)
620
+ ndims_x = x.ndim
621
+ if ndims_x != n_dims:
622
+ bs_error_abort(f"{fun_str} x should have {n_dims} dimensions, not {ndims_x}")
623
+ return (0,) # for mypy
624
+ return cast(tuple[int, ...], x.shape)
625
+
626
+
627
+ def make_lexico_grid(arr: np.ndarray) -> np.ndarray:
628
+ """
629
+ make a lexicographic grid
630
+
631
+ Args:
632
+ arr: `nr`-vector or `(nr,nc)` matrix; `nc` must be 1, 2 or 3
633
+
634
+ Returns:
635
+ `arr` if it is a vector; otherwise a matrix `({nr}^{nc}, {nc})`
636
+ for `nc=2` it is like `bsgrid`
637
+ """
638
+ ndims_arr = check_vector_or_matrix(arr, "make_lexico_grid`")
639
+ if ndims_arr == 1:
640
+ return arr
641
+ else:
642
+ nr, nc = arr.shape
643
+ if nc == 2:
644
+ n0 = np.repeat(arr[:, 0], nr)
645
+ n1 = np.tile(arr[:, 1], nr)
646
+ return np.column_stack((n0, n1))
647
+ elif nc == 3:
648
+ nr2 = nr * nr
649
+ n0 = np.repeat(arr[:, 0], nr2)
650
+ n1 = np.repeat(np.tile(arr[:, 1], nr), nr)
651
+ n2 = np.tile(arr[:, 2], nr2)
652
+ return np.column_stack((n0, n1, n2))
653
+ else:
654
+ bs_error_abort(
655
+ f"at this stage, the number of columns must be 3 or less, not {nc}..."
656
+ )
657
+ return arr # for mypy
658
+
659
+
660
+ class BivariatePolynomial:
661
+ """
662
+ a class for bivariate polynomials as a list of `Polynomial` objects
663
+
664
+ minimal interface:
665
+
666
+ * construct from matrix of coefficients
667
+ * add, subtract, multiply (with constant and with :class:`BivariatePolynomial`)
668
+ * evaluate p(x, y) when x, y are at most vectors (and have the same shape if both vectors)
669
+ """
670
+
671
+ def __init__(self, coeffs: np.ndarray):
672
+ """
673
+ constructor for :class:`BivariatePolynomial`
674
+
675
+ coeffs: a two-dimensional array `(deg1+1, deg2+2)`
676
+ """
677
+ self.deg1, self.deg2 = coeffs.shape[0] - 1, coeffs.shape[1] - 1
678
+ self.coef = coeffs
679
+ self.listpol2 = []
680
+ for k in range(self.deg1 + 1):
681
+ self.listpol2.append(Polynomial(coeffs[k, :]))
682
+
683
+ def __add__(self, bivpol):
684
+ if isinstance(bivpol, (int, float)):
685
+ coeffs = self.coef.copy()
686
+ coeffs[0, 0] += bivpol
687
+ return BivariatePolynomial(coeffs)
688
+ degbp1, degbp2 = bivpol.deg1, bivpol.deg2
689
+ max_deg1 = max(degbp1, self.deg1)
690
+ max_deg2 = max(degbp2, self.deg2)
691
+ coeffs_new = nppad2_end_zeros(self.coef, max_deg1 + 1, max_deg2 + 1)
692
+ coeffsbp_new = nppad2_end_zeros(bivpol.coef, max_deg1 + 1, max_deg2 + 1)
693
+ return BivariatePolynomial(coeffs_new + coeffsbp_new)
694
+
695
+ def __repr__(self):
696
+ return f"BivariatePolynomial({self.deg1!r}, {self.deg2!r})"
697
+
698
+ def __iadd__(self, bivpol):
699
+ return self.__add__(bivpol)
700
+
701
+ def __radd__(self, bivpol):
702
+ return self.__add__(bivpol)
703
+
704
+ def __sub__(self, bivpol):
705
+ if isinstance(bivpol, (int, float)):
706
+ coeffs = self.coef.copy()
707
+ coeffs[0, 0] -= bivpol
708
+ return BivariatePolynomial(coeffs)
709
+ degbp1, degbp2 = bivpol.deg1, bivpol.deg2
710
+ max_deg1 = max(degbp1, self.deg1)
711
+ max_deg2 = max(degbp2, self.deg2)
712
+ coeffs_new = nppad2_end_zeros(self.coef, max_deg1 + 1, max_deg2 + 1)
713
+ coeffsbp_new = nppad2_end_zeros(bivpol.coef, max_deg1 + 1, max_deg2 + 1)
714
+ return BivariatePolynomial(coeffs_new - coeffsbp_new)
715
+
716
+ def __mul__(self, bivpol):
717
+ if isinstance(bivpol, (int, float)):
718
+ return BivariatePolynomial(bivpol * self.coef)
719
+ deg1, degbp1 = self.deg1, bivpol.deg1
720
+ deg2, degbp2 = self.deg2, bivpol.deg2
721
+ degmul1 = deg1 + degbp1
722
+ degmul2 = deg2 + degbp2
723
+ lp2, blp2 = self.listpol2, bivpol.listpol2
724
+
725
+ coeffs_mul = np.zeros((degmul1 + 1, degmul2 + 1))
726
+ for m in range(degmul1 + 1):
727
+ minm = max(0, m - degbp1)
728
+ maxm = min(m, self.deg1)
729
+ pm = Polynomial(0)
730
+ for i in range(minm, maxm + 1):
731
+ pm += lp2[i] * blp2[m - i]
732
+ coeffs_mul[m, :] += pm.coef
733
+
734
+ bp_mul = BivariatePolynomial(coeffs_mul)
735
+ return bp_mul
736
+
737
+ def __rmul__(self, bivpol):
738
+ return self.__mul__(bivpol)
739
+
740
+ def __call__(self, x1, x2):
741
+ x1fac = 1.0
742
+ val = 0.0
743
+ for p in self.listpol2:
744
+ val += p(x2) * x1fac
745
+ x1fac *= x1
746
+ return val
747
+
748
+
749
+ def outer_bivar(pol1: Polynomial, pol2: Polynomial) -> BivariatePolynomial:
750
+ """
751
+ make a `BivariatePolynomial` from the product of two `Polynomial` objects
752
+
753
+ Args:
754
+ pol1: Polynomial in the first variable
755
+ pol2: Polynomial in the second variable
756
+
757
+ Returns:
758
+ a `BivariatePolynomial` = `pol1 * pol2`
759
+ """
760
+ p1 = pol1.coef
761
+ p2 = pol2.coef
762
+ prod_coef = np.outer(p1, p2)
763
+ return BivariatePolynomial(prod_coef)
764
+
765
+
766
+ def npxlogx(
767
+ arr: np.ndarray,
768
+ eps: float = 1e-30,
769
+ deriv: int = 0,
770
+ verbose: bool = False,
771
+ ) -> np.ndarray | TwoArrays | ThreeArrays:
772
+ """
773
+ C^2` extension of `a\\ln(a)` below `eps`, perhaps with derivatives
774
+
775
+ Args:
776
+ arr: a Numpy array
777
+ eps: lower bound
778
+ deriv: if 1, compute derivative, if 2, second derivative
779
+ verbose: prints debugging info
780
+
781
+ Returns:
782
+ `a\\ln(a)` `C^2`-extended below `eps`, perhaps with derivatives
783
+ """
784
+ if deriv not in [0, 1, 2]:
785
+ bs_error_abort(f"deriv must be 0, 1, or 2; not {deriv}")
786
+ if np.min(arr) > eps:
787
+ return cast(np.ndarray, arr * np.log(arr))
788
+ else:
789
+ logeps = log(eps)
790
+ logarreps = np.log(np.maximum(arr, eps))
791
+ xlogarreps = arr * logarreps
792
+ xlogarr_smaller = arr * (arr / eps + logeps - 1.0)
793
+ if verbose:
794
+ n_small_args = np.sum(arr < eps)
795
+ if n_small_args > 0:
796
+ finals = "s" if n_small_args > 1 else ""
797
+ print(
798
+ f"npxlogx: {n_small_args} argument{finals} smaller than {eps}: mini"
799
+ f" = {np.min(arr)}"
800
+ )
801
+ xlogval = np.where(arr > eps, xlogarreps, xlogarr_smaller)
802
+ if deriv == 0:
803
+ return xlogval
804
+ dxlogarreps = 1.0 + logarreps
805
+ dxlogarr_smaller = logeps + arr / eps
806
+ dxlogval = np.where(arr > eps, dxlogarreps, dxlogarr_smaller)
807
+ if deriv == 1:
808
+ return cast(TwoArrays, (xlogval, dxlogval))
809
+ # deriv == 2
810
+ d2xlogval = 1.0 / np.maximum(arr, eps)
811
+ return cast(ThreeArrays, (xlogval, dxlogval, d2xlogval))
812
+
813
+
814
+ def gauher(n: int) -> TwoArrays:
815
+ """
816
+ nodes and weights for Gauss-Hermite integration
817
+
818
+ Args:
819
+ n: number of nodes
820
+
821
+ Returns:
822
+ array of `n` nodes, array of `n` weights
823
+ """
824
+ EPS = 1.0e-14
825
+ PIM4 = 0.7511255444649425
826
+ MAXIT = 10
827
+
828
+ x = np.zeros(n)
829
+ w = np.zeros(n)
830
+
831
+ m = (n + 1) // 2
832
+
833
+ for i in range(m):
834
+ if i == 0:
835
+ n2 = 2.0 * n + 1.0
836
+ z = sqrt(n2) - 1.85575 * (n2**-0.16667)
837
+ elif i == 1:
838
+ z -= 1.14 * (n**0.426) / z
839
+ elif i == 2:
840
+ z = 1.86 * z - 0.86 * x[0]
841
+ elif i == 3:
842
+ z = 1.91 * z - 0.91 * x[1]
843
+ else:
844
+ z = 2.0 * z - x[i - 2]
845
+ for _n_iter in range(MAXIT):
846
+ p1 = PIM4
847
+ p2 = 0.0
848
+ for j in range(n):
849
+ p3 = p2
850
+ p2 = p1
851
+ p1 = z * sqrt(2.0 / (j + 1)) * p2 - sqrt(j / (j + 1)) * p3
852
+ pp = sqrt(2 * n) * p2
853
+ z1 = z
854
+ z = z1 - p1 / pp
855
+ if abs(z - z1) <= EPS:
856
+ break
857
+ if _n_iter >= MAXIT:
858
+ bs_error_abort(f"too many iterations: {_n_iter}")
859
+ x[i] = z
860
+ x[n - 1 - i] = -z
861
+ w[i] = 2.0 / (pp * pp)
862
+ w[n - 1 - i] = w[i]
863
+
864
+ # need to reverse order for x (w is symmetric)
865
+ return cast(TwoArrays, (x[::-1], w))
866
+
867
+
868
+ def gauleg(n: int) -> TwoArrays:
869
+ """
870
+ nodes and weights for Gauss-Legendre integration `\\int_{-1}^1 f(x)dx`
871
+
872
+ Args:
873
+ n: number of nodes
874
+
875
+ Returns:
876
+ array of `n` nodes, array of `n` weights
877
+ """
878
+ x = np.zeros(n)
879
+ w = np.zeros(n)
880
+ EPS = 3e-11
881
+ m = (n + 1) // 2
882
+ for i in range(1, m + 1):
883
+ z = cos(pi * (i - 0.25) / (n + 0.5))
884
+ z1 = np.inf
885
+ while abs(z - z1) > EPS:
886
+ p1 = 1.0
887
+ p2 = 0.0
888
+ for j in range(1, n + 1):
889
+ p3 = p2
890
+ p2 = p1
891
+ p1 = ((2.0 * j - 1.0) * z * p2 - (j - 1.0) * p3) / j
892
+ pp = n * (z * p1 - p2) / (z * z - 1.0)
893
+ z1 = z
894
+ z = z1 - p1 / pp
895
+ x[i - 1] = -z
896
+ x[n - i] = z
897
+ w[i - 1] = 2.0 / ((1.0 - z * z) * pp * pp)
898
+ w[n - i] = w[i - 1]
899
+
900
+ return cast(TwoArrays, (x, w))
901
+
902
+
903
+ def gaussian_expectation(
904
+ f: Callable,
905
+ x: np.ndarray | None,
906
+ w: np.ndarray | None,
907
+ n: int = 16,
908
+ vectorized: bool = False,
909
+ pars: Iterable = None,
910
+ ) -> np.ndarray | float:
911
+ """
912
+ computes the expectation of a function of an `N(0,1)` random variable
913
+ using Gauss-Hermite with n nodes
914
+ the nodes and weights can be provided, if available
915
+
916
+ Args:
917
+ f: a scalar or array function of a scalar or array variable and possibly other parameters
918
+ vectorized: if True, the function accepts an array as argument
919
+ pars: parameters for `f`, if any
920
+ n: number of nodes
921
+ x: locations of the nodes
922
+ w: their weights
923
+
924
+ Returns:
925
+ the expectation of `f(N(0,1))`
926
+ """
927
+ if x is None:
928
+ nodes, weights = gauher(n)
929
+ nodes *= sqrt(2.0)
930
+ weights /= sqrt(pi)
931
+ n_nodes = n
932
+ elif w is None:
933
+ bs_error_abort("x is None but w is not")
934
+ elif w.size != x.size:
935
+ bs_error_abort("x has {x.size} elements and w has {w.size}")
936
+ else:
937
+ nodes = x * sqrt(2.0)
938
+ weights = w / sqrt(pi)
939
+ n_nodes = nodes.size
940
+ if pars is None:
941
+ if vectorized:
942
+ integral_vec = f(nodes) @ weights
943
+ else:
944
+ # to ensure integral_val has the same shape as f
945
+ integral_val = weights[0] * f(nodes[0])
946
+ for i in range(1, n_nodes):
947
+ integral_val += weights[i] * f(nodes[i])
948
+ else:
949
+ if vectorized:
950
+ integral_vec = f(nodes, pars) @ weights
951
+ else:
952
+ # to ensure integral_val has the same shape as f
953
+ integral_val = weights[0] * f(nodes[0], pars)
954
+ for i in range(1, n_nodes):
955
+ integral_val += weights[i] * f(nodes[i], pars)
956
+
957
+ return cast(np.ndarray, integral_vec) if vectorized else cast(float, integral_val)