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.
@@ -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)
@@ -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
+ )