|
|
@@ -339,6 +339,126 @@ class LogisticPolyRegression:
|
|
|
|
|
|
return scipy.special.expit(F)
|
|
|
|
|
|
+ def get_x50(self, theta):
|
|
|
+ """Return the first predictor value at which the model equals 0.5.
|
|
|
+
|
|
|
+ Since ``expit(F) = 0.5`` exactly when ``F = 0``, ``x50`` is the
|
|
|
+ smallest real root of the polynomial logit
|
|
|
+
|
|
|
+ F(x) = sum_i beta_i(theta) x**i.
|
|
|
+
|
|
|
+ For a monotonic fitted model the root is unique. Defining "first" as
|
|
|
+ the smallest real root also makes the result unambiguous for an
|
|
|
+ unconstrained polynomial with several 0.5 crossings.
|
|
|
+
|
|
|
+ Parameters
|
|
|
+ ----------
|
|
|
+ theta
|
|
|
+ Model parameter vector of length ``degree + 1``.
|
|
|
+
|
|
|
+ Returns
|
|
|
+ -------
|
|
|
+ float
|
|
|
+ The smallest real solution of ``model(x, theta) = 0.5``.
|
|
|
+
|
|
|
+ Raises
|
|
|
+ ------
|
|
|
+ ValueError
|
|
|
+ If ``theta`` is invalid, the model never reaches 0.5 at a real
|
|
|
+ predictor value, or the model is identically 0.5 and hence has no
|
|
|
+ unique first crossing.
|
|
|
+ """
|
|
|
+ theta = np.asarray(theta, dtype=float)
|
|
|
+ expected_shape = (self.degree + 1,)
|
|
|
+ if theta.shape != expected_shape:
|
|
|
+ raise ValueError(
|
|
|
+ f"theta must have shape {expected_shape}; got {theta.shape}."
|
|
|
+ )
|
|
|
+ if not np.all(np.isfinite(theta)):
|
|
|
+ raise ValueError("theta must contain only finite values.")
|
|
|
+
|
|
|
+ beta = np.asarray(self.get_beta(theta), dtype=float)
|
|
|
+ nonzero = np.flatnonzero(beta != 0.0)
|
|
|
+
|
|
|
+ if nonzero.size == 0:
|
|
|
+ raise ValueError(
|
|
|
+ "x50 is not uniquely defined because the model equals 0.5 "
|
|
|
+ "for every x."
|
|
|
+ )
|
|
|
+
|
|
|
+ polynomial_degree = int(nonzero[-1])
|
|
|
+ if polynomial_degree == 0:
|
|
|
+ raise ValueError("The model does not reach 0.5 for any real x.")
|
|
|
+
|
|
|
+ coefficients = beta[:polynomial_degree + 1]
|
|
|
+
|
|
|
+ # Avoid the unnecessary loss of precision of a general polynomial
|
|
|
+ # root solver in the common linear-logistic case.
|
|
|
+ if polynomial_degree == 1:
|
|
|
+ return float(-coefficients[0] / coefficients[1])
|
|
|
+
|
|
|
+ roots = np.roots(coefficients[::-1])
|
|
|
+ real_roots = []
|
|
|
+ machine_tolerance = 100 * np.finfo(float).eps
|
|
|
+
|
|
|
+ for root in roots:
|
|
|
+ candidate = float(root.real)
|
|
|
+ root_scale = max(1.0, abs(candidate))
|
|
|
+
|
|
|
+ # Repeated real roots may acquire a small imaginary part in a
|
|
|
+ # numerical polynomial-root calculation. In that case, also
|
|
|
+ # accept the real component when its scaled polynomial residual
|
|
|
+ # is negligible.
|
|
|
+ residual = abs(
|
|
|
+ np.polynomial.polynomial.polyval(candidate, coefficients)
|
|
|
+ )
|
|
|
+ coefficient_scale = np.polynomial.polynomial.polyval(
|
|
|
+ abs(candidate), np.abs(coefficients)
|
|
|
+ )
|
|
|
+ small_imaginary_part = (
|
|
|
+ abs(root.imag) <= machine_tolerance * root_scale
|
|
|
+ )
|
|
|
+ small_residual = residual <= 1e-10 * max(
|
|
|
+ coefficient_scale, np.finfo(float).tiny
|
|
|
+ )
|
|
|
+
|
|
|
+ if small_imaginary_part or small_residual:
|
|
|
+ real_roots.append(candidate)
|
|
|
+
|
|
|
+ if not real_roots:
|
|
|
+ raise ValueError("The model does not reach 0.5 for any real x.")
|
|
|
+
|
|
|
+ return float(min(real_roots))
|
|
|
+
|
|
|
+ def get_s50(self, theta):
|
|
|
+ """Return the probability slope at the model's first 0.5 crossing.
|
|
|
+
|
|
|
+ If ``p(x) = expit(F(x))``, then
|
|
|
+
|
|
|
+ dp/dx = p(x) * (1 - p(x)) * F'(x).
|
|
|
+
|
|
|
+ At ``x50``, ``p(x50) = 0.5``, so the reported midpoint slope is
|
|
|
+ ``F'(x50) / 4``. The derivative is with respect to the predictor on
|
|
|
+ the scale supplied to the model.
|
|
|
+
|
|
|
+ Parameters
|
|
|
+ ----------
|
|
|
+ theta
|
|
|
+ Model parameter vector of length ``degree + 1``.
|
|
|
+
|
|
|
+ Returns
|
|
|
+ -------
|
|
|
+ float
|
|
|
+ ``d model(x, theta) / dx`` evaluated at ``x = get_x50(theta)``.
|
|
|
+ """
|
|
|
+ x50 = self.get_x50(theta)
|
|
|
+ beta = np.asarray(self.get_beta(theta), dtype=float)
|
|
|
+ derivative_coefficients = np.arange(1, len(beta)) * beta[1:]
|
|
|
+ logit_slope = np.polynomial.polynomial.polyval(
|
|
|
+ x50, derivative_coefficients
|
|
|
+ )
|
|
|
+ return float(logit_slope / 4.0)
|
|
|
+
|
|
|
"""
|
|
|
Calculate negative log-likelihood function
|
|
|
|