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,79 @@
1
+ """
2
+ Contains various `scipy` utility programs.
3
+ """
4
+ from math import sqrt
5
+ from typing import Any, cast
6
+
7
+ import numpy as np
8
+ import scipy.stats as sts
9
+ from scipy.interpolate import UnivariateSpline
10
+
11
+ from bs_python_utils.bsnputils import check_vector, rice_stderr
12
+ from bs_python_utils.bsutils import bs_error_abort, print_stars
13
+
14
+
15
+ def describe_array(v: np.ndarray, name: str | None = "v") -> Any:
16
+ """
17
+ descriptive statistics on an array interpreted as a vector
18
+
19
+ Args:
20
+ v: the array
21
+ name: its name
22
+
23
+ Returns:
24
+ the `scipy.stats.describe` object
25
+ """
26
+ print_stars(f"{name} has:")
27
+ d = sts.describe(v, None)
28
+ print(f"Number of elements: {d.nobs}")
29
+ print(f"Minimum: {d.minmax[0]}")
30
+ print(f"Maximum: {d.minmax[1]}")
31
+ print(f"Mean: {d.mean}")
32
+ print(f"Stderr: {sqrt(d.variance)}")
33
+ return d
34
+
35
+
36
+ def spline_reg(
37
+ y: np.ndarray,
38
+ x: np.ndarray,
39
+ x_new: np.ndarray | None = None,
40
+ is_sorted: bool | None = False,
41
+ smooth: bool | None = True,
42
+ ) -> np.ndarray:
43
+ """
44
+ one-dimensional spline interpolation of vector y on vector x
45
+
46
+ Args:
47
+ y: vector of y-values
48
+ x: vector of x-values
49
+ x_new: where we evaluate (at the points in `x` by default)
50
+ is_sorted: True if `x` is sorted in increasing order
51
+ smooth: True if we want a smoother; otherwise we go through all points provided
52
+
53
+ Returns:
54
+ values interpolated at `x_new`
55
+ """
56
+ n = check_vector(x)
57
+ ny = check_vector(y)
58
+ if ny != n:
59
+ bs_error_abort("x and y should have the same size")
60
+
61
+ if not is_sorted:
62
+ # need to sort by increasing value of x
63
+ order_rhs = np.argsort(x)
64
+ rhs = x[order_rhs]
65
+ lhs = y[order_rhs]
66
+ else:
67
+ rhs, lhs = x, y
68
+
69
+ if smooth:
70
+ # we compute a local estimator of the stderr of (y | x) and we use it to enter weights
71
+ sigyx = rice_stderr(lhs, rhs)
72
+ w = 1 / sigyx
73
+ spl = UnivariateSpline(rhs, lhs, w=w)
74
+ else:
75
+ spl = UnivariateSpline(rhs, lhs)
76
+
77
+ xeval = x if x_new is None else x_new
78
+ y_pred = spl(xeval)
79
+ return cast(np.ndarray, y_pred)
@@ -0,0 +1,463 @@
1
+ """
2
+ Contains some statistical routines.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from itertools import combinations_with_replacement
7
+ from typing import cast
8
+
9
+ import numpy as np
10
+ import scipy.linalg as spla
11
+ import scipy.stats as sts
12
+ from statsmodels.nonparametric._kernel_base import EstimatorSettings
13
+ from statsmodels.nonparametric.kernel_regression import KernelReg
14
+
15
+ from bs_python_utils.bsnputils import (
16
+ check_matrix,
17
+ check_vector,
18
+ check_vector_or_matrix,
19
+ make_lexico_grid,
20
+ )
21
+ from bs_python_utils.bssputils import spline_reg
22
+ from bs_python_utils.bsutils import bs_error_abort
23
+
24
+
25
+ @dataclass
26
+ class TslsResults:
27
+ """
28
+ contains full results of a TSLS regression
29
+ """
30
+
31
+ iv_estimates: float | np.ndarray | None
32
+ r2_first_iv: float | np.ndarray | None
33
+ r2_y: float | None
34
+ r2_second: float | np.ndarray | None
35
+ y_proj: float | np.ndarray | None
36
+ y_coeffs: float | np.ndarray | None
37
+ X_IV_proj: float | np.ndarray | None
38
+ b_proj_IV: float | np.ndarray | None
39
+
40
+
41
+ def _powers_Z(Z: np.ndarray, degrees: np.ndarray) -> np.ndarray:
42
+ """
43
+ used internally by `proj_Z`; returns `\\prod_{k=1}^m Z_{\\cdot k}^{l_k}`
44
+
45
+ Args:
46
+ Z: a matrix `(n, m)`
47
+ list_ints: a list of integers
48
+
49
+ Returns:
50
+ the product of the powers of `Z`
51
+ """
52
+ if Z.ndim != 2:
53
+ bs_error_abort(f"Z should have dimension 2, not {Z.ndim}")
54
+ m = Z.shape[1]
55
+ mdegs = check_vector(degrees)
56
+ if mdegs != m:
57
+ bs_error_abort("The size of degrees should equal the number of columns of Z")
58
+ res = np.ones(Z.shape[0])
59
+ for i, degi in enumerate(degrees):
60
+ res *= Z[:, i] ** degi
61
+ return res
62
+
63
+
64
+ def _final_proj(Zp: np.ndarray, W: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
65
+ MINVAR = 1e-12
66
+ b_proj, _, _, _ = spla.lstsq(Zp, W)
67
+ W_proj = Zp @ b_proj
68
+ if W.ndim == 1:
69
+ var_w = np.var(W)
70
+ r2 = np.var(W_proj) / var_w if var_w > MINVAR else 1.0
71
+ elif W.ndim == 2:
72
+ nw = W.shape[1]
73
+ r2 = np.ones(nw)
74
+ for i in range(nw):
75
+ var_w = np.var(W[:, i])
76
+ if var_w > MINVAR:
77
+ r2[i] = np.var(W_proj[:, i]) / var_w
78
+ else:
79
+ bs_error_abort(f"Wrong number of dimensions {W.ndim} for W")
80
+ return W_proj, b_proj, r2
81
+
82
+
83
+ def _make_Zp(Z: np.ndarray, p: int) -> tuple[np.ndarray, int]:
84
+ nobs, m = Z.shape
85
+ list_vars = list(range(m))
86
+ MAX_NTERMS = round(nobs / 5)
87
+ Zp = np.zeros((nobs, MAX_NTERMS))
88
+ Zp[:, 0] = np.ones(nobs)
89
+ k = 1
90
+ for q in range(1, p + 1):
91
+ listq = list(combinations_with_replacement(list_vars, q))
92
+ lenq = len(listq)
93
+ degrees = np.zeros((m, lenq))
94
+ for i in range(m):
95
+ degrees[i, :] = np.ndarray([x.count(i) for x in listq])
96
+ for j in range(lenq):
97
+ Zp[:, k] = _powers_Z(Z, degrees[:, j])
98
+ k += 1
99
+ if k >= MAX_NTERMS:
100
+ bs_error_abort(f"We don't allow more than {MAX_NTERMS} terms")
101
+ Zp = Zp[:, :k]
102
+ return Zp, k
103
+
104
+
105
+ def proj_Z(
106
+ W: np.ndarray, Z: np.ndarray, p: int = 1, verbose: bool = False
107
+ ) -> tuple[np.ndarray, np.ndarray, float]:
108
+ """
109
+ project `W` on `Z` up to degree `p` interactions
110
+
111
+ Args:
112
+ W: variable(s) `(nobs)` or `(nobs, nw)`
113
+ Z: instruments `(nobs) or `(nobs, nz)`;
114
+ they should **not** include a constant term
115
+ p: maximum total degree for interactions of the columns of `Z`
116
+ verbose: prints stuff if True
117
+
118
+ Returns:
119
+ the projections of the columns of `W` on `Z` etc, the coefficients, and the `R^2` of each column
120
+ """
121
+ nobs = Z.shape[0]
122
+ if W.shape[0] != nobs:
123
+ bs_error_abort("W and Z should have the same number of rows")
124
+ if W.ndim > 2:
125
+ bs_error_abort("W should have 1 or 2 dimensions")
126
+ if Z.ndim > 2:
127
+ bs_error_abort("Z should have 1 or 2 dimensions")
128
+
129
+ if Z.ndim == 1:
130
+ Zp = np.zeros((nobs, 1 + p))
131
+ Zp[:, 0] = np.ones(nobs)
132
+ for q in range(1, p + 1):
133
+ Zp[:, q] = Z**q
134
+ else: # Z is a matrix
135
+ Zp, k = _make_Zp(Z, p)
136
+ if verbose:
137
+ print(f"_proj_Z with degree {p}, using {k} regressors")
138
+
139
+ return _final_proj(Zp, W)
140
+
141
+
142
+ def tsls(y: np.ndarray, X: np.ndarray, Z: np.ndarray) -> TslsResults:
143
+ """
144
+ TSLS of `y` on `X` with instruments `Z`
145
+
146
+ Args:
147
+ y: independent variable `(nobs)`
148
+ X: covariates `(nobs, nx)`
149
+ Z: instruments `(nobs, nz)`
150
+
151
+ Returns:
152
+ a `tsls_results` object
153
+ """
154
+ # first stage
155
+ X_IV_proj, b_proj_IV, r2_first_iv = proj_Z(X, Z)
156
+ # second stage
157
+ y_proj, y_coeffs, r2_y = proj_Z(y, Z)
158
+ _, iv_estimates, r2_second = proj_Z(y_proj, X_IV_proj)
159
+ return TslsResults(
160
+ iv_estimates,
161
+ r2_first_iv,
162
+ r2_y,
163
+ r2_second,
164
+ y_proj,
165
+ y_coeffs,
166
+ X_IV_proj,
167
+ b_proj_IV,
168
+ )
169
+
170
+
171
+ def reg_nonpar(
172
+ y: np.ndarray,
173
+ X: np.ndarray,
174
+ var_types: str | None = None,
175
+ n_sub: int | None = None,
176
+ n_res: int | None = 1,
177
+ ) -> tuple[KernelReg, np.ndarray]:
178
+ """
179
+ nonparametric regression of y on the columns of X;
180
+ bandwidth chosen on a subsample of size nsub if nsub < nobs, and rescaled
181
+
182
+ Args:
183
+ y: a vector of size nobs
184
+ X: a (nobs) vector or a matrix of shape (nobs, m)
185
+ var_types: specify types of all `X` variables if not all of them are continuous;
186
+ one character per variable
187
+ * 'c' for continuous
188
+ * 'u' discrete unordered
189
+ * 'o' discrete ordered
190
+ n_sub: size of subsample for cross-validation; by default it is `200^{(m+4)/5}`
191
+ n_res: how many subsamples we draw; 1 by default
192
+
193
+ Returns:
194
+ fitted on sample (nobs, with derivatives)
195
+ and bandwidths (m)
196
+ """
197
+ _ = check_vector_or_matrix(X)
198
+ n_obs = check_vector(y)
199
+ if X.shape[0] != n_obs:
200
+ bs_error_abort("X and y should have the same number of observations")
201
+ m = 1 if X.ndim == 1 else X.shape[1]
202
+ if var_types is None:
203
+ types = "c" * m
204
+ else:
205
+ if len(var_types) != m:
206
+ bs_error_abort("var_types should have one entry for each column of X")
207
+ types = var_types
208
+
209
+ if n_sub is None:
210
+ n_sub = round(200 ** ((m + 4.0) / 5.0))
211
+
212
+ k = KernelReg(
213
+ y,
214
+ X,
215
+ var_type=types,
216
+ defaults=EstimatorSettings(
217
+ efficient=True, n_sub=n_sub, randomize=True, n_res=n_res
218
+ ),
219
+ )
220
+ return k.fit(), k.bw
221
+
222
+
223
+ def reg_nonpar_fit(
224
+ y: np.ndarray,
225
+ X: np.ndarray,
226
+ var_types: str | None = None,
227
+ n_sub: int | None = None,
228
+ n_res: int = 1,
229
+ verbose: bool = False,
230
+ ) -> np.ndarray:
231
+ """
232
+ nonparametric regression of y on the columns of X; bandwidth chosen on a subsample of size nsub if nsub < nobs, and rescaled
233
+
234
+ Args:
235
+ y: a vector of size nobs
236
+ X: a (nobs) vector or a matrix of shape (nobs, m)
237
+ var_types: specify types of all `X` variables if not all of them are continuous;
238
+ one character per variable
239
+ * 'c' for continuous
240
+ * 'u' discrete unordered
241
+ * 'o' discrete ordered
242
+ n_sub: size of subsample for cross-validation; by default it is `200^{(m+4)/5}`
243
+ n_res: how many subsamples we draw; 1 by default
244
+ verbose: prints stuff if True
245
+
246
+ Returns:
247
+ fitted values on sample (nobs)
248
+ """
249
+ kfbw = reg_nonpar(y, X, var_types, n_sub, n_res)
250
+ fitted_vals = cast(np.ndarray, kfbw[0][0])
251
+ return fitted_vals
252
+
253
+
254
+ def flexible_reg(
255
+ Y: np.ndarray,
256
+ X: np.ndarray,
257
+ mode: str = "NP",
258
+ var_types: str | None = None,
259
+ n_sub: int | None = None,
260
+ n_res: int = 1,
261
+ verbose: bool = False,
262
+ ) -> np.ndarray:
263
+ """
264
+ flexible regression of `Y` on `X`
265
+
266
+ Args:
267
+ Y: independent variable `(nobs)` or `(nobs, ny)`
268
+ X: covariates `(nobs)` or `(nobs, nx)`; should **not** include a constant term
269
+ mode: what flexible means
270
+ * 'NP': non parametric
271
+ * 'SPL': spline regression, only on one covariate
272
+ * '1': linear
273
+ * '2': quadratic
274
+ var_types: [for 'NP' only] specify types of all `X` variables if not all of them are continuous;
275
+ one character per variable
276
+ * 'c' for continuous
277
+ * 'u' discrete unordered
278
+ * 'o' discrete ordered
279
+ n_sub: [for 'NP' only] size of subsample for cross-validation; \
280
+ by default it is `200^{(m+4)/5}`
281
+ n_res: [for 'NP' only] how many subsamples we draw; 1 if `None`
282
+ verbose: prints stuff if True
283
+
284
+ Returns:
285
+ `E(y|X)` at the sample points
286
+ """
287
+ if mode == "NP":
288
+ if Y.ndim == 2:
289
+ ny = Y.shape[1]
290
+ Y_fit = np.zeros_like(Y)
291
+ for iy in range(ny):
292
+ Y_fit[:, iy] = reg_nonpar_fit(
293
+ Y[:, iy],
294
+ X,
295
+ var_types=var_types,
296
+ n_sub=n_sub,
297
+ n_res=n_res,
298
+ verbose=verbose,
299
+ )
300
+ return Y_fit
301
+ else:
302
+ return reg_nonpar_fit(
303
+ Y, X, var_types=var_types, n_sub=n_sub, n_res=n_res, verbose=verbose
304
+ )
305
+ elif mode == "SPL":
306
+ if X.ndim > 1:
307
+ bs_error_abort("with a spline, only works in one dimension")
308
+ return spline_reg(Y, X)
309
+ else:
310
+ try:
311
+ imode = int(mode)
312
+ except TypeError:
313
+ bs_error_abort(f"does not accept mode={mode}")
314
+ preg, _, _ = proj_Z(Y, X, p=imode, verbose=verbose)
315
+ return preg
316
+
317
+
318
+ def bs_multivariate_normal_pdf(
319
+ values_x: np.ndarray, means_x: float | np.ndarray, cov_mat: float | np.ndarray
320
+ ) -> np.ndarray:
321
+ """
322
+ Multivariate (or univariate) normal probability density function at values_x
323
+
324
+ Args:
325
+ values_x: values at which to evaluate the pdf, an `n`-vector or an `(n, nvars)` matrix
326
+ means_x: means of the multivariate normal, a float or an `(nvars)` vector
327
+ cov_mat: covariance matrix of the multivariate normal, a float or an `(nvars, nvars)` matrix
328
+
329
+ Returns:
330
+ the values of the density at `values_x`
331
+ """
332
+ ndims_values = check_vector_or_matrix(values_x, "bs_multivariate_normal_pdf")
333
+ if ndims_values == 1: # we are evaluating a univariate normal
334
+ # if not type(means_x) == float:
335
+ # bs_error_abort(f"means_x should be a float as values_x is a vector")
336
+ # if not type(cov_mat) == float:
337
+ # bs_error_abort(f"cov_mat should be a float as values_x is a vector")
338
+ sigma2 = cov_mat
339
+ resid = values_x - means_x
340
+ dval = np.exp(-0.5 * resid * resid / sigma2) / np.sqrt(2 * np.pi * sigma2)
341
+ return cast(np.ndarray, dval)
342
+ else: # we are evaluating a multivariate normal
343
+ n, nvars = values_x.shape
344
+ n_means = check_vector(means_x, "bs_multivariate_normal_pdf")
345
+ if n_means != nvars:
346
+ bs_error_abort(f"means_x should be a vector of size {nvars} not {n_means}")
347
+ nrows, ncols = check_matrix(cov_mat, "bs_multivariate_normal_pdf")
348
+ if nrows != ncols or nrows != nvars:
349
+ bs_error_abort(
350
+ f"cov_mat should be a matrix ({nvars}, {nvars}) not ({nrows}, {ncols})"
351
+ )
352
+ resid = values_x - means_x
353
+ argresid = spla.solve(cov_mat, resid.T)
354
+ argexp = np.zeros(n)
355
+ for i in range(n):
356
+ argexp[i] = np.dot(resid[i, :], argresid[:, i])
357
+ dval = np.exp(-0.5 * argexp) / np.sqrt(
358
+ ((2 * np.pi) ** nvars) * spla.det(cov_mat)
359
+ )
360
+ return cast(np.ndarray, dval)
361
+
362
+
363
+ def estimate_pdf(
364
+ x_obs: np.ndarray,
365
+ x_points: np.ndarray,
366
+ MIN_SIZE_NONPAR: int = 200,
367
+ weights: np.ndarray | None = None,
368
+ ) -> np.ndarray:
369
+ """
370
+ return an estimate of the conditional densities of `x` at points `values_x` (Silverman rule)
371
+
372
+ Args:
373
+ x_obs: an `n`-vector or an `(n, nvars)` matrix of the observed values of `x`
374
+ x_points: an `m`-vector or an `(m, nvars)` matrix of x values
375
+ MIN_SIZE_NONPAR: minimum size above which we use kernel density estimators
376
+ weights: an `n`-vector of weights for the observations, if present
377
+
378
+ Returns:
379
+ the density estimates at `values_x`
380
+ """
381
+ ndims_x = check_vector_or_matrix(x_obs, "estimate_pdf")
382
+ ndims_valx = check_vector_or_matrix(x_points, "estimate_pdf")
383
+
384
+ if ndims_x == 1:
385
+ n_obs = x_obs.size
386
+ if ndims_valx != 1:
387
+ bs_error_abort(f"x_points should have one dimension, not {ndims_valx}")
388
+ xt_obs = x_obs.reshape((-1, 1))
389
+ nvars = 1
390
+ xt_points = x_points.reshape((-1, 1))
391
+ else: # ndims_x == 2
392
+ n_obs, nvars = x_obs.shape
393
+ if ndims_valx == 1: # only one x point with nv elements
394
+ nv = x_points.size
395
+ else: # several x points with nv elements
396
+ nv = x_points.shape[1]
397
+ if nv != nvars:
398
+ bs_error_abort(f"x_points should have {nvars} variables, not {nv}")
399
+ xt_obs = x_obs
400
+ xt_points = x_points
401
+
402
+ if weights is not None:
403
+ n_w = check_vector(weights, "estimate_pdf")
404
+ if n_w != n_obs:
405
+ bs_error_abort(
406
+ f"if weights is given, it should be a vector of size {n_obs} not {n_w}"
407
+ )
408
+
409
+ min_size_np = MIN_SIZE_NONPAR ** ((4.0 + nvars) / 5.0)
410
+
411
+ if n_obs > min_size_np: # cell large enough to use nonparametrics
412
+ # fit joint density of x
413
+ kde = sts.gaussian_kde(xt_obs.T, bw_method="silverman", weights=weights)
414
+ # density of x at values_x
415
+ f_x = kde.evaluate(xt_points.T)
416
+ else:
417
+ # sample too small, we fit a normal
418
+ if ndims_x == 1: # univariate
419
+ mean_x = np.mean(x_obs)
420
+ var_x = np.var(x_obs)
421
+ f_x = bs_multivariate_normal_pdf(x_points, mean_x, var_x)
422
+ else: # multivariate
423
+ means_x = np.mean(x_obs, 0)
424
+ cov_mat = np.cov(x_obs.T)
425
+ f_x = bs_multivariate_normal_pdf(x_points, means_x, cov_mat)
426
+ if weights is not None:
427
+ f_x *= weights / np.mean(weights)
428
+ return cast(np.ndarray, f_x)
429
+
430
+
431
+ def estimate_densities_at_quantiles(
432
+ X: np.ndarray, qtiles: np.ndarray
433
+ ) -> tuple[np.ndarray, np.ndarray] | tuple[np.ndarray, np.ndarray, np.ndarray]:
434
+ """
435
+ estimate densities of margins at prespecified quantiles (Silverman rule)
436
+ and the joint density at each vector of these quantiles
437
+
438
+ Args:
439
+ X: `n`-vector or `(n, nx)`-matrix
440
+ qtiles: vector of `nq` numbers between 0 and 1
441
+
442
+ Returns:
443
+ if `X` is a matrix, the `({nq}^{nx}, {nx})` matrices of estimated margin densities \
444
+ and the `{nq}^{nx}` vector of the joint density on the lexicographic grid of quantiles;
445
+ if `X` is a vector, the `nq`-vector of the density at the quantiles, twice
446
+ """
447
+ ndims_X = check_vector_or_matrix(X, "estimate_densities_")
448
+ if ndims_X == 1:
449
+ f_X = estimate_pdf(X, np.quantile(X, qtiles))
450
+ return f_X, f_X
451
+ else:
452
+ nx = X.shape[1]
453
+ nq = qtiles.size
454
+ f_X_k = np.zeros((nq, nx))
455
+ nodes_mat = np.zeros((nq, nx))
456
+ for i_x in range(nx):
457
+ X_ix = X[:, i_x]
458
+ nodes_mat[:, i_x] = np.quantile(X_ix, qtiles)
459
+ f_X_k[:, i_x] = estimate_pdf(X_ix, nodes_mat[:, i_x])
460
+ f_margins = make_lexico_grid(f_X_k)
461
+ values_X = make_lexico_grid(nodes_mat)
462
+ f_X = estimate_pdf(X, values_X) # joint density
463
+ return f_margins, f_X, values_X