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.
- {funcsim-0.2.5/funcsim.egg-info → funcsim-0.2.7}/PKG-INFO +2 -2
- {funcsim-0.2.5 → funcsim-0.2.7}/README.md +1 -1
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/__init__.py +4 -2
- funcsim-0.2.7/funcsim/copcompare.py +120 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/copfit.py +151 -13
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/core.py +91 -23
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/dependence.py +766 -19
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/distfit.py +76 -10
- funcsim-0.2.7/funcsim/kde.py +247 -0
- {funcsim-0.2.5 → funcsim-0.2.7/funcsim.egg-info}/PKG-INFO +2 -2
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/SOURCES.txt +1 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/setup.py +1 -1
- funcsim-0.2.5/funcsim/kde.py +0 -183
- {funcsim-0.2.5 → funcsim-0.2.7}/LICENSE +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/MANIFEST.in +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/addist.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/conversions.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/cpt.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/cvmdist.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/ecdfgof.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/edf.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/eut.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/imanconover.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/ksdist.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/multicore.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/nearby.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/plotting.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/rdarrays.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/screen.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/shapiro.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/shrinkage.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/testsim.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/utests.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim/vect.py +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/dependency_links.txt +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/requires.txt +0 -0
- {funcsim-0.2.5 → funcsim-0.2.7}/funcsim.egg-info/top_level.txt +0 -0
- {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.
|
|
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.
|
|
51
|
+
The version string of the package (e.g., '0.2.7').
|
|
50
52
|
"""
|
|
51
|
-
return "0.2.
|
|
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
|
|
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
|
|
271
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
#
|
|
280
|
-
# in an outer func that
|
|
281
|
-
|
|
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
|
-
|
|
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)}
|