pyAssetCorr 0.1.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.
- pyassetcorr/__init__.py +80 -0
- pyassetcorr/_accel.py +109 -0
- pyassetcorr/_quadrature.py +126 -0
- pyassetcorr/_vasicek.py +94 -0
- pyassetcorr/mle.py +199 -0
- pyassetcorr/moments.py +224 -0
- pyassetcorr/multicohort.py +337 -0
- pyassetcorr/overlap.py +219 -0
- pyassetcorr/results.py +190 -0
- pyassetcorr/simulate.py +125 -0
- pyassetcorr-0.1.1.dist-info/METADATA +161 -0
- pyassetcorr-0.1.1.dist-info/RECORD +14 -0
- pyassetcorr-0.1.1.dist-info/WHEEL +4 -0
- pyassetcorr-0.1.1.dist-info/licenses/LICENSE +21 -0
pyassetcorr/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""pyAssetCorr -- asset-correlation estimation under the Vasicek model.
|
|
2
|
+
|
|
3
|
+
Estimate intra-cohort and inter-cohort asset correlation from historical
|
|
4
|
+
default data using method-of-moments and maximum-likelihood estimators, with
|
|
5
|
+
standard errors, information criteria, likelihood-ratio tests, and tools for
|
|
6
|
+
overlapping multi-year cohorts.
|
|
7
|
+
|
|
8
|
+
Clean-room implementation from the published equations; see ``docs/USER_GUIDE.md``
|
|
9
|
+
and the per-function docstrings for formulas and references.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._accel import HAS_NUMBA
|
|
15
|
+
from .mle import intra_mle
|
|
16
|
+
from .moments import intra_amm, intra_fmm, intra_jdp1, intra_jdp2
|
|
17
|
+
from .multicohort import (
|
|
18
|
+
multi_cohort_mle,
|
|
19
|
+
multi_cohort_single_factor,
|
|
20
|
+
multi_cohort_uniform,
|
|
21
|
+
)
|
|
22
|
+
from .overlap import (
|
|
23
|
+
GroupAverageResult,
|
|
24
|
+
group_average,
|
|
25
|
+
nonoverlap_subsample,
|
|
26
|
+
partition_groups,
|
|
27
|
+
)
|
|
28
|
+
from .results import LRTestResult, MLEResult, MomentResult, aic, bic, lr_test
|
|
29
|
+
from .simulate import (
|
|
30
|
+
simulate_default_series,
|
|
31
|
+
simulate_multi_cohort,
|
|
32
|
+
simulate_overlapping_series,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.1"
|
|
36
|
+
|
|
37
|
+
# AssetCorr-style aliases for discoverability.
|
|
38
|
+
intraAMM = intra_amm
|
|
39
|
+
intraFMM = intra_fmm
|
|
40
|
+
intraJDP1 = intra_jdp1
|
|
41
|
+
intraJDP2 = intra_jdp2
|
|
42
|
+
intraMLE = intra_mle
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"__version__",
|
|
46
|
+
"HAS_NUMBA",
|
|
47
|
+
# moment estimators
|
|
48
|
+
"intra_amm",
|
|
49
|
+
"intra_fmm",
|
|
50
|
+
"intra_jdp1",
|
|
51
|
+
"intra_jdp2",
|
|
52
|
+
# mle
|
|
53
|
+
"intra_mle",
|
|
54
|
+
# multi-cohort
|
|
55
|
+
"multi_cohort_mle",
|
|
56
|
+
"multi_cohort_uniform",
|
|
57
|
+
"multi_cohort_single_factor",
|
|
58
|
+
# overlap handling
|
|
59
|
+
"group_average",
|
|
60
|
+
"partition_groups",
|
|
61
|
+
"nonoverlap_subsample",
|
|
62
|
+
"GroupAverageResult",
|
|
63
|
+
# simulation
|
|
64
|
+
"simulate_default_series",
|
|
65
|
+
"simulate_multi_cohort",
|
|
66
|
+
"simulate_overlapping_series",
|
|
67
|
+
# results & inference
|
|
68
|
+
"MomentResult",
|
|
69
|
+
"MLEResult",
|
|
70
|
+
"LRTestResult",
|
|
71
|
+
"aic",
|
|
72
|
+
"bic",
|
|
73
|
+
"lr_test",
|
|
74
|
+
# aliases
|
|
75
|
+
"intraAMM",
|
|
76
|
+
"intraFMM",
|
|
77
|
+
"intraJDP1",
|
|
78
|
+
"intraJDP2",
|
|
79
|
+
"intraMLE",
|
|
80
|
+
]
|
pyassetcorr/_accel.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Optional numba acceleration with a pure-NumPy fallback.
|
|
2
|
+
|
|
3
|
+
The single hot kernel shared by the likelihood code is the conditional
|
|
4
|
+
log-likelihood of a binomial observation summed over Gauss-Hermite nodes. We
|
|
5
|
+
expose one function, :func:`gh_loglik`, with two interchangeable
|
|
6
|
+
implementations selected at import time:
|
|
7
|
+
|
|
8
|
+
* a vectorised NumPy version (always available), and
|
|
9
|
+
* a numba-jitted version (used when ``numba`` imports successfully).
|
|
10
|
+
|
|
11
|
+
Both must return identical values; ``tests/test_mle.py`` asserts parity when
|
|
12
|
+
numba is present. The local interpreter may be too new for numba, in which
|
|
13
|
+
case the NumPy path is exercised transparently.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from scipy.special import gammaln
|
|
20
|
+
|
|
21
|
+
__all__ = ["gh_loglik", "HAS_NUMBA"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _log_binom_coeff(d, n):
|
|
25
|
+
"""log C(n, d) via log-gamma; works on scalars or arrays."""
|
|
26
|
+
return gammaln(n + 1.0) - gammaln(d + 1.0) - gammaln(n - d + 1.0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gh_loglik_numpy(d, n, cond_pd, log_weights):
|
|
30
|
+
"""Total log-likelihood over independent periods (NumPy).
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
d, n : ndarray, shape (T,)
|
|
35
|
+
Default counts and obligor counts per period.
|
|
36
|
+
cond_pd : ndarray, shape (T, M)
|
|
37
|
+
Conditional default probability ``g(z_i)`` for each period (rows) and
|
|
38
|
+
Gauss-Hermite node (columns).
|
|
39
|
+
log_weights : ndarray, shape (M,)
|
|
40
|
+
``log(w_i / sqrt(2 pi))`` from :func:`gauss_hermite`.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
float
|
|
45
|
+
``sum_t log( sum_i exp(log_weights_i) * Binom(d_t; n_t, g(z_i)) )``.
|
|
46
|
+
"""
|
|
47
|
+
d = d[:, None]
|
|
48
|
+
n = n[:, None]
|
|
49
|
+
p = np.clip(cond_pd, 1e-15, 1.0 - 1e-15)
|
|
50
|
+
log_pmf = _log_binom_coeff(d, n) + d * np.log(p) + (n - d) * np.log1p(-p)
|
|
51
|
+
log_terms = log_pmf + log_weights[None, :]
|
|
52
|
+
# logsumexp over nodes, then sum over periods.
|
|
53
|
+
m = np.max(log_terms, axis=1, keepdims=True)
|
|
54
|
+
per_period = m[:, 0] + np.log(np.sum(np.exp(log_terms - m), axis=1))
|
|
55
|
+
return float(np.sum(per_period))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
try: # pragma: no cover - exercised only where numba is installed
|
|
59
|
+
import math
|
|
60
|
+
|
|
61
|
+
import numba
|
|
62
|
+
|
|
63
|
+
@numba.njit(cache=True)
|
|
64
|
+
def _gh_loglik_numba(d, n, cond_pd, log_weights):
|
|
65
|
+
T = d.shape[0]
|
|
66
|
+
M = log_weights.shape[0]
|
|
67
|
+
total = 0.0
|
|
68
|
+
for t in range(T):
|
|
69
|
+
lognc = (
|
|
70
|
+
math.lgamma(n[t] + 1.0)
|
|
71
|
+
- math.lgamma(d[t] + 1.0)
|
|
72
|
+
- math.lgamma(n[t] - d[t] + 1.0)
|
|
73
|
+
)
|
|
74
|
+
max_lt = -1e300
|
|
75
|
+
tmp = np.empty(M)
|
|
76
|
+
for i in range(M):
|
|
77
|
+
p = cond_pd[t, i]
|
|
78
|
+
if p < 1e-15:
|
|
79
|
+
p = 1e-15
|
|
80
|
+
elif p > 1.0 - 1e-15:
|
|
81
|
+
p = 1.0 - 1e-15
|
|
82
|
+
lt = (
|
|
83
|
+
lognc
|
|
84
|
+
+ d[t] * math.log(p)
|
|
85
|
+
+ (n[t] - d[t]) * math.log(1.0 - p)
|
|
86
|
+
+ log_weights[i]
|
|
87
|
+
)
|
|
88
|
+
tmp[i] = lt
|
|
89
|
+
if lt > max_lt:
|
|
90
|
+
max_lt = lt
|
|
91
|
+
s = 0.0
|
|
92
|
+
for i in range(M):
|
|
93
|
+
s += math.exp(tmp[i] - max_lt)
|
|
94
|
+
total += max_lt + math.log(s)
|
|
95
|
+
return total
|
|
96
|
+
|
|
97
|
+
HAS_NUMBA = True
|
|
98
|
+
|
|
99
|
+
def gh_loglik(d, n, cond_pd, log_weights):
|
|
100
|
+
return _gh_loglik_numba(
|
|
101
|
+
np.ascontiguousarray(d, dtype=np.float64),
|
|
102
|
+
np.ascontiguousarray(n, dtype=np.float64),
|
|
103
|
+
np.ascontiguousarray(cond_pd, dtype=np.float64),
|
|
104
|
+
np.ascontiguousarray(log_weights, dtype=np.float64),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
except Exception: # numba unavailable or incompatible interpreter
|
|
108
|
+
HAS_NUMBA = False
|
|
109
|
+
gh_loglik = _gh_loglik_numpy
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Gauss-Hermite quadrature helpers for marginalising the systematic factor.
|
|
2
|
+
|
|
3
|
+
The Vasicek likelihood integrates the conditional binomial against a standard
|
|
4
|
+
normal density. Using the *probabilists'* Hermite polynomials
|
|
5
|
+
(``numpy.polynomial.hermite_e``) gives nodes ``z_i`` and weights ``w_i`` such
|
|
6
|
+
that
|
|
7
|
+
|
|
8
|
+
E_{Z~N(0,1)}[f(Z)] = sum_i (w_i / sqrt(2 pi)) * f(z_i)
|
|
9
|
+
|
|
10
|
+
with no change of variables required (the standard normal density is built in).
|
|
11
|
+
|
|
12
|
+
This is faster and more accurate than the Simpson-rule grid used by the
|
|
13
|
+
reference R implementation, and the same nodes/weights are reused across all
|
|
14
|
+
periods and bootstrap resamples, so they are cached.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from numpy.polynomial.hermite_e import hermegauss
|
|
23
|
+
from scipy.special import gammaln, logsumexp
|
|
24
|
+
|
|
25
|
+
from ._vasicek import pnorm, qnorm
|
|
26
|
+
|
|
27
|
+
__all__ = ["gauss_hermite", "DEFAULT_NODES", "adaptive_binom_loglik"]
|
|
28
|
+
|
|
29
|
+
DEFAULT_NODES = 32
|
|
30
|
+
|
|
31
|
+
_INV_SQRT_2PI = 1.0 / np.sqrt(2.0 * np.pi)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _std_normal_pdf(x):
|
|
35
|
+
return _INV_SQRT_2PI * np.exp(-0.5 * x * x)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lru_cache(maxsize=None)
|
|
39
|
+
def gauss_hermite(n: int = DEFAULT_NODES):
|
|
40
|
+
"""Return ``(nodes, log_weights)`` for standard-normal expectation.
|
|
41
|
+
|
|
42
|
+
``nodes`` are the abscissae ``z_i``; ``log_weights`` are
|
|
43
|
+
``log(w_i / sqrt(2 pi))`` so that ``sum_i exp(log_weights_i) f(z_i)``
|
|
44
|
+
approximates ``E[f(Z)]`` for ``Z ~ N(0, 1)``. Log-weights are returned
|
|
45
|
+
because the likelihood is evaluated in log-space via ``logsumexp``.
|
|
46
|
+
"""
|
|
47
|
+
nodes, weights = hermegauss(int(n))
|
|
48
|
+
log_weights = np.log(weights) - 0.5 * np.log(2.0 * np.pi)
|
|
49
|
+
return nodes, log_weights
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def adaptive_binom_loglik(d, n, c_eff, coef, denom, z, logw, newton_iter=80):
|
|
53
|
+
"""Adaptive (mode-centered) Gauss-Hermite log-likelihood of a binomial mixed
|
|
54
|
+
over a standard-normal latent factor ``t``.
|
|
55
|
+
|
|
56
|
+
Computes, elementwise over the broadcast of the inputs,
|
|
57
|
+
|
|
58
|
+
log integral phi(t) * Binom(d; n, Phi((c_eff - coef*t)/denom)) dt.
|
|
59
|
+
|
|
60
|
+
For large ``n`` the integrand is sharply peaked in ``t``; fixed-node
|
|
61
|
+
quadrature under-resolves it. Here the nodes are recentered on the
|
|
62
|
+
per-element integrand mode (a few damped Newton steps) and rescaled by the
|
|
63
|
+
local curvature (Laplace/Liu-Pierce adaptive GH), so a small base grid
|
|
64
|
+
integrates accurately at any ``n``.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
d, n, c_eff, coef, denom : array_like
|
|
69
|
+
Broadcastable. ``c_eff`` is the (possibly shifted) probit threshold,
|
|
70
|
+
``coef`` the latent-factor loading, ``denom`` the conditional scale.
|
|
71
|
+
Single-cohort Vasicek: ``c_eff=Phi^{-1}(pd)``, ``coef=sqrt(rho)``,
|
|
72
|
+
``denom=sqrt(1-rho)``.
|
|
73
|
+
z, logw : ndarray
|
|
74
|
+
Base probabilists' Gauss-Hermite nodes and log-weights from
|
|
75
|
+
:func:`gauss_hermite`.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
ndarray
|
|
80
|
+
Per-element log marginal likelihood (broadcast shape of the inputs).
|
|
81
|
+
"""
|
|
82
|
+
d = np.asarray(d, dtype=float)
|
|
83
|
+
n = np.asarray(n, dtype=float)
|
|
84
|
+
c_eff = np.asarray(c_eff, dtype=float)
|
|
85
|
+
coef = np.asarray(coef, dtype=float)
|
|
86
|
+
denom = np.asarray(denom, dtype=float)
|
|
87
|
+
shape = np.broadcast_shapes(d.shape, n.shape, c_eff.shape, coef.shape, denom.shape)
|
|
88
|
+
d = np.broadcast_to(d, shape)
|
|
89
|
+
n = np.broadcast_to(n, shape)
|
|
90
|
+
c_eff = np.broadcast_to(c_eff, shape)
|
|
91
|
+
coef = np.broadcast_to(coef, shape)
|
|
92
|
+
denom = np.broadcast_to(denom, shape)
|
|
93
|
+
k = -coef / denom
|
|
94
|
+
|
|
95
|
+
def derivs(mu):
|
|
96
|
+
u = (c_eff - coef * mu) / denom
|
|
97
|
+
g = np.clip(pnorm(u), 1e-12, 1.0 - 1e-12)
|
|
98
|
+
pu = _std_normal_pdf(u)
|
|
99
|
+
gp = pu * k
|
|
100
|
+
gpp = -u * pu * k * k
|
|
101
|
+
ratio = d / g - (n - d) / (1.0 - g)
|
|
102
|
+
ratiop = -d * gp / g ** 2 - (n - d) * gp / (1.0 - g) ** 2
|
|
103
|
+
f1 = -mu + ratio * gp
|
|
104
|
+
f2 = -1.0 + ratiop * gp + ratio * gpp
|
|
105
|
+
return f1, f2
|
|
106
|
+
|
|
107
|
+
mu = np.zeros(shape)
|
|
108
|
+
for _ in range(newton_iter):
|
|
109
|
+
f1, f2 = derivs(mu)
|
|
110
|
+
f2 = np.minimum(f2, -1e-6) # keep concave for a stable Newton step
|
|
111
|
+
mu = mu - np.clip(f1 / f2, -2.0, 2.0)
|
|
112
|
+
|
|
113
|
+
_, f2 = derivs(mu)
|
|
114
|
+
sigma = 1.0 / np.sqrt(np.clip(-f2, 1e-12, None))
|
|
115
|
+
|
|
116
|
+
t = mu[..., None] + sigma[..., None] * z # (..., M)
|
|
117
|
+
u = (c_eff[..., None] - coef[..., None] * t) / denom[..., None]
|
|
118
|
+
g = np.clip(pnorm(u), 1e-15, 1.0 - 1e-15)
|
|
119
|
+
log_coeff = gammaln(n + 1.0) - gammaln(d + 1.0) - gammaln(n - d + 1.0)
|
|
120
|
+
binom = (
|
|
121
|
+
log_coeff[..., None]
|
|
122
|
+
+ d[..., None] * np.log(g)
|
|
123
|
+
+ (n - d)[..., None] * np.log1p(-g)
|
|
124
|
+
)
|
|
125
|
+
log_term = logw + np.log(sigma)[..., None] + 0.5 * z ** 2 - 0.5 * t ** 2 + binom
|
|
126
|
+
return logsumexp(log_term, axis=-1)
|
pyassetcorr/_vasicek.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Core Vasicek one-factor model primitives.
|
|
2
|
+
|
|
3
|
+
The single-factor Gaussian copula (Vasicek) model assumes that, for an obligor
|
|
4
|
+
with unconditional default probability ``PD`` and asset correlation ``rho``, the
|
|
5
|
+
latent asset return is
|
|
6
|
+
|
|
7
|
+
A = sqrt(rho) * X + sqrt(1 - rho) * eps
|
|
8
|
+
|
|
9
|
+
with ``X`` the systematic factor and ``eps`` idiosyncratic, both standard
|
|
10
|
+
normal. The obligor defaults when ``A < Phi^{-1}(PD)``. Conditional on ``X``,
|
|
11
|
+
defaults are independent, so the conditional default probability is
|
|
12
|
+
|
|
13
|
+
g(x) = Phi( (Phi^{-1}(PD) - sqrt(rho) * x) / sqrt(1 - rho) ).
|
|
14
|
+
|
|
15
|
+
Reference
|
|
16
|
+
---------
|
|
17
|
+
Vasicek, O. (2002). *The Distribution of Loan Portfolio Value.* Risk.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from scipy.special import ndtr, ndtri
|
|
24
|
+
from scipy.stats import multivariate_normal
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"qnorm",
|
|
28
|
+
"pnorm",
|
|
29
|
+
"conditional_pd",
|
|
30
|
+
"bvn_cdf",
|
|
31
|
+
"EPS",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Numerical floor used to keep probabilities strictly inside (0, 1) so that
|
|
35
|
+
# Phi^{-1} stays finite.
|
|
36
|
+
EPS = 1e-12
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def qnorm(p):
|
|
40
|
+
"""Standard-normal quantile (inverse CDF), ``Phi^{-1}``."""
|
|
41
|
+
p = np.clip(p, EPS, 1.0 - EPS)
|
|
42
|
+
return ndtri(p)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pnorm(x):
|
|
46
|
+
"""Standard-normal CDF, ``Phi``."""
|
|
47
|
+
return ndtr(x)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def conditional_pd(x, pd, rho):
|
|
51
|
+
"""Conditional default probability ``g(x)`` given the systematic factor.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
x : array_like
|
|
56
|
+
Realisation(s) of the systematic factor.
|
|
57
|
+
pd : float
|
|
58
|
+
Unconditional probability of default.
|
|
59
|
+
rho : float
|
|
60
|
+
Asset correlation in ``[0, 1)``.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
ndarray
|
|
65
|
+
``Phi((Phi^{-1}(pd) - sqrt(rho) x) / sqrt(1 - rho))``.
|
|
66
|
+
"""
|
|
67
|
+
x = np.asarray(x, dtype=float)
|
|
68
|
+
rho = float(rho)
|
|
69
|
+
c = qnorm(pd)
|
|
70
|
+
denom = np.sqrt(max(1.0 - rho, EPS))
|
|
71
|
+
return pnorm((c - np.sqrt(max(rho, 0.0)) * x) / denom)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def bvn_cdf(a, b, rho):
|
|
75
|
+
"""Bivariate standard-normal CDF ``P(Z1 <= a, Z2 <= b)`` with correlation ``rho``.
|
|
76
|
+
|
|
77
|
+
This is the joint default probability ``BVN(Phi^{-1}(p), Phi^{-1}(p); rho)``
|
|
78
|
+
used by the method-of-moments estimators.
|
|
79
|
+
"""
|
|
80
|
+
rho = float(np.clip(rho, -1.0 + EPS, 1.0 - EPS))
|
|
81
|
+
cov = np.array([[1.0, rho], [rho, 1.0]])
|
|
82
|
+
return float(multivariate_normal.cdf([a, b], mean=[0.0, 0.0], cov=cov))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def bvn_pdf(a, b, rho):
|
|
86
|
+
"""Bivariate standard-normal density at ``(a, b)`` with correlation ``rho``.
|
|
87
|
+
|
|
88
|
+
Used for the analytic derivative ``d/drho BVN(a, b; rho) = phi_2(a, b; rho)``
|
|
89
|
+
(Plackett's identity), which appears in the delta-method standard errors.
|
|
90
|
+
"""
|
|
91
|
+
rho = float(np.clip(rho, -1.0 + EPS, 1.0 - EPS))
|
|
92
|
+
det = 1.0 - rho * rho
|
|
93
|
+
quad = (a * a - 2.0 * rho * a * b + b * b) / det
|
|
94
|
+
return float(np.exp(-0.5 * quad) / (2.0 * np.pi * np.sqrt(det)))
|
pyassetcorr/mle.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Single-cohort Vasicek-Binomial maximum-likelihood estimator.
|
|
2
|
+
|
|
3
|
+
For each period ``t`` the likelihood marginalises the systematic factor:
|
|
4
|
+
|
|
5
|
+
L_t(rho, PD) = integral Binom(d_t; n_t, g(x)) phi(x) dx,
|
|
6
|
+
|
|
7
|
+
with ``g`` the Vasicek conditional default probability. The integral is
|
|
8
|
+
evaluated by *adaptive* (mode-centered) Gauss-Hermite quadrature in log-space --
|
|
9
|
+
accurate at any ``n`` with a small node grid -- and the total log-likelihood
|
|
10
|
+
``sum_t log L_t`` is maximised. ``pd="plug_in"`` fixes ``PD = mean(d/n)`` and
|
|
11
|
+
optimises ``rho`` only; ``pd="joint"`` estimates ``PD`` as well.
|
|
12
|
+
|
|
13
|
+
Standard errors come from the inverse observed Fisher information (numerical
|
|
14
|
+
Hessian of the negative log-likelihood); with ``lag > 0`` a Newey-West HAC
|
|
15
|
+
sandwich is used for autocorrelated (overlapping-cohort) data.
|
|
16
|
+
|
|
17
|
+
References
|
|
18
|
+
----------
|
|
19
|
+
Gordy, M. & Heitfield, E. (2010). *Small-sample estimation of models of
|
|
20
|
+
portfolio credit risk.*
|
|
21
|
+
Duellmann, K. & Gehde-Trapp, M. (2004). *Probability of default estimation.*
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from scipy.optimize import minimize, minimize_scalar
|
|
28
|
+
|
|
29
|
+
from ._quadrature import DEFAULT_NODES, adaptive_binom_loglik, gauss_hermite
|
|
30
|
+
from .results import MLEResult, normal_ci
|
|
31
|
+
|
|
32
|
+
__all__ = ["intra_mle"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate(d, n):
|
|
36
|
+
d = np.asarray(d, dtype=float)
|
|
37
|
+
n = np.asarray(n, dtype=float)
|
|
38
|
+
if d.shape != n.shape or d.ndim != 1:
|
|
39
|
+
raise ValueError("d and n must be equal-length 1-D arrays")
|
|
40
|
+
if np.any(n <= 0) or np.any(d < 0) or np.any(d > n):
|
|
41
|
+
raise ValueError("require positive n and 0 <= d <= n")
|
|
42
|
+
return d, n
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _logL_periods(d, n, pd, rho, z, logw):
|
|
46
|
+
"""Per-period log-likelihood ``log L_t`` (shape ``(T,)``)."""
|
|
47
|
+
from ._vasicek import qnorm
|
|
48
|
+
|
|
49
|
+
rho = min(max(float(rho), 1e-9), 1.0 - 1e-9)
|
|
50
|
+
return adaptive_binom_loglik(
|
|
51
|
+
d, n, qnorm(pd), np.sqrt(rho), np.sqrt(1.0 - rho), z, logw
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _num_hessian(f, x, rel=1e-4):
|
|
56
|
+
x = np.asarray(x, dtype=float)
|
|
57
|
+
k = x.size
|
|
58
|
+
h = rel * (np.abs(x) + rel)
|
|
59
|
+
H = np.zeros((k, k))
|
|
60
|
+
for i in range(k):
|
|
61
|
+
for j in range(i, k):
|
|
62
|
+
xpp = x.copy(); xpp[i] += h[i]; xpp[j] += h[j]
|
|
63
|
+
xpm = x.copy(); xpm[i] += h[i]; xpm[j] -= h[j]
|
|
64
|
+
xmp = x.copy(); xmp[i] -= h[i]; xmp[j] += h[j]
|
|
65
|
+
xmm = x.copy(); xmm[i] -= h[i]; xmm[j] -= h[j]
|
|
66
|
+
H[i, j] = H[j, i] = (f(xpp) - f(xpm) - f(xmp) + f(xmm)) / (
|
|
67
|
+
4.0 * h[i] * h[j]
|
|
68
|
+
)
|
|
69
|
+
return H
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _score_series(periods_fn, theta, rel=1e-4):
|
|
73
|
+
"""Per-period score rows ``(T, k)`` by central differences."""
|
|
74
|
+
theta = np.asarray(theta, dtype=float)
|
|
75
|
+
k = theta.size
|
|
76
|
+
h = rel * (np.abs(theta) + rel)
|
|
77
|
+
cols = []
|
|
78
|
+
for i in range(k):
|
|
79
|
+
tp = theta.copy(); tp[i] += h[i]
|
|
80
|
+
tm = theta.copy(); tm[i] -= h[i]
|
|
81
|
+
cols.append((periods_fn(tp) - periods_fn(tm)) / (2.0 * h[i]))
|
|
82
|
+
return np.stack(cols, axis=1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hac_meat(S, lag):
|
|
86
|
+
"""Newey-West meat matrix from per-period score rows ``S`` (T x k)."""
|
|
87
|
+
T = S.shape[0]
|
|
88
|
+
B = S.T @ S
|
|
89
|
+
for z in range(1, min(lag, T - 1) + 1):
|
|
90
|
+
Gz = S[z:].T @ S[:-z]
|
|
91
|
+
B += (1.0 - z / T) * (Gz + Gz.T)
|
|
92
|
+
return B
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def intra_mle(d, n, *, pd="plug_in", nodes=DEFAULT_NODES, lag=0, ci=False, ci_level=0.95):
|
|
96
|
+
"""Single-cohort Vasicek-Binomial MLE.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
d, n : array_like
|
|
101
|
+
Per-period default counts and obligor counts (equal length).
|
|
102
|
+
pd : {"plug_in", "joint"}, default "plug_in"
|
|
103
|
+
``"plug_in"`` fixes ``PD = mean(d/n)`` and optimises ``rho`` only;
|
|
104
|
+
``"joint"`` estimates ``PD`` jointly with ``rho``.
|
|
105
|
+
nodes : int, default 32
|
|
106
|
+
Base Gauss-Hermite nodes for the adaptive quadrature. Because the
|
|
107
|
+
quadrature is mode-centered (adaptive), the estimate should already be
|
|
108
|
+
stable at this default; refit with a larger ``nodes`` and confirm
|
|
109
|
+
``rho``/``loglik`` are unchanged to verify convergence.
|
|
110
|
+
lag : int, default 0
|
|
111
|
+
HAC lag for an autocorrelation-robust sandwich SE (overlapping cohorts).
|
|
112
|
+
``lag=0`` uses the plain inverse observed information.
|
|
113
|
+
ci : bool, default False
|
|
114
|
+
Attach Wald confidence intervals.
|
|
115
|
+
ci_level : float, default 0.95
|
|
116
|
+
Confidence level.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
MLEResult
|
|
121
|
+
``params`` holds ``{"rho": ...}`` and, when ``pd="joint"``, ``"pd"``.
|
|
122
|
+
"""
|
|
123
|
+
d, n = _validate(d, n)
|
|
124
|
+
z, logw = gauss_hermite(nodes)
|
|
125
|
+
T = d.size
|
|
126
|
+
pbar = float(np.mean(d / n))
|
|
127
|
+
|
|
128
|
+
if pd == "plug_in":
|
|
129
|
+
def periods(theta):
|
|
130
|
+
return _logL_periods(d, n, pbar, theta[0], z, logw)
|
|
131
|
+
|
|
132
|
+
def neg(rho):
|
|
133
|
+
return -float(np.sum(_logL_periods(d, n, pbar, rho, z, logw)))
|
|
134
|
+
|
|
135
|
+
res = minimize_scalar(neg, bounds=(1e-6, 1.0 - 1e-6), method="bounded")
|
|
136
|
+
rho_hat = float(res.x)
|
|
137
|
+
loglik = -float(res.fun)
|
|
138
|
+
params = {"rho": rho_hat}
|
|
139
|
+
theta = np.array([rho_hat])
|
|
140
|
+
success = bool(res.success)
|
|
141
|
+
elif pd == "joint":
|
|
142
|
+
def periods(theta):
|
|
143
|
+
return _logL_periods(d, n, theta[1], theta[0], z, logw)
|
|
144
|
+
|
|
145
|
+
def neg_vec(th):
|
|
146
|
+
return -float(np.sum(periods(th)))
|
|
147
|
+
|
|
148
|
+
x0 = np.array([0.1, pbar])
|
|
149
|
+
res = minimize(
|
|
150
|
+
neg_vec, x0, method="L-BFGS-B",
|
|
151
|
+
bounds=[(1e-6, 1.0 - 1e-6), (1e-9, 1.0 - 1e-9)],
|
|
152
|
+
)
|
|
153
|
+
rho_hat, pd_hat = float(res.x[0]), float(res.x[1])
|
|
154
|
+
loglik = -float(res.fun)
|
|
155
|
+
params = {"rho": rho_hat, "pd": pd_hat}
|
|
156
|
+
theta = np.array([rho_hat, pd_hat])
|
|
157
|
+
success = bool(res.success)
|
|
158
|
+
else:
|
|
159
|
+
raise ValueError("pd must be 'plug_in' or 'joint'")
|
|
160
|
+
|
|
161
|
+
n_params = theta.size
|
|
162
|
+
keys = list(params.keys())
|
|
163
|
+
|
|
164
|
+
def neg_theta(th):
|
|
165
|
+
return -float(np.sum(periods(th)))
|
|
166
|
+
|
|
167
|
+
se = None
|
|
168
|
+
cov = None
|
|
169
|
+
ci_out = None
|
|
170
|
+
try:
|
|
171
|
+
A = _num_hessian(neg_theta, theta) # observed information
|
|
172
|
+
if lag > 0:
|
|
173
|
+
S = _score_series(periods, theta)
|
|
174
|
+
B = _hac_meat(S, lag)
|
|
175
|
+
Ainv = np.linalg.inv(A)
|
|
176
|
+
cov = Ainv @ B @ Ainv
|
|
177
|
+
else:
|
|
178
|
+
cov = np.linalg.inv(A)
|
|
179
|
+
diag = np.diag(cov)
|
|
180
|
+
if np.all(diag > 0):
|
|
181
|
+
se_vec = np.sqrt(diag)
|
|
182
|
+
se = {k: float(se_vec[i]) for i, k in enumerate(keys)}
|
|
183
|
+
if ci:
|
|
184
|
+
ci_out = {k: normal_ci(params[k], se[k], ci_level) for k in keys}
|
|
185
|
+
except np.linalg.LinAlgError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return MLEResult(
|
|
189
|
+
params=params,
|
|
190
|
+
loglik=loglik,
|
|
191
|
+
n_params=n_params,
|
|
192
|
+
n_obs=T,
|
|
193
|
+
method="intra_mle",
|
|
194
|
+
se=se,
|
|
195
|
+
cov=cov,
|
|
196
|
+
ci=ci_out,
|
|
197
|
+
pd=pd,
|
|
198
|
+
converged=success,
|
|
199
|
+
)
|