funcsim 0.2.5__tar.gz → 0.2.7__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 (38) hide show
  1. {funcsim-0.2.5/funcsim.egg-info → funcsim-0.2.7}/PKG-INFO +2 -2
  2. {funcsim-0.2.5 → funcsim-0.2.7}/README.md +1 -1
  3. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/__init__.py +4 -2
  4. funcsim-0.2.7/funcsim/copcompare.py +120 -0
  5. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/copfit.py +151 -13
  6. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/core.py +91 -23
  7. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/dependence.py +766 -19
  8. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/distfit.py +76 -10
  9. funcsim-0.2.7/funcsim/kde.py +247 -0
  10. {funcsim-0.2.5 → funcsim-0.2.7/funcsim.egg-info}/PKG-INFO +2 -2
  11. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/SOURCES.txt +1 -0
  12. {funcsim-0.2.5 → funcsim-0.2.7}/setup.py +1 -1
  13. funcsim-0.2.5/funcsim/kde.py +0 -183
  14. {funcsim-0.2.5 → funcsim-0.2.7}/LICENSE +0 -0
  15. {funcsim-0.2.5 → funcsim-0.2.7}/MANIFEST.in +0 -0
  16. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/addist.py +0 -0
  17. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/conversions.py +0 -0
  18. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/cpt.py +0 -0
  19. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/cvmdist.py +0 -0
  20. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/ecdfgof.py +0 -0
  21. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/edf.py +0 -0
  22. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/eut.py +0 -0
  23. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/imanconover.py +0 -0
  24. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/ksdist.py +0 -0
  25. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/multicore.py +0 -0
  26. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/nearby.py +0 -0
  27. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/plotting.py +0 -0
  28. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/rdarrays.py +0 -0
  29. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/screen.py +0 -0
  30. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/shapiro.py +0 -0
  31. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/shrinkage.py +0 -0
  32. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/testsim.py +0 -0
  33. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/utests.py +0 -0
  34. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/vect.py +0 -0
  35. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/dependency_links.txt +0 -0
  36. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/requires.txt +0 -0
  37. {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/top_level.txt +0 -0
  38. {funcsim-0.2.5 → funcsim-0.2.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: funcsim
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: functional, simple stochastic simulation
5
5
  Home-page: https://github.com/h-bryant/funcsim
6
6
  Author: Henry Bryant
@@ -54,7 +54,7 @@ Features:
54
54
  - stratified/Latin hypercube sampling
55
55
  - painless multi-core simulations
56
56
  - fitting and comparison of univariate distributions, kernel density estimates, and goodness-of-fit tests
57
- - multivariate normal and KDE distributions; Gaussian, Student's t, Clayton, Gumbel, and Frank copulas (implemented natively, no additional packages required); Iman-Conover; covariance shrinkage
57
+ - multivariate normal (fitted to data or specified by a mean vector and covariance matrix) and multivariate KDE distributions; Gaussian, Student's t, Clayton, Gumbel, and Frank copulas, fitted to pseudo-observations or specified by their parameters (implemented natively, no additional packages required); information-criterion comparison of copula families; Iman-Conover; covariance shrinkage
58
58
  - expected utility and cumulative prospect theory calculations
59
59
  - plotly-based fan charts and diagnostic plots
60
60
  - highly flexible, and as simple as possible (but no more)
@@ -9,7 +9,7 @@ Features:
9
9
  - stratified/Latin hypercube sampling
10
10
  - painless multi-core simulations
11
11
  - fitting and comparison of univariate distributions, kernel density estimates, and goodness-of-fit tests
12
- - multivariate normal and KDE distributions; Gaussian, Student's t, Clayton, Gumbel, and Frank copulas (implemented natively, no additional packages required); Iman-Conover; covariance shrinkage
12
+ - multivariate normal (fitted to data or specified by a mean vector and covariance matrix) and multivariate KDE distributions; Gaussian, Student's t, Clayton, Gumbel, and Frank copulas, fitted to pseudo-observations or specified by their parameters (implemented natively, no additional packages required); information-criterion comparison of copula families; Iman-Conover; covariance shrinkage
13
13
  - expected utility and cumulative prospect theory calculations
14
14
  - plotly-based fan charts and diagnostic plots
15
15
  - highly flexible, and as simple as possible (but no more)
@@ -10,6 +10,8 @@ from .dependence import CopulaClayton
10
10
  from .dependence import CopulaGumbel
11
11
  from .dependence import CopulaFrank
12
12
 
13
+ from .copcompare import copcompare
14
+
13
15
  from .imanconover import imanconover
14
16
 
15
17
  from .shrinkage import shrink
@@ -46,6 +48,6 @@ def version():
46
48
  Returns
47
49
  -------
48
50
  str
49
- The version string of the package (e.g., '0.2.5').
51
+ The version string of the package (e.g., '0.2.7').
50
52
  """
51
- return "0.2.5"
53
+ return "0.2.7"
@@ -0,0 +1,120 @@
1
+ """
2
+ Information-criteria comparison of funcsim's copula families.
3
+ """
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from . import conversions
11
+ from . import copfit
12
+ from .dependence import (CopulaGauss, CopulaStudent, CopulaClayton,
13
+ CopulaGumbel, CopulaFrank)
14
+
15
+
16
+ def _fmt(x: float) -> str:
17
+ return f"{x:.3f}"
18
+
19
+
20
+ def _rho_label(rho: np.ndarray) -> str:
21
+ # a scalar for two variables, otherwise just the matrix size
22
+ K = rho.shape[0]
23
+ if K == 2:
24
+ return f"rho={_fmt(rho[0, 1])}"
25
+ return f"rho: {K}x{K} matrix"
26
+
27
+
28
+ def copcompare(udata: conversions.ArrayLike) -> pd.DataFrame:
29
+ """
30
+ Compare fits of funcsim's copula families to pseudo-observations.
31
+
32
+ Each family (Gaussian, Student's t, Clayton, Gumbel, Frank) is fitted
33
+ to `udata` exactly as the corresponding copula class would fit it, its
34
+ copula log-likelihood (the pseudo-likelihood of Genest, Ghoudi, and
35
+ Rivest, 1995) is evaluated at those fitted parameters, and the Akaike
36
+ and Bayesian information criteria are computed from it.
37
+
38
+ Parameters
39
+ ----------
40
+ udata : ArrayLike
41
+ Pseudo-observations (marginal CDF values) with variables in columns
42
+ and observations in rows; every value must lie in (0, 1).
43
+
44
+ Returns
45
+ -------
46
+ pd.DataFrame
47
+ One row per family, indexed by family name and sorted by BIC from
48
+ best (smallest) to worst, with columns ``parameters`` (the fitted
49
+ values as text), ``n_params``, ``loglik``, ``AIC``, and ``BIC``.
50
+
51
+ Notes
52
+ -----
53
+ The parameters are the ones the copula classes use: normal-scores
54
+ correlation for the Gaussian copula, Kendall's-tau inversion plus a
55
+ profile likelihood for the degrees of freedom for the Student's t copula,
56
+ and maximum pseudo-likelihood for the Archimedean families. The
57
+ Gaussian and Student's t log-likelihoods are therefore evaluated at
58
+ moment-type estimates rather than at the exact maximum, so their
59
+ criteria are conservative by a small amount. A Student's t row with
60
+ ``nu=inf`` means the profile likelihood peaked at the ceiling of the
61
+ degrees-of-freedom search (200), so the fitted copula is the Gaussian
62
+ copula with the Kendall's-tau correlation matrix; it still pays for the
63
+ extra parameter in the criteria. Standard information
64
+ criteria applied to pseudo-likelihoods are approximate (Grønneberg and
65
+ Hjort, 2014); treat differences of a few points as ties. Warnings
66
+ issued while fitting a family (for example, negative dependence in data
67
+ handed to an Archimedean family that represents only positive
68
+ dependence) are passed through.
69
+
70
+ References
71
+ ----------
72
+ Genest, C., Ghoudi, K., & Rivest, L.-P. (1995). A semiparametric
73
+ estimation procedure of dependence parameters in multivariate families
74
+ of distributions. Biometrika, 82(3), 543-552.
75
+
76
+ Grønneberg, S., & Hjort, N. L. (2014). The copula information criteria.
77
+ Scandinavian Journal of Statistics, 41(2), 436-459.
78
+
79
+ Examples
80
+ --------
81
+ >>> table = fs.copcompare(uh)
82
+ >>> print(table.round(2))
83
+ """
84
+ u = conversions.alToArray(udata)
85
+ if u.ndim != 2 or u.shape[1] < 2:
86
+ raise ValueError("udata must have at least two columns (variables)")
87
+ (M, K) = u.shape
88
+ npair = K * (K - 1) // 2
89
+
90
+ def row(name, parameters, n_params, loglik):
91
+ return {"copula": name,
92
+ "parameters": parameters,
93
+ "n_params": n_params,
94
+ "loglik": loglik,
95
+ "AIC": 2.0 * n_params - 2.0 * loglik,
96
+ "BIC": n_params * math.log(M) - 2.0 * loglik}
97
+
98
+ cg = CopulaGauss(u)
99
+ rho_g = cg.rho.to_numpy()
100
+ ct = CopulaStudent(u)
101
+ rho_t = ct.rho.to_numpy()
102
+ cc = CopulaClayton(u)
103
+ cgu = CopulaGumbel(u)
104
+ cf = CopulaFrank(u)
105
+ uc = copfit._clipu(u)
106
+
107
+ rows = [
108
+ row("Gaussian", _rho_label(rho_g), npair,
109
+ copfit.gauss_loglik(rho_g, u)),
110
+ row("Student", f"{_rho_label(rho_t)}, nu={_fmt(ct.nu)}", npair + 1,
111
+ copfit.student_loglik(rho_t, ct.nu, u)),
112
+ row("Clayton", f"theta={_fmt(cc.theta)}", 1,
113
+ copfit.clayton_loglik(cc.theta, uc)),
114
+ row("Gumbel", f"theta={_fmt(cgu.theta)}", 1,
115
+ copfit.gumbel_loglik(cgu.theta, uc)),
116
+ row("Frank", f"theta={_fmt(cf.theta)}", 1,
117
+ copfit.frank_loglik(cf.theta, uc)),
118
+ ]
119
+ table = pd.DataFrame(rows).set_index("copula")
120
+ return table.sort_values("BIC")
@@ -41,9 +41,13 @@ THETA_MIN_FRANK = 1e-6
41
41
  # largest admissible theta (tau approx. 0.96-0.99 depending on family)
42
42
  _THETA_MAX = 100.0
43
43
 
44
- # search bounds for the Student's t degrees of freedom
44
+ # search bounds for the Student's t degrees of freedom. An optimum within
45
+ # _NU_GAUSS_FRAC of the ceiling is reported as nu = inf (the Gaussian
46
+ # copula with the same correlation matrix): the profile likelihood is flat
47
+ # there and the ceiling itself is an artifact of the search, not an estimate
45
48
  _NU_MIN = 2.0
46
49
  _NU_MAX = 200.0
50
+ _NU_GAUSS_FRAC = 0.99
47
51
 
48
52
 
49
53
  def _clipu(u: np.ndarray) -> np.ndarray:
@@ -135,12 +139,45 @@ def _eulerian(n: int) -> list:
135
139
  return row
136
140
 
137
141
 
142
+ def _log_expm1(x: np.ndarray) -> np.ndarray:
143
+ # log(exp(x) - 1) for x > 0, without overflow for large x
144
+ x = np.asarray(x, dtype=float)
145
+ small = np.log(np.expm1(np.minimum(x, 30.0)))
146
+ large = x + np.log1p(-np.exp(-np.maximum(x, 30.0)))
147
+ return np.where(x > 30.0, large, small)
148
+
149
+
150
+ def _frank_loglik_neg2(theta: float, u: np.ndarray) -> float:
151
+ # total bivariate Frank log-density for theta < 0. The density is the
152
+ # same expression as for theta > 0 (Nelsen, 2006, table 4.1, family 5);
153
+ # with s = -theta > 0 it reads, every factor positive,
154
+ # c(u, v) = s (e^s - 1) e^{s(u+v)}
155
+ # / [(e^s - 1) + (e^{su} - 1)(e^{sv} - 1)]^2,
156
+ # which is evaluated in log space
157
+ s = -theta
158
+ (M, d) = u.shape
159
+ log_es1 = float(_log_expm1(np.array(s)))
160
+ log_den = np.logaddexp(log_es1,
161
+ _log_expm1(s * u[:, 0]) + _log_expm1(s * u[:, 1]))
162
+ return float(M * (math.log(s) + log_es1) + s * np.sum(u)
163
+ - 2.0 * np.sum(log_den))
164
+
165
+
138
166
  def frank_loglik(theta: float, u: np.ndarray) -> float:
139
- # total Frank copula log-density over the rows of u, for theta > 0:
167
+ # total Frank copula log-density over the rows of u. theta = 0 is the
168
+ # independence limit (log-density zero); theta < 0 is admissible for two
169
+ # variables only. For theta > 0 and any d:
140
170
  # log c = (d-1) log(theta) + log Li_{-(d-1)}(z)
141
171
  # + sum_j [-theta u_j - log(1 - exp(-theta u_j))]
142
172
  # with log z = sum_j log(1 - exp(-theta u_j)) - (d-1) log(1 - exp(-theta))
143
173
  # and Li_{-n}(z) evaluated via Eulerian numbers, in log space
174
+ if theta == 0.0:
175
+ return 0.0
176
+ if theta < 0.0:
177
+ if u.shape[1] != 2:
178
+ raise ValueError("the Frank copula with theta < 0 (negative "
179
+ "dependence) is defined only for two variables")
180
+ return _frank_loglik_neg2(theta, u)
144
181
  (M, d) = u.shape
145
182
  n = d - 1
146
183
  log1me_u = np.log(-np.expm1(-theta * u)) # log(1 - exp(-theta u))
@@ -160,11 +197,72 @@ def _debye1(x: float) -> float:
160
197
  # Debye function of order 1: (1/x) * integral_0^x t/(e^t - 1) dt
161
198
  if x < 1e-8:
162
199
  return 1.0 - x / 4.0
200
+ if x > 200.0:
201
+ # beyond the quadrature's range (never reached by the fitting code):
202
+ # the integrand's mass lies below t ~ 40, so the integral is pi^2/6
203
+ # less the tail integral_x^inf t/(e^t - 1) dt = (x + 1) e^-x (to
204
+ # within e^-2x). This agrees with the quadrature to one ulp at
205
+ # x = 200 and stays finite where expm1 would overflow
206
+ return (math.pi ** 2 / 6.0 - (x + 1.0) * math.exp(-x)) / x
163
207
  val = integrate.quad(lambda t: t / math.expm1(t) if t > 0.0 else 1.0,
164
208
  0.0, x)[0]
165
209
  return val / x
166
210
 
167
211
 
212
+ # Bernoulli numbers B_2, B_4, ..., B_14, for the small-theta series of
213
+ # Frank's Kendall's tau
214
+ _BERNOULLI_EVEN = (1.0 / 6.0, -1.0 / 30.0, 1.0 / 42.0, -1.0 / 30.0,
215
+ 5.0 / 66.0, -691.0 / 2730.0, 7.0 / 6.0)
216
+
217
+
218
+ def frank_tau(theta: float) -> float:
219
+ # Kendall's tau of the Frank copula, tau = 1 - (4/theta)(1 - D_1(theta)),
220
+ # an odd function of theta with tau(0) = 0 (Nelsen, 2006, eq. 5.1.5).
221
+ # Below |theta| = 0.5 the direct formula loses digits to cancellation
222
+ # (1 - D_1 ~ theta/4), so the power series
223
+ # tau = 4 sum_k B_2k theta^(2k-1) / ((2k+1)(2k)!)
224
+ # = theta/9 - theta^3/900 + theta^5/52920 - ...
225
+ # is used there; its truncation error is below 1e-16 at |theta| = 0.5
226
+ s = abs(float(theta))
227
+ if s == 0.0:
228
+ return 0.0
229
+ if s <= 0.5:
230
+ t = 4.0 * sum(b * s ** (2 * k - 1)
231
+ / ((2 * k + 1) * math.factorial(2 * k))
232
+ for k, b in enumerate(_BERNOULLI_EVEN, start=1))
233
+ else:
234
+ t = 1.0 - (4.0 / s) * (1.0 - _debye1(s))
235
+ return math.copysign(t, theta)
236
+
237
+
238
+ def frank_theta_from_tau(tau: float) -> float:
239
+ # exact inverse of frank_tau for tau in (-1, 0) or (0, 1): theta takes
240
+ # the sign of tau, and |theta| is found by bracketed root-finding on
241
+ # |tau|. tau(theta) is concave with slope 1/9 at the origin, so
242
+ # theta = 9 |tau| is a lower bracket; the upper bracket is doubled until
243
+ # it overshoots. Unlike frank_theta0 (the fitting start value, which
244
+ # clamps tau at 0.96 and theta at _THETA_MAX) nothing is clamped here
245
+ t = abs(float(tau))
246
+ if not (0.0 < t < 1.0):
247
+ raise ValueError("Kendall's tau for a Frank copula must lie strictly "
248
+ "between -1 and 1 and be nonzero (tau = 0 is "
249
+ "independence, which the Frank copula reaches only "
250
+ f"as theta -> 0); got {tau}")
251
+
252
+ def f(th):
253
+ return frank_tau(th) - t
254
+
255
+ lo = 9.0 * t
256
+ hi = max(2.0 * lo, 1.0)
257
+ while f(hi) < 0.0:
258
+ hi *= 2.0
259
+ if hi > 1e300:
260
+ raise ValueError(f"cannot represent a Frank copula with "
261
+ f"Kendall's tau {tau}")
262
+ theta = optimize.brentq(f, lo, hi, xtol=1e-14, maxiter=500)
263
+ return math.copysign(theta, tau)
264
+
265
+
168
266
  def clayton_theta0(tau: float) -> float:
169
267
  # Kendall's-tau inversion for Clayton: theta = 2 tau / (1 - tau)
170
268
  t = min(tau, 0.97)
@@ -257,9 +355,19 @@ def fit_gumbel(u: np.ndarray) -> Tuple[float, float, bool]:
257
355
 
258
356
 
259
357
  def fit_frank(u: np.ndarray) -> Tuple[float, float, bool]:
260
- # fit Frank theta; returns (theta, mean pairwise tau, clamped flag)
358
+ # fit Frank theta; returns (theta, mean pairwise tau, clamped flag).
359
+ # With two variables negative dependence is admissible (theta < 0): the
360
+ # log-likelihood is mirrored so that the positive-side machinery (start
361
+ # value, bracket, floor and ceiling) applies to -theta. With more
362
+ # variables the Frank copula exists only for theta > 0, and non-positive
363
+ # dependence is clamped to the floor
261
364
  uc = _clipu(u)
262
365
  tb = taubar(uc)
366
+ if tb < 0.0 and uc.shape[1] == 2:
367
+ theta0 = frank_theta0(-tb)
368
+ theta = _fit_theta(lambda th, uu: frank_loglik(-th, uu), uc, theta0,
369
+ THETA_MIN_FRANK, _THETA_MAX)
370
+ return (-theta, tb, False)
263
371
  if tb <= 0.0:
264
372
  return (THETA_MIN_FRANK, tb, True)
265
373
  theta0 = frank_theta0(tb)
@@ -267,12 +375,43 @@ def fit_frank(u: np.ndarray) -> Tuple[float, float, bool]:
267
375
  return (theta, tb, False)
268
376
 
269
377
 
270
- def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float]:
271
- # fit a Student's t copula; returns (correlation matrix, deg. of freedom).
378
+ def gauss_loglik(R: np.ndarray, u: np.ndarray) -> float:
379
+ # total Gaussian copula log-density over the rows of u for the
380
+ # correlation matrix R:
381
+ # log c(u) = -0.5 log|R| - 0.5 z' (R^-1 - I) z, with z = Phi^-1(u)
382
+ uc = _clipu(u)
383
+ z = stats.norm.ppf(uc)
384
+ (M, d) = z.shape
385
+ (sign, logdet) = np.linalg.slogdet(R)
386
+ if sign <= 0:
387
+ raise ValueError("R must be positive definite")
388
+ quad = np.einsum("ij,jk,ik->i", z, np.linalg.inv(R) - np.eye(d), z)
389
+ return float(-0.5 * M * logdet - 0.5 * np.sum(quad))
390
+
391
+
392
+ def student_loglik(R: np.ndarray, nu: float, u: np.ndarray) -> float:
393
+ # total Student's t copula log-density over the rows of u for the
394
+ # correlation matrix R and degrees of freedom nu:
395
+ # log c(u) = log t_{nu,R}(x) - sum_k log t_nu(x_k), with x = t_nu^-1(u).
396
+ # nu = inf is the Gaussian copula with the same R
397
+ if math.isinf(nu):
398
+ return gauss_loglik(R, u)
399
+ uc = _clipu(u)
400
+ x = stats.t.ppf(uc, df=nu)
401
+ d = R.shape[0]
402
+ joint = stats.multivariate_t(loc=np.zeros(d), shape=R, df=nu).logpdf(x)
403
+ return float(np.sum(joint) - np.sum(stats.t.logpdf(x, df=nu)))
404
+
405
+
406
+ def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float, float]:
407
+ # fit a Student's t copula; returns (correlation matrix, deg. of freedom,
408
+ # log-likelihood gain of the fitted nu over the Gaussian limit nu = inf
409
+ # at the same correlation matrix).
272
410
  # stage 1: pairwise Kendall's tau -> rho_ij = sin(pi tau_ij / 2),
273
411
  # corrected to the nearest positive definite correlation matrix.
274
412
  # stage 2: profile maximum pseudo-likelihood for nu, searching on a
275
- # log scale (the likelihood is flat in nu near the Gaussian limit)
413
+ # log scale (the likelihood is flat in nu near the Gaussian limit). An
414
+ # optimum at the search ceiling is reported as nu = inf, with zero gain
276
415
  uc = _clipu(u)
277
416
  R = np.sin(0.5 * np.pi * taumatrix(uc))
278
417
  np.fill_diagonal(R, 1.0)
@@ -281,17 +420,12 @@ def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float]:
281
420
  R = R / np.outer(s, s) # renormalize to unit diagonal
282
421
  np.fill_diagonal(R, 1.0)
283
422
 
284
- mvt_dim = R.shape[0]
285
-
286
423
  def negll(lognu):
287
424
  nu = math.exp(lognu)
288
425
  try:
289
426
  with np.errstate(divide="ignore", invalid="ignore",
290
427
  over="ignore"):
291
- x = stats.t.ppf(uc, df=nu)
292
- ll = (stats.multivariate_t(loc=np.zeros(mvt_dim), shape=R,
293
- df=nu).logpdf(x).sum()
294
- - stats.t.logpdf(x, df=nu).sum())
428
+ ll = student_loglik(R, nu, uc)
295
429
  except Exception:
296
430
  return np.inf
297
431
  return -ll if np.isfinite(ll) else np.inf
@@ -301,4 +435,8 @@ def fit_student(u: np.ndarray) -> Tuple[np.ndarray, float]:
301
435
  math.log(_NU_MAX)),
302
436
  method="bounded")
303
437
  nu = float(math.exp(res.x)) if res.success else _NU_MAX
304
- return (R, nu)
438
+ if nu > _NU_GAUSS_FRAC * _NU_MAX:
439
+ return (R, math.inf, 0.0)
440
+ ll_gauss = gauss_loglik(R, uc)
441
+ gain = -negll(math.log(nu)) - ll_gauss
442
+ return (R, nu, float(gain) if np.isfinite(gain) else math.nan)
@@ -12,18 +12,55 @@ from typing import Optional
12
12
  import inspect
13
13
 
14
14
 
15
- def _get_arg_count(func):
16
- sig = inspect.signature(func)
17
- params = sig.parameters.values()
18
-
19
- # Count only parameters that are positional or keyword
20
- # (excluding *args and **kwargs)
21
- return sum(
22
- 1 for p in params
23
- if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
24
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
25
- inspect.Parameter.KEYWORD_ONLY)
26
- )
15
+ def _stepf_arity(func):
16
+ # how many positional arguments simulate passes to the user's trial or
17
+ # step function: 1 (the draw generator) or 2 (the generator and the
18
+ # per-trial history). The rule, stated in the simulate docstring:
19
+ # * the first parameter receives the generator;
20
+ # * a second parameter receives the history if it has no default or
21
+ # is named 'hist'. A second parameter with a default and another
22
+ # name is a setting (e.g., 'scale=1.0') left at its default, and the
23
+ # function is called with the generator alone;
24
+ # * any further parameters must have defaults; keyword-only
25
+ # parameters (as functools.partial produces) must have defaults;
26
+ # * a parameter named 'hist' anywhere but second is ambiguous.
27
+ # Formerly every parameter was counted, so 'def f(ugen, scale=1.0)'
28
+ # received the history object as 'scale' and failed far from the cause
29
+ P = inspect.Parameter
30
+ try:
31
+ params = list(inspect.signature(func).parameters.values())
32
+ except (TypeError, ValueError):
33
+ raise ValueError('"f" must be a function whose signature can be '
34
+ 'inspected') from None
35
+ positional = [p for p in params
36
+ if p.kind in (P.POSITIONAL_ONLY, P.POSITIONAL_OR_KEYWORD)]
37
+ required_kw = [p.name for p in params
38
+ if p.kind is P.KEYWORD_ONLY and p.default is P.empty]
39
+ if required_kw:
40
+ raise ValueError(f'"f" has keyword-only parameter(s) without a '
41
+ f'default ({", ".join(required_kw)}); simulate '
42
+ f'supplies only the draw generator and the history, '
43
+ f'positionally. Bind such parameters with '
44
+ f'functools.partial or give them defaults')
45
+ if not positional:
46
+ raise ValueError('"f" must take the draw generator as its first '
47
+ 'parameter')
48
+ required = [p.name for p in positional if p.default is P.empty]
49
+ if len(required) > 2:
50
+ raise ValueError(f'"f" has {len(required)} parameters without '
51
+ f'defaults ({", ".join(required)}); simulate '
52
+ f'supplies at most two: the draw generator and the '
53
+ f'history. Give the others defaults or bind them '
54
+ f'with functools.partial')
55
+ if any(p.name == "hist" and i != 1 for i, p in enumerate(positional)):
56
+ raise ValueError('a parameter of "f" named "hist" receives the '
57
+ 'per-trial history and must therefore be the second '
58
+ 'parameter')
59
+ if len(required) == 2:
60
+ return 2
61
+ if len(positional) >= 2 and positional[1].name == "hist":
62
+ return 2
63
+ return 1
27
64
 
28
65
 
29
66
  def _checkhist0(hist0):
@@ -197,6 +234,17 @@ def simulate(f: Callable[[Generator[int, float, None],
197
234
  times `f` calls ``next(ugen)`` must be the same in every trial and
198
235
  step, because draws are pre-allocated across trials for stratified
199
236
  sampling.
237
+
238
+ Which parameters receive what is decided from the signature of `f`:
239
+ the first parameter receives the generator; a second parameter
240
+ receives the history if it has no default or is named ``hist``,
241
+ whereas a second parameter with a default and another name (for
242
+ example ``scale=1.0``) is treated as a setting, left at its default,
243
+ and `f` is called with the generator alone. Any further parameters
244
+ must have defaults, as must keyword-only parameters (which is what
245
+ :func:`functools.partial` produces when binding a scenario
246
+ parameter by name). A parameter named ``hist`` in any position but
247
+ the second is rejected as ambiguous.
200
248
  ntrials : int, optional
201
249
  The number of trials to perform. Default is 500.
202
250
  nsteps : int, optional
@@ -234,14 +282,37 @@ def simulate(f: Callable[[Generator[int, float, None],
234
282
  Raises
235
283
  ------
236
284
  ValueError
237
- If `hist0` is malformed, `f` is not callable or takes more than two
238
- arguments, `f` does not return a dict keyed by strings, or `sampling`
239
- is not one of 'lh' or 'mc'.
285
+ If `hist0` is malformed; if `f` is not callable, has more than two
286
+ parameters without defaults, has a keyword-only parameter without a
287
+ default, or has a parameter named ``hist`` that is not its second;
288
+ if `f` does not return a dict keyed by strings; or if `sampling` is
289
+ not one of 'lh' or 'mc'.
240
290
  RuntimeError
241
291
  If `f` consumes a different number of draws in some trial or step
242
292
  than it did when first probed.
243
293
  funcsim.rdarrays.MissingValue
244
294
  If `f` recalls a lagged value that is not available.
295
+
296
+ Notes
297
+ -----
298
+ **Draw allocation and common random numbers.** Before any trial runs,
299
+ the draws are allocated as one column of `ntrials` values per uniform
300
+ that `f` consumes over all steps of a trial, generated in consumption
301
+ order from a single :class:`numpy.random.Generator` seeded with `seed`
302
+ (stratified across trials when `sampling` is ``'lh'``). The j-th
303
+ uniform consumed in trial r therefore depends only on `seed`, `ntrials`,
304
+ `sampling`, j, and r: not on how many uniforms `f` consumes in total,
305
+ on what `f` computes from them, or on `multi`. This is a documented
306
+ guarantee. Its consequence is common random numbers across scenarios:
307
+ two trial functions that consume K and K + 1 uniforms (with `nsteps` of
308
+ 1) receive identical first K uniforms in every trial under the same
309
+ `seed` and `ntrials`, so differences in their outputs are due to the
310
+ extra draw and the model alone. With several steps, step s of trial r
311
+ receives uniforms sK + 1 through (s + 1)K of that trial's stream, where
312
+ K is the number consumed per step; two step functions with different
313
+ per-step counts therefore share their draws only in the first step.
314
+ With `stdnorm` True the same statements hold for the standard normal
315
+ draws, which are an elementwise transform of the uniforms.
245
316
  """
246
317
  if hist0 is not None:
247
318
  _checkhist0(hist0)
@@ -276,16 +347,13 @@ def simulate(f: Callable[[Generator[int, float, None],
276
347
  if not isinstance(f, Callable):
277
348
  raise ValueError('"f" must be a callable function')
278
349
 
279
- # infer number of arguments in 'f'. If it takes only a single arg, wrap it
280
- # in an outer func that takes "hist" as a second arg
281
- numb_f_args = _get_arg_count(f)
282
- if numb_f_args == 1:
283
- stepf = functools.partial(_stepf_1arg, f)
284
- elif numb_f_args == 2:
350
+ # decide from the signature of 'f' whether it takes the history as a
351
+ # second argument; if not, wrap it in an outer func that does
352
+ if _stepf_arity(f) == 2:
285
353
  stepf = f
286
354
  else:
287
- raise ValueError('"f" should take two arguments at most')
288
-
355
+ stepf = functools.partial(_stepf_1arg, f)
356
+
289
357
  # indexes for the final output xr.DataArray
290
358
  varNames = hist0.indexes['variables']
291
359
  namePositionsPrelim = {nm: i for i, nm in enumerate(varNames)}