bs-python-utils 0.8.3__tar.gz → 0.9__tar.gz

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 (67) hide show
  1. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/PKG-INFO +5 -1
  2. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/README.md +3 -0
  3. bs_python_utils-0.9/bs_python_utils/bivariate_quantiles.py +238 -0
  4. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_logging.py +1 -1
  5. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils.egg-info/PKG-INFO +5 -1
  6. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils.egg-info/requires.txt +1 -0
  7. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/pyproject.toml +2 -1
  8. bs_python_utils-0.9/tests/test_bivariate_quantiles.py +40 -0
  9. bs_python_utils-0.8.3/bs_python_utils/bivariate_quantiles.py +0 -306
  10. bs_python_utils-0.8.3/tests/test_bivariate_quantiles.py +0 -80
  11. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/LICENSE +0 -0
  12. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid1prec13.txt +0 -0
  13. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid1prec17.txt +0 -0
  14. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid1prec9.txt +0 -0
  15. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid2prec13.txt +0 -0
  16. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid2prec17.txt +0 -0
  17. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid2prec9.txt +0 -0
  18. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid3prec13.txt +0 -0
  19. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid3prec17.txt +0 -0
  20. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid3prec9.txt +0 -0
  21. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid4prec13.txt +0 -0
  22. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid4prec17.txt +0 -0
  23. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid4prec9.txt +0 -0
  24. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid5prec13.txt +0 -0
  25. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid5prec17.txt +0 -0
  26. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/GaussHermiteSparseGrids/GHsparseGrid5prec9.txt +0 -0
  27. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/Timer.py +0 -0
  28. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/__init__.py +0 -0
  29. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_altair.py +0 -0
  30. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_mathstr.py +0 -0
  31. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_mem.py +0 -0
  32. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_opt.py +0 -0
  33. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_plots.py +0 -0
  34. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_seaborn.py +0 -0
  35. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bs_sparse_gaussian.py +0 -0
  36. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bsmplutils.py +0 -0
  37. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bsnputils.py +0 -0
  38. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bssputils.py +0 -0
  39. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bsstats.py +0 -0
  40. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/bsutils.py +0 -0
  41. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/chebyshev.py +0 -0
  42. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/distance_covariances.py +0 -0
  43. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_altair.py +0 -0
  44. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_distance_covariances.py +0 -0
  45. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_mem.py +0 -0
  46. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_mpl.py +0 -0
  47. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_opt.py +0 -0
  48. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_seaborn.py +0 -0
  49. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_sklearn.py +0 -0
  50. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/examples/examples_sparse_gaussian.py +0 -0
  51. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/pandas_utils.py +0 -0
  52. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/sklearn_utils.py +0 -0
  53. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils/streamlit_utils.py +0 -0
  54. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils.egg-info/SOURCES.txt +0 -0
  55. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils.egg-info/dependency_links.txt +0 -0
  56. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/bs_python_utils.egg-info/top_level.txt +0 -0
  57. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/setup.cfg +0 -0
  58. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bs_opt.py +0 -0
  59. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bs_sparse_gaussian.py +0 -0
  60. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bsnputils.py +0 -0
  61. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bssputils.py +0 -0
  62. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bsstats.py +0 -0
  63. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_bsutils.py +0 -0
  64. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_chebyshev.py +0 -0
  65. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_nptri.py +0 -0
  66. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_random_draws.py +0 -0
  67. {bs_python_utils-0.8.3 → bs_python_utils-0.9}/tests/test_sklearn_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bs-python-utils
3
- Version: 0.8.3
3
+ Version: 0.9
4
4
  Summary: Utilities programs for my Python code
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -25,6 +25,7 @@ Requires-Dist: statsmodels>=0.14.5
25
25
  Requires-Dist: streamlit>=1.49.1
26
26
  Requires-Dist: vega-datasets>=0.9.0
27
27
  Requires-Dist: colorama>=0.4.6
28
+ Requires-Dist: pyarrow>=21.0.0
28
29
  Dynamic: license-file
29
30
 
30
31
  ## bs-python-utils
@@ -42,6 +43,9 @@ Dynamic: license-file
42
43
 
43
44
  ### Release notes
44
45
 
46
+ #### 0.8.5 (January 25, 2026)
47
+ Added colored logger.
48
+
45
49
  #### 0.8.2 (October 26, 2025)
46
50
  Included grids for sparse Gaussian integration.
47
51
 
@@ -13,6 +13,9 @@
13
13
 
14
14
  ### Release notes
15
15
 
16
+ #### 0.8.5 (January 25, 2026)
17
+ Added colored logger.
18
+
16
19
  #### 0.8.2 (October 26, 2025)
17
20
  Included grids for sparse Gaussian integration.
18
21
 
@@ -0,0 +1,238 @@
1
+ """This takes in observations of a bivariate random variable `y`
2
+ and computes vector quantiles and vector ranks à la
3
+ [Chernozhukov-Galichon-Hallin-Henry (*Ann. Stats.* 2017)](
4
+ https://projecteuclid.org/journals/annals-of-statistics/volume-45/
5
+ issue-1/MongeKantorovich-depth-quantiles-ranks-and-signs/10.1214/16-AOS1450.full).
6
+
7
+
8
+ Note:
9
+ if the math looks strange in the documentation, just reload the page.
10
+
11
+ The sequence of steps is as follows:
12
+
13
+ 1. optimize the weights: `v = solve_for_v(y, n_nodes)` given `n_nodes` Chebyshev nodes for numerical integration
14
+ 2. to obtain the $(u_1,u_2)$ quantiles for $(u_1, u_2)\\in [0,1]$, run
15
+ `qtiles_y = bivariate_quantiles_v(y, v, u1, u2)`
16
+ 3. to compute the vector ranks for all points in the sample (the barycenters
17
+ of the cells in the power diagram):
18
+ `ranks_y = bivariate_ranks_v(y, v, n_nodes)`
19
+
20
+ Steps 1 and 2 can be combined: `qtiles_y = bivariate_quantiles(y, v, u1, u2, n_nodes)`
21
+
22
+ Steps 1 and 3 can be combined: `ranks_y = bivariate_ranks(y, n_nodes)`
23
+ """
24
+
25
+ from typing import cast
26
+
27
+ import numpy as np
28
+
29
+ from bs_python_utils.bs_opt import minimize_free, print_optimization_results
30
+ from bs_python_utils.bsnputils import TwoArrays, npmaxabs
31
+ from bs_python_utils.bsutils import bs_error_abort
32
+ from bs_python_utils.chebyshev import Interval, cheb_get_nodes_1d
33
+
34
+
35
+ def _compute_ad(y: np.ndarray) -> TwoArrays:
36
+ """Build the `A` and `dy2` matrices used in the dual optimisation."""
37
+ y1 = y[:, 0]
38
+ dy1 = np.subtract.outer(y1, y1)
39
+ y2 = y[:, 1]
40
+ dy2 = np.subtract.outer(y2, y2)
41
+ np.fill_diagonal(dy2, 1.0)
42
+ dy2 = dy2.T
43
+ a_mat = np.divide(dy1, dy2)
44
+ return a_mat, dy2
45
+
46
+
47
+ def _compute_m_M(
48
+ v: np.ndarray, a_mat: np.ndarray, dy2: np.ndarray, tau1_nodes: np.ndarray
49
+ ) -> TwoArrays:
50
+ """Build the `m` and `M` matrices used in the dual optimisation."""
51
+ dv = np.subtract.outer(v, v)
52
+ b_mat = dv / dy2
53
+ np.fill_diagonal(dy2, 0.0)
54
+ EPS = 1e-12
55
+ maskp = dy2 < EPS
56
+ maskm = dy2 > -EPS
57
+ n, n_nodes = v.size, tau1_nodes.size
58
+ m_low = np.empty((n, n_nodes))
59
+ m_high = np.empty((n, n_nodes))
60
+ for i, tau1 in enumerate(tau1_nodes):
61
+ f_mat = tau1 * a_mat - b_mat
62
+ f_matp = f_mat.copy()
63
+ f_matm = f_mat.copy()
64
+ f_matp[maskp] = 1
65
+ f_matm[maskm] = 0
66
+ m_low[:, i] = np.max(f_matm, axis=1)
67
+ m_high[:, i] = np.min(f_matp, axis=1)
68
+ return np.clip(m_low, 0.0, 1.0), np.clip(m_high, 0.0, 1.0)
69
+
70
+
71
+ def bivariate_quantiles_v(y: np.ndarray, tau: np.ndarray, v: np.ndarray) -> np.ndarray:
72
+ """Evaluate vector quantiles for a given set of dual weights.
73
+
74
+ Args:
75
+ y: Observations with shape ``(n, 2)``.
76
+ tau: Evaluation points in ``[0, 1]^2`` (shape ``(m, 2)``).
77
+ v: Dual weights solving the optimal transport problem (length ``n``).
78
+
79
+ Returns:
80
+ Array of quantile locations with shape ``(m, 2)``.
81
+ """
82
+ if tau.shape[1] != 2:
83
+ bs_error_abort("tau must have two columns")
84
+ q = y[np.argmax(tau @ y.T - v, axis=1), :]
85
+ return cast(np.ndarray, q)
86
+
87
+
88
+ def _objgrad(
89
+ v: np.ndarray, args: list, gr: bool = False
90
+ ) -> float | tuple[float, np.ndarray, np.ndarray]:
91
+ """computes the expectation of $\\psi(U, v)$ and perhaps its gradient wrt `v` or the bivariate ranks
92
+
93
+ Args:
94
+ v: an `(n-1)`-vector
95
+ args: a list of other arguments `[y, a_mat, dy2, tau1_nodes, tau1_weights, verbose]`
96
+ gr: if `False`, we only return the value of the objective function
97
+ if `True`, we also return the gradient and the bivariate ranks
98
+
99
+ Returns:
100
+ the value of the expectation and perhaps its gradient and the bivariate ranks
101
+ """
102
+ y = args[0]
103
+ y1 = y[:, 0]
104
+ y2 = y[:, 1]
105
+ n = y.shape[0]
106
+ a_mat, dy2 = args[1], args[2]
107
+ tau1_nodes = args[3]
108
+ tau1_weights = args[4]
109
+ vs1 = np.append(v, -np.sum(v))
110
+ m, M = _compute_m_M(vs1, a_mat, dy2, tau1_nodes)
111
+ # print(f"m is {m}")
112
+ # print(f"M is {M}")
113
+ # import sys
114
+
115
+ # sys.exit(1)
116
+
117
+ EPS = 1e-12
118
+ obj_val = 0.0
119
+ probs = np.zeros(n)
120
+ bivrank = np.zeros((n, 2))
121
+ for k in range(n):
122
+ Mk = M[k, :]
123
+ mk = m[k, :]
124
+ pos_diffs = np.maximum(Mk - mk, 0.0)
125
+ # print(f"pos_diffs for k={k} are {pos_diffs}")
126
+ pos_diffs_sq = np.maximum(Mk * Mk - mk * mk, 0.0)
127
+ probs[k] = pos_diffs @ tau1_weights
128
+ # print(f"probs[{k}] = {probs[k]}")
129
+ factor1 = (tau1_nodes * pos_diffs) @ tau1_weights
130
+ factor2 = (pos_diffs_sq @ tau1_weights) / 2.0
131
+ obj_val += y1[k] * factor1 + y2[k] * factor2 - vs1[k] * probs[k]
132
+ if probs[k] > EPS:
133
+ bivrank[k, 0] = factor1 / probs[k]
134
+ bivrank[k, 1] = factor2 / probs[k]
135
+
136
+ # print(f"{np.min(probs)=}")
137
+
138
+ if gr:
139
+ grad_val = probs[-1] - probs[:-1]
140
+ return obj_val, grad_val, bivrank
141
+ else:
142
+ return obj_val
143
+
144
+
145
+ def _obj(v: np.ndarray, args: list):
146
+ return _objgrad(v, args)
147
+
148
+
149
+ def _grad(v: np.ndarray, args: list):
150
+ res_objg = cast(tuple[float, np.ndarray], _objgrad(v, args, gr=True))
151
+ grad_val = res_objg[1]
152
+ verbose = args[-1]
153
+ if verbose:
154
+ print(f"The error on the gradient is {npmaxabs(grad_val)}")
155
+ return grad_val
156
+
157
+
158
+ def _solve_for_v(y: np.ndarray, n_nodes: int = 32, verbose: bool = False) -> TwoArrays:
159
+ """Solve the dual optimisation to obtain the optimal weights ``v`` and the bivariate ranks
160
+
161
+ Args:
162
+ y: Observations with shape ``(n, 2)``.
163
+ n_nodes: Number of Chebyshev nodes for the quadrature.
164
+ verbose: Print optimisation diagnostics when ``True``.
165
+
166
+ Returns:
167
+ Array of length ``n`` containing the optimal weights (including the
168
+ residual term).
169
+ Array of shape ``(n, 2)`` containing the bivariate ranks (the barycenters of the cells in the power diagram).
170
+ """
171
+ d = y.shape[1]
172
+
173
+ if d != 2:
174
+ bs_error_abort(f"only works for 2-dimensional y, not for {d}")
175
+
176
+ v0 = np.mean(y[:-1, :], 1)
177
+
178
+ interval01 = Interval(0.0, 1.0)
179
+ tau1_nodes, tau1_weights = cheb_get_nodes_1d(interval01, n_nodes)
180
+
181
+ a_mat, dy2 = _compute_ad(y)
182
+
183
+ argsog = [y, a_mat, dy2, tau1_nodes, tau1_weights, verbose]
184
+
185
+ res = minimize_free(_obj, _grad, v0, args=argsog)
186
+ if verbose:
187
+ print_optimization_results(res, "Minimizing over v")
188
+
189
+ if not res.success:
190
+ bs_error_abort("Problem! the optimization failed.")
191
+ vstar = res.x
192
+ if verbose:
193
+ print(f"The final gradient over v is close to 0: error {npmaxabs(res.jac)}")
194
+ _, _, bivranks = cast(tuple, _objgrad(vstar, argsog, gr=True))
195
+ vstar = np.append(vstar, -np.sum(vstar))
196
+ return cast(np.ndarray, vstar), cast(np.ndarray, bivranks)
197
+
198
+
199
+ def bivariate_ranks(
200
+ y: np.ndarray,
201
+ n_nodes: int = 32,
202
+ verbose: bool = False,
203
+ ) -> np.ndarray:
204
+ """Compute the barycentric ranks of each observation.
205
+
206
+ Args:
207
+ y: Observations with shape ``(n, 2)``.
208
+ n_nodes: Number of Chebyshev nodes used in the quadrature.
209
+ verbose: Print diagnostics when ``True``.
210
+
211
+ Returns:
212
+ Array of average ranks (shape ``(n, 2)``) with ``nan`` for zero-mass cells.
213
+ """
214
+ d = y.shape[1]
215
+
216
+ if d != 2:
217
+ bs_error_abort(f"only works for 2-dimensional y, not for {d}")
218
+
219
+ _, bivranks = _solve_for_v(y, n_nodes, verbose)
220
+ return cast(np.ndarray, bivranks)
221
+
222
+
223
+ def bivariate_quantiles(
224
+ y: np.ndarray, tau: np.ndarray, n_nodes: int = 32, verbose: bool = False
225
+ ) -> np.ndarray:
226
+ """Solve for the dual weights then evaluate bivariate quantiles.
227
+
228
+ Args:
229
+ y: Observations, shape ``(n, 2)``.
230
+ tau: Query points in ``[0, 1]^2`` (shape ``(m, 2)``).
231
+ n_nodes: Number of Chebyshev nodes for the quadrature.
232
+ verbose: Print optimisation diagnostics when ``True``.
233
+
234
+ Returns:
235
+ Bivariate quantiles at ``u``.
236
+ """
237
+ v, _ = _solve_for_v(y, n_nodes, verbose)
238
+ return bivariate_quantiles_v(y, tau, v)
@@ -46,7 +46,7 @@ class ColorFormatter(logging.Formatter):
46
46
 
47
47
  def get_logger(
48
48
  name: str,
49
- level=logging.INFO,
49
+ level: int = logging.INFO,
50
50
  log_to_file: bool = False,
51
51
  ) -> logging.Logger:
52
52
  """Create and return a logger with colored output.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bs-python-utils
3
- Version: 0.8.3
3
+ Version: 0.9
4
4
  Summary: Utilities programs for my Python code
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -25,6 +25,7 @@ Requires-Dist: statsmodels>=0.14.5
25
25
  Requires-Dist: streamlit>=1.49.1
26
26
  Requires-Dist: vega-datasets>=0.9.0
27
27
  Requires-Dist: colorama>=0.4.6
28
+ Requires-Dist: pyarrow>=21.0.0
28
29
  Dynamic: license-file
29
30
 
30
31
  ## bs-python-utils
@@ -42,6 +43,9 @@ Dynamic: license-file
42
43
 
43
44
  ### Release notes
44
45
 
46
+ #### 0.8.5 (January 25, 2026)
47
+ Added colored logger.
48
+
45
49
  #### 0.8.2 (October 26, 2025)
46
50
  Included grids for sparse Gaussian integration.
47
51
 
@@ -18,3 +18,4 @@ statsmodels>=0.14.5
18
18
  streamlit>=1.49.1
19
19
  vega-datasets>=0.9.0
20
20
  colorama>=0.4.6
21
+ pyarrow>=21.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bs-python-utils"
3
- version = "0.8.3"
3
+ version = "0.9"
4
4
  description = "Utilities programs for my Python code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  "streamlit>=1.49.1",
26
26
  "vega-datasets>=0.9.0",
27
27
  "colorama>=0.4.6",
28
+ "pyarrow>=21.0.0",
28
29
  ]
29
30
 
30
31
  [tool.setuptools]
@@ -0,0 +1,40 @@
1
+ import numpy as np
2
+
3
+ from bs_python_utils.bivariate_quantiles import (
4
+ _compute_ad,
5
+ _solve_for_v,
6
+ )
7
+ from bs_python_utils.bsnputils import npmaxabs
8
+
9
+
10
+ def test_bivariate_ranks():
11
+ rng = np.random.default_rng(123)
12
+ n = 500
13
+ y = rng.normal(size=(n, 2))
14
+ nodes = 32
15
+
16
+ _, bivranks = _solve_for_v(y, n_nodes=nodes, verbose=False)
17
+
18
+ bivranks_th = np.empty_like(bivranks)
19
+ for k in range(n):
20
+ bivranks_th[k, 0] = np.mean(y[:, 0] < y[k, 0]) + 0.5 * np.mean(
21
+ y[:, 0] == y[k, 0]
22
+ )
23
+ bivranks_th[k, 1] = np.mean(y[:, 1] < y[k, 1]) + 0.5 * np.mean(
24
+ y[:, 1] == y[k, 1]
25
+ )
26
+
27
+ assert npmaxabs(bivranks - bivranks_th) < 0.01
28
+
29
+
30
+ def test_compute_ad():
31
+ y = np.array([[0.0, 1.0], [1.0, 1.3], [-1.0, 3.0]])
32
+ a_mat, dy2 = _compute_ad(y)
33
+
34
+ a_mat_th = np.array(
35
+ [[0.0, -1.0 / 0.3, 0.5], [-1.0 / 0.3, 0.0, 2.0 / 1.7], [0.5, 2.0 / 1.7, 0.0]]
36
+ )
37
+ dy2_th = np.array([[1.0, 0.3, 2.0], [-0.3, 1.0, 1.7], [-2.0, -1.7, 1.0]])
38
+
39
+ assert np.allclose(a_mat, a_mat_th)
40
+ assert np.allclose(dy2, dy2_th)
@@ -1,306 +0,0 @@
1
- """This takes in observations of a bivariate random variable `y`
2
- and computes vector quantiles and vector ranks à la
3
- [Chernozhukov-Galichon-Hallin-Henry (*Ann. Stats.* 2017)](
4
- https://projecteuclid.org/journals/annals-of-statistics/volume-45/
5
- issue-1/MongeKantorovich-depth-quantiles-ranks-and-signs/10.1214/16-AOS1450.full).
6
-
7
-
8
- Note:
9
- if the math looks strange in the documentation, just reload the page.
10
-
11
- The sequence of steps is as follows:
12
-
13
- * choose a number of Chebyshev nodes for numerical integration and optimize
14
- the weights: `v = solve_for_v(y, n_nodes)`
15
- * to obtain the $(u_1,u_2)$ quantiles for $(u_1, u_2)\\in [0,1]$, run
16
- `qtiles_y = bivariate_quantiles_v(y, v, u1, u2)`
17
- * to compute the vector ranks for all points in the sample (the barycenters
18
- of the cells in the power diagram):
19
- `ranks_y = bivariate_ranks_v(y, v, n_nodes)`
20
-
21
- Steps 1 and 2 can be combined: `qtiles_y = bivariate_quantiles(y, v, u1, u2, n_nodes)`
22
-
23
- Steps 1 and 3 can be combined: `ranks_y = bivariate_ranks(y, n_nodes)`
24
- """
25
-
26
- from typing import cast
27
-
28
- import numpy as np
29
-
30
- from bs_python_utils.bs_opt import minimize_free, print_optimization_results
31
- from bs_python_utils.bsnputils import TwoArrays, npmaxabs
32
- from bs_python_utils.bsutils import bs_error_abort
33
- from bs_python_utils.chebyshev import Interval, cheb_get_nodes_1d
34
-
35
-
36
- def _compute_ab(y_sorted: np.ndarray, v_sorted: np.ndarray) -> TwoArrays:
37
- """Build the `A` and `B` matrices used in the dual optimisation."""
38
- y1 = y_sorted[:, 0]
39
- dy1 = np.subtract.outer(y1, y1)
40
- y2 = y_sorted[:, 1]
41
- dy2 = np.subtract.outer(y2, y2)
42
- np.fill_diagonal(dy2, 1.0)
43
- dv = np.subtract.outer(v_sorted, v_sorted)
44
- with np.errstate(divide="ignore", invalid="ignore"):
45
- a_mat = np.divide(dy1.T, dy2, where=np.abs(dy2) > 1e-12)
46
- b_mat = np.divide(dv.T, dy2, where=np.abs(dy2) > 1e-12)
47
- a_mat = np.nan_to_num(a_mat, nan=0.0, posinf=0.0, neginf=0.0)
48
- b_mat = np.nan_to_num(b_mat, nan=0.0, posinf=0.0, neginf=0.0)
49
- return a_mat, b_mat
50
-
51
-
52
- def _compute_u2_bounds(
53
- k: int, u1: np.ndarray, a_mat: np.ndarray, b_mat: np.ndarray
54
- ) -> TwoArrays:
55
- """Return the admissible interval of ``u2`` that selects index ``k``."""
56
- n = a_mat.shape[0]
57
- m = u1.size
58
- if k == 0:
59
- left_bound = np.zeros(m)
60
- a_right = a_mat[0, 1:]
61
- b_right = b_mat[0, 1:]
62
- if a_right.size:
63
- right_bound = np.min(np.outer(u1, a_right) - b_right, 1)
64
- else:
65
- right_bound = np.ones(m)
66
- elif 1 <= k < n - 1:
67
- a_left = a_mat[k, :k]
68
- b_left = b_mat[k, :k]
69
- if a_left.size:
70
- left_bound = np.max(np.outer(u1, a_left) - b_left, 1)
71
- else:
72
- left_bound = np.zeros(m)
73
- a_right = a_mat[k, (k + 1) :]
74
- b_right = b_mat[k, (k + 1) :]
75
- if a_right.size:
76
- right_bound = np.min(np.outer(u1, a_right) - b_right, 1)
77
- else:
78
- right_bound = np.ones(m)
79
- elif k == n - 1:
80
- a_left = a_mat[-1, :-1]
81
- b_left = b_mat[-1, :-1]
82
- if a_left.size:
83
- left_bound = np.max(np.outer(u1, a_left) - b_left, 1)
84
- else:
85
- left_bound = np.zeros(m)
86
- right_bound = np.ones(m)
87
- else:
88
- bs_error_abort(f"{k=} is not compatible with {n=}")
89
- left_bound = np.clip(left_bound, 0.0, 1.0)
90
- right_bound = np.clip(right_bound, 0.0, 1.0)
91
-
92
- return left_bound, right_bound
93
-
94
-
95
- def bivariate_quantiles_v(y: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
96
- """Evaluate vector quantiles for a given set of dual weights.
97
-
98
- Args:
99
- y: Observations with shape ``(n, 2)``.
100
- u: Evaluation points in ``[0, 1]^2`` (shape ``(m, 2)``).
101
- v: Dual weights solving the optimal transport problem (length ``n``).
102
-
103
- Returns:
104
- Array of quantile locations with shape ``(m, 2)``.
105
- """
106
- u = np.atleast_2d(u)
107
- if u.shape[1] != 2:
108
- bs_error_abort("u must have two columns")
109
- m = u.shape[0]
110
- q = np.empty((m, 2))
111
- block = max(1, min(m, 5_000))
112
- for start in range(0, m, block):
113
- stop = min(start + block, m)
114
- chunk = u[start:stop]
115
- net_val = chunk @ y.T - v
116
- k_max = np.argmax(net_val, axis=1)
117
- q[start:stop] = y[k_max]
118
- return cast(np.ndarray, q)
119
-
120
-
121
- def bivariate_ranks_v(
122
- y: np.ndarray, v: np.ndarray, n_nodes: int = 32, presorted: bool = False
123
- ) -> np.ndarray:
124
- """Compute the barycentric ranks of each observation given optimal weights.
125
-
126
- Args:
127
- y: Observations with shape ``(n, 2)``.
128
- v: Dual weights returned by ``solve_for_v_``.
129
- n_nodes: Number of Chebyshev nodes used in the quadrature.
130
- presorted: Set to ``True`` when ``y``/``v`` are pre-sorted by the
131
- second coordinate.
132
-
133
- Returns:
134
- Array of average ranks (shape ``(n, 2)``) with ``nan`` for zero-mass cells.
135
- """
136
- n, d = y.shape
137
-
138
- if d != 2:
139
- bs_error_abort(f"only works for 2-dimensional y, not for {d}")
140
-
141
- interval01 = Interval(0.0, 1.0)
142
- u1_nodes, u1_weights = cheb_get_nodes_1d(interval01, n_nodes)
143
-
144
- if presorted:
145
- sort_order = np.arange(n)
146
- y_sorted = y
147
- v_sorted = v
148
- else:
149
- sort_order = np.argsort(y[:, 1])
150
- y_sorted = y[sort_order, :]
151
- v_sorted = v[sort_order]
152
-
153
- a_mat, b_mat = _compute_ab(y_sorted, v_sorted)
154
-
155
- average_ranks = np.zeros((n, 2))
156
-
157
- for k in range(n):
158
- left_bounds, right_bounds = _compute_u2_bounds(k, u1_nodes, a_mat, b_mat)
159
- pos_diffs = np.maximum(right_bounds - left_bounds, 0.0)
160
- pos_diffs_sq = np.maximum(
161
- right_bounds * right_bounds - left_bounds * left_bounds, 0.0
162
- )
163
- prob_k = pos_diffs @ u1_weights
164
- if prob_k <= 1e-12:
165
- average_ranks[sort_order[k], :] = np.array([np.nan, np.nan])
166
- continue
167
- average_ranks[sort_order[k], 0] = ((u1_nodes * pos_diffs) @ u1_weights) / prob_k
168
- average_ranks[sort_order[k], 1] = ((pos_diffs_sq @ u1_weights) / 2.0) / prob_k
169
-
170
- return average_ranks
171
-
172
-
173
- def _objgrad(
174
- v_sorted: np.ndarray, args: list, gr: bool = False
175
- ) -> float | tuple[float, np.ndarray]:
176
- """computes the expectation of $\\psi(U, v)$ and perhaps its gradient wrt `v`
177
-
178
- Args:
179
- v_sorted: an `n`-vector of weights, sorted by increasing `y[:, 1]`
180
- args: a list of other arguments `[y_sorted, u1_nodes, u1_weights, verbose]`
181
- gr: if `True`, we also evaluate the gradient
182
-
183
- Returns:
184
- the value of the expectation and perhaps its gradient
185
- """
186
- y_sorted = args[0]
187
- n = y_sorted.shape[0]
188
- u1_nodes = args[1]
189
- u1_weights = args[2]
190
- vs1 = np.append(v_sorted, -np.sum(v_sorted))
191
- a_mat, b_mat = _compute_ab(y_sorted, vs1)
192
-
193
- obj_val = 0.0
194
- probs = np.zeros(n)
195
- for k in range(n):
196
- left_bounds, right_bounds = _compute_u2_bounds(k, u1_nodes, a_mat, b_mat)
197
- pos_diffs = np.maximum(right_bounds - left_bounds, 0.0)
198
- pos_diffs_sq = np.maximum(
199
- right_bounds * right_bounds - left_bounds * left_bounds, 0.0
200
- )
201
- obj_val += (
202
- y_sorted[k, 0] * ((u1_nodes * pos_diffs) @ u1_weights)
203
- + y_sorted[k, 1] * (pos_diffs_sq @ u1_weights) / 2.0
204
- )
205
- probs[k] = pos_diffs @ u1_weights
206
- obj_val -= vs1[k] * probs[k]
207
-
208
- if gr:
209
- grad_val = probs[-1] - probs[:-1]
210
- return obj_val, grad_val
211
- else:
212
- return cast(float, obj_val)
213
-
214
-
215
- def _obj(v_sorted: np.ndarray, args: list):
216
- return _objgrad(v_sorted, args)
217
-
218
-
219
- def _grad(v_sorted: np.ndarray, args: list):
220
- res_objg = cast(tuple[float, np.ndarray], _objgrad(v_sorted, args, gr=True))
221
- grad_val = res_objg[1]
222
- verbose = args[3]
223
- if verbose:
224
- print(f"The error on the gradient is {npmaxabs(grad_val)}")
225
- return grad_val
226
-
227
-
228
- def solve_for_v_(y: np.ndarray, n_nodes: int = 32, verbose: bool = False) -> np.ndarray:
229
- """Solve the dual optimisation to obtain the optimal weights ``v``.
230
-
231
- Args:
232
- y: Observations with shape ``(n, 2)``.
233
- n_nodes: Number of Chebyshev nodes for the quadrature.
234
- verbose: Print optimisation diagnostics when ``True``.
235
-
236
- Returns:
237
- Array of length ``n`` containing the optimal weights (including the
238
- residual term).
239
- """
240
- n, d = y.shape
241
-
242
- if d != 2:
243
- bs_error_abort(f"only works for 2-dimensional y, not for {d}")
244
-
245
- # sort by increasing y[:, 1]
246
- sort_order = np.argsort(y[:, 1])
247
- y_sorted = y[sort_order, :]
248
-
249
- v0 = np.mean(y_sorted[:-1, :], 1)
250
-
251
- interval01 = Interval(0.0, 1.0)
252
- u1_nodes, u1_weights = cheb_get_nodes_1d(interval01, n_nodes)
253
-
254
- argsog = [y_sorted, u1_nodes, u1_weights, verbose]
255
-
256
- res = minimize_free(_obj, _grad, v0, args=argsog)
257
- if verbose:
258
- print_optimization_results(res, "Minimizing over v")
259
-
260
- if not res.success:
261
- bs_error_abort("Problem! the optimization failed.")
262
- vstar = res.x
263
- if verbose:
264
- print(f"The final gradient over v is close to 0: error {npmaxabs(res.jac)}")
265
- vstar1_sorted = np.append(vstar, -np.sum(vstar))
266
-
267
- # revert to original order
268
- vstar1 = np.zeros_like(vstar1_sorted)
269
- vstar1[sort_order] = vstar1_sorted
270
-
271
- return vstar1
272
-
273
-
274
- def bivariate_quantiles(
275
- y: np.ndarray, u: np.ndarray, n_nodes: int = 32, verbose: bool = False
276
- ) -> np.ndarray:
277
- """Solve for the dual weights then evaluate bivariate quantiles.
278
-
279
- Args:
280
- y: Observations, shape ``(n, 2)``.
281
- u: Query points in ``[0, 1]^2`` (shape ``(m, 2)``).
282
- n_nodes: Number of Chebyshev nodes for the quadrature.
283
- verbose: Print optimisation diagnostics when ``True``.
284
-
285
- Returns:
286
- Bivariate quantiles at ``u``.
287
- """
288
- v = solve_for_v_(y, n_nodes, verbose)
289
- return bivariate_quantiles_v(y, u, v)
290
-
291
-
292
- def bivariate_ranks(
293
- y: np.ndarray, n_nodes: int = 32, verbose: bool = False
294
- ) -> np.ndarray:
295
- """Compute ranks by first solving for the optimal weights ``v``.
296
-
297
- Args:
298
- y: Observations, shape ``(n, 2)``.
299
- n_nodes: Number of Chebyshev nodes for the quadrature.
300
- verbose: Print optimisation diagnostics when ``True``.
301
-
302
- Returns:
303
- Average ranks with shape ``(n, 2)``.
304
- """
305
- v = solve_for_v_(y, n_nodes, verbose)
306
- return bivariate_ranks_v(y, v, n_nodes)
@@ -1,80 +0,0 @@
1
- import numpy as np
2
- import pytest
3
-
4
- from bs_python_utils.bivariate_quantiles import (
5
- _compute_ab,
6
- _compute_u2_bounds,
7
- bivariate_quantiles,
8
- bivariate_quantiles_v,
9
- bivariate_ranks,
10
- bivariate_ranks_v,
11
- solve_for_v_,
12
- )
13
- from bs_python_utils.bsnputils import bsgrid, ecdf, npmaxabs
14
-
15
-
16
- def test_bivariate_quantiles_recompute_matches():
17
- rng = np.random.default_rng(123)
18
- y = rng.normal(size=(64, 2))
19
- nodes = 16
20
-
21
- weights = solve_for_v_(y, n_nodes=nodes, verbose=False)
22
-
23
- q = np.linspace(0.0, 1.0, 5)
24
- u_points = bsgrid(q, q)
25
-
26
- direct = bivariate_quantiles_v(y, u_points, weights)
27
- via_solver = bivariate_quantiles(y, u_points, n_nodes=nodes, verbose=False)
28
-
29
- ranks_direct = bivariate_ranks_v(y, weights, n_nodes=nodes)
30
- ranks_via_solver = bivariate_ranks(y, n_nodes=nodes, verbose=False)
31
-
32
- assert np.allclose(direct, via_solver)
33
- assert np.allclose(ranks_direct, ranks_via_solver)
34
- assert (
35
- npmaxabs(ranks_direct - np.column_stack((ecdf(y[:, 0]), ecdf(y[:, 1])))) < 0.2
36
- )
37
-
38
-
39
- def test_chunked_quantiles_matches_numpy_argmax():
40
- rng = np.random.default_rng(7)
41
- y = rng.normal(size=(10, 2))
42
- v = rng.normal(size=10)
43
- u = rng.uniform(0.0, 1.0, size=(6000, 2))
44
-
45
- chunked = bivariate_quantiles_v(y, u, v)
46
- expected = y[np.argmax(u @ y.T - v, axis=1)]
47
-
48
- assert np.allclose(chunked, expected)
49
-
50
-
51
- def test_compute_ab_handles_ties():
52
- y = np.array([[0.0, 1.0], [1.0, 1.0]])
53
- v = np.array([0.2, -0.2])
54
- a_mat, b_mat = _compute_ab(y, v)
55
-
56
- assert np.isfinite(a_mat).all()
57
- assert np.isfinite(b_mat).all()
58
-
59
-
60
- @pytest.mark.parametrize("k", [0, 1])
61
- def test_compute_u2_bounds_degenerate(k):
62
- y = np.array([[0.0, 0.0], [1.0, 1.0]])
63
- v = np.array([0.1, -0.1])
64
- a_mat, b_mat = _compute_ab(y, v)
65
- nodes = np.linspace(0.0, 1.0, 5)
66
-
67
- left, right = _compute_u2_bounds(k, nodes, a_mat, b_mat)
68
-
69
- assert np.isfinite(left).all()
70
- assert np.isfinite(right).all()
71
- assert (right >= left).all()
72
-
73
-
74
- def test_bivariate_ranks_zero_mass_returns_nan():
75
- y = np.array([[0.0, 0.0], [1.0, 1.0]])
76
- v = np.array([0.1, -0.1])
77
- ranks = bivariate_ranks_v(y, v, n_nodes=8, presorted=True)
78
-
79
- assert np.isnan(ranks[0]).all()
80
- assert np.isfinite(ranks[1]).all()
File without changes
File without changes