| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239 |
- """
- The file provides a class for logistic regression utilities with polynomial
- logit (log odds) function:
- p(x|theta) = 1/(1 + exp(-F(x|beta(theta))))
- with log odds F of polynomial form:
- F(x|beta) = sum_{j=0}^degree beta_j x^j
- with decision function (aka logit) F and parameters
- beta(theta) = [beta(theta)_j]_{j=0}^degree
- where theta are regression parameters and len(theta) = degree + 1.
- Coefficients beta(theta) can be constrained to be monotonic
- function of x by using monotonic cubic transformation:
- beta(theta) = mc.forward_map(theta)
- where mc is module mono_cubic2.
- NOTES: The model defines the conditional probability
- Prob(Y = y|x, theta) = 1/(1 + exp(-s(y) F(x| beta(theta))))
- where
- s(y) = 2*y - 1
- with x in R and y in {0,1}. For degree = 1 this is standard logistic regression
- beta(theta) = (theta[0], theta[1]).
- and generally without monotonicity condition
- beta(theta) = [theta_i]_{i=0}^degree
- For degree = 3 and mono = True the coefficients beta(theta) are constrained to be
- monotonic by using monotonic cubic transformation.
- We have data
- {(x_i, y_i) : x_i in R, y_i in {0,1}, i = 0, ..., n-1 }
- and the model is fit to data by minimizing negative log-likelihood function:
- nlff = -sum_i log(Prob(Y = y_i| x_i, theta)) : neg. log likelihood
- with respect to parameters theta. We can have regularization term in cost function
- and this case we minimize cost function:
- cost(theta) = nllf(theta) + lambda_0*|theta| + lambda_1*|theta|_2^2
- Author: Martin Horvat, January 2026
- """
- import numpy as np
- import scipy
- import scipy.optimize
- from . import monotonic as mc
- def resize_with_const(v, n, val=0):
- """
- Resize a 1D vector to a specified length `n`.
- If the input vector `v` is longer than `n`, it is truncated.
- If it is shorter, it is padded with the constant value `val`.
- If it is already of length `n`, it is returned unchanged.
- Parameters:
- v (array-like): Input 1D vector (list or NumPy array).
- n (int): Target length of the output vector.
- val (scalar, optional): Value used to pad if `v` is shorter than `n`. Default is 0.
- Returns:
- np.ndarray: Resized 1D NumPy array of length `n`.
- """
- v = np.asarray(v)
- if len(v) == n: return v
- if len(v) > n: return v[:n]
- return np.concatenate([v, np.full(n - len(v), val)])
- """
- Fitting data
- {(x_i, y_i) in R x {0,1} : i = 0, ..., n-1}
- to model function
- f(x|theta) = 1/(1 + exp(-F(x|beta(theta))))
- with log odds of polynomial form:
- log(f(x|theta)/(1 - f(x|theta))) = F(x| beta(theta))
- where F is decision function (aka logit)
- F(x|beta) = sum_{i=0}^degree beta_i x^i
- and coefficients
- beta(theta) = [beta_i(theta)]_{i=0}^degrees
- Conditional probability
- Prob(Y = y_i|x, theta) = 1/(1 + exp(-s(y) F(x|beta(theta)))
- with
- s(y) = 2*y -1
- """
- class LogisticPolyRegression:
- """
- Class constructor.
- Input:
- degree: int, degree of polynomial
- mono: boolean, default False
- lambda: None or tuple float, L2 regularization
- """
- def __init__(self, degree = 1, mono = False, lam = None):
- self.degree = degree
- self.mono = mono
- self.big = 1e3
- self.small = 1e-8
- self.lam = lam
- if mono and self.degree not in (1, 3):
- raise ValueError(
- f"Monotonic regression supports only degree 1 or 3, "
- f"not degree {self.degree}."
- )
- self.mono1 = self.mono and (self.degree == 1)
- self.mono3 = self.mono and (self.degree == 3)
- def _get_bounds(self):
- """Return optimizer bounds for the regression parameters."""
- bounds = [(-self.big, self.big)] * (self.degree + 1)
- # A positive linear coefficient makes both the logit and probability
- # strictly increasing in x.
- if self.mono1:
- bounds[1] = (self.small, self.big)
- return bounds
- def _validate_data(self, x, y):
- """Validate and return one-dimensional predictor and response arrays."""
- x = np.asarray(x, dtype=float)
- y = np.asarray(y)
- if x.ndim != 1 or y.ndim != 1:
- raise ValueError("x and y must be one-dimensional arrays.")
- if len(x) != len(y):
- raise ValueError(
- f"x and y must have the same length; got {len(x)} and {len(y)}."
- )
- if len(x) == 0:
- raise ValueError("x and y must not be empty.")
- if not np.all(np.isfinite(x)):
- raise ValueError("x must contain only finite values.")
- if not np.all(np.isfinite(y)):
- raise ValueError("y must contain only finite values.")
- if not np.all(np.isin(y, (0, 1))):
- raise ValueError("y must contain only the binary values 0 and 1.")
- return x, y.astype(int, copy=False)
- @staticmethod
- def _validate_sample_count(m):
- """Validate a requested number of generated parameter samples."""
- if isinstance(m, (bool, np.bool_)) or not isinstance(m, (int, np.integer)):
- raise TypeError("m must be a positive integer.")
- if m < 1:
- raise ValueError("m must be at least 1.")
- return int(m)
- def _validate_bootstrap_request(self, m, max_attempts):
- """Validate bootstrap size and return an explicit attempt limit."""
- m = self._validate_sample_count(m)
- if max_attempts is None:
- max_attempts = max(100, 10 * m)
- elif (
- isinstance(max_attempts, (bool, np.bool_))
- or not isinstance(max_attempts, (int, np.integer))
- ):
- raise TypeError("max_attempts must be an integer or None.")
- max_attempts = int(max_attempts)
- if max_attempts < m:
- raise ValueError("max_attempts must be at least m.")
- return m, max_attempts
- def _collect_bootstrap_theta(
- self,
- sampler,
- theta0,
- m,
- max_attempts,
- ):
- """Collect exactly ``m`` successful bootstrap parameter estimates."""
- samples = []
- attempts = 0
- while len(samples) < m and attempts < max_attempts:
- attempts += 1
- xb, yb = sampler()
- # A binary logistic-regression fit requires both classes.
- if np.unique(yb).size < 2:
- continue
- result = self.fit(
- xb,
- yb,
- theta0=theta0,
- method="local",
- )
- theta = np.asarray(result["theta"], dtype=float)
- if result["success"] and np.all(np.isfinite(theta)):
- samples.append(theta.copy())
- if len(samples) < m:
- raise RuntimeError(
- f"Generated only {len(samples)} successful bootstrap fits "
- f"from {attempts} attempts; requested {m}."
- )
- return np.stack(samples)
- def _fit_bootstrap_reference(self, x, y, seed):
- """Fit the original data before generating bootstrap samples."""
- if np.unique(y).size < 2:
- raise ValueError("Bootstrap data must contain both response classes.")
- result = self.fit(x, y, method="diff_evol", seed=seed)
- if not result["success"]:
- raise RuntimeError(
- f"Initial fit for bootstrap sampling failed: {result['message']}"
- )
- return np.asarray(result["theta"], dtype=float)
- """
- Mapping regression parameters theta to coefficients beta
- beta = beta(theta)
- used in decision function:
- F(x|beta) = sum_{i=0}^degree beta_i x^i
- Input:
- theta
- Return:
- beta
- """
- def get_beta(self, theta):
- beta = mc.forward_map(theta) if self.mono3 else theta
- return np.array(beta)
- """
- Mapping beta to regression parameters used in decision function.
- Input:
- beta: coefficient beta
- Return:
- theta
- """
- def get_theta(self, beta):
- theta = mc.backward_map(beta) if self.mono3 else beta
- return np.array(theta)
- """
- Calculate jacobian between beta and regression parameters
- J = d(beta)/d(theta)
- = [d(beta_i)/d(theta_a)]_{i,a}
- and
- H = [d^2 beta_i/(d(theta_a) d(theta_b))]_{i,a,b}
- Input:
- theta
- hess: boolean, False
- Return:
- J if hess = True
- (J, H) if hess = False
- """
- def get_jac_beta(self, theta, hess = False):
- n = len(theta)
- J = mc.forward_map_jacobian(theta) if self.mono3 else np.eye(n)
- if not hess: return J
- H = mc.forward_map_hessian(theta) if self.mono3 else np.zeros(shape = (n, n, n))
- return (J, H)
- """
- Model function
- f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
- Input:
- x: scalar value or a array of values
- theta: array of r = degree + 1 floats, model parameters array of floats
- Return:
- model function values
- """
- def model(self, x, theta):
- beta = self.get_beta(theta) # beta
- X = np.column_stack([x**i for i in range(len(beta))])
- F = X @ beta # decision function, X beta
- 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
- nllf = -sum_i log(Prob(Y = y_i| x_i, theta)) : neg. log likelihood
- grad = [d(nllf)/d(theta_a)]_a : jacobian
- where
- Prob(Y = y_i| x_i, theta) = 1/(1 + exp(-s_i F(x_i| beta))) beta=beta(theta)
- s_i = 2*y_i - 1
- Input:
- x: array of n floats
- y: array of n int in {0,1}
- theta: array of r = degree + 1 floats, model parameters array of floats
- jac: boolean, default False, if jacobian is needed
- Return:
- nllf : if jac is false
- (nllf, grad) : if jac is true
- """
- def get_nllf(self, x, y, theta, jac=False):
- beta = self.get_beta(theta) # shape (p,)
- J = self.get_jac_beta(theta) if jac else None # shape (p, q)
- X = np.column_stack([x**i for i in range(len(beta))]) # shape (n, p)
- s = 2.0 * y - 1.0
- eta = X @ beta
- z = s * eta
- # stable negative log-likelihood
- nllf = -np.sum(scipy.special.log_expit(z))
- if not jac:
- return nllf
- grad = -(s * (1.0 - scipy.special.expit(z))) @ (X @ J)
- return nllf, grad
- """
- Penalty function
- Input:
- theta: array of r = degree + 1 floats, model parameters array of floats
- jac: boolean, default False, if jacobian is needed
- Return:
- val : if jac is false
- (val, grad) : if jac is true
- """
- def penalty(self, theta, jac = False):
- val = self.lam[0]*np.sum(np.abs(theta)) + self.lam[1]*np.sum(theta**2)
- if jac:
- grad = self.lam[0]*np.sign(theta) + 2*self.lam[1]*theta
- return (val, grad)
- return val
- """
- Cost function
- """
- def get_cost(self, x, y, theta, jac = False):
- val = self.get_nllf(x, y, theta, jac)
- if self.lam is not None:
- pen = self.penalty(theta, jac)
- return (val[0] + pen[0], val[1] + pen[1]) if jac else val + pen
- return val
- """
- Estimate parameters.
- Input:
- x: array of n floats
- y: array of n int in {0,1}
- Return:
- theta0
- """
- def get_est_theta(self, x, y):
- L = np.log(2*len(x) + 1)
- if self.mono3:
- z = L*np.polyfit(x, 2.0*y - 1, 1)[::-1]
- beta = resize_with_const(z, self.degree + 1, 1e-8)
- else:
- beta = L*np.polyfit(x, 2.0*y - 1, self.degree)[::-1]
- return self.get_theta(beta)
- """
- Performing logistic regression with log odds of polynomial form:
- log(f(x|theta)/(1 - f(x|theta))) = F(x|beta) beta = beta(theta)
- and this gives
- f(x|theta) = 1/(1 + exp(-F(x|beta)))
- where coefficient beta = [beta_i(theta)]_{i=0}^degree, with decision
- function (aka logit)
- F(x|beta) = sum_{i=0}^degree beta_i x^i
- Input:
- x: array of n floats
- y: array of n int in {0, 1}
- Return:
- dict {"theta", "cost", "success"}
- """
- def fit(self, x, y, theta0 = None, method = "local", seed = None):
- """Fit the model using a local or global optimization method.
- ``theta0`` is used as the initial point for local optimization. If it is
- omitted, an initial estimate is calculated from the data. ``seed`` is
- passed to stochastic global optimizers for reproducible fits.
- """
- x, y = self._validate_data(x, y)
- bnds = self._get_bounds()
- if method == "local":
- if theta0 is None:
- theta0 = self.get_est_theta(x, y)
- theta0 = np.asarray(theta0, dtype=float)
- expected_shape = (self.degree + 1,)
- if theta0.shape != expected_shape:
- raise ValueError(
- f"theta0 must have shape {expected_shape}; got {theta0.shape}."
- )
- if not np.all(np.isfinite(theta0)):
- raise ValueError("theta0 must contain only finite values.")
- # L-BFGS-B requires its initial point to satisfy the bounds.
- lower = np.array([bound[0] for bound in bnds])
- upper = np.array([bound[1] for bound in bnds])
- theta0 = np.clip(theta0, lower, upper)
- cf = lambda theta: self.get_cost(x, y, theta, jac = True)
- res = scipy.optimize.minimize(
- cf,
- x0 = theta0,
- method = "L-BFGS-B",
- jac = True,
- bounds = bnds,
- tol = 1e-12,
- )
- elif method == "diff_evol":
- cf = lambda theta: self.get_cost(x, y, theta, jac = False)
- res_global = scipy.optimize.differential_evolution(
- cf,
- bounds = bnds,
- tol = 1e-8,
- polish = False,
- seed = seed,
- )
- cf = lambda theta: self.get_cost(x, y, theta, jac = True)
- res = scipy.optimize.minimize(
- cf,
- x0 = res_global.x,
- method = "L-BFGS-B",
- jac = True,
- bounds = bnds,
- tol = 1e-12,
- )
- elif method == "anneal":
- cf = lambda theta: self.get_cost(x, y, theta, jac = False)
- res = scipy.optimize.dual_annealing(
- cf,
- bounds = bnds,
- seed = seed,
- )
- else:
- raise ValueError(
- f"Unsupported fitting method {method!r}; expected "
- "'local', 'diff_evol', or 'anneal'."
- )
- return {
- "theta": res.x,
- "cost": res.fun,
- "success": res.success,
- "message": res.message,
- "nit": res.nit,
- }
- def _parametric_bootstrap_deviance(
- self,
- x,
- y,
- theta,
- m,
- seed,
- max_attempts,
- ):
- """Return observed deviance and its refitted bootstrap p-value."""
- m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
- observed = 2.0 * self.get_nllf(x, y, theta)
- fitted_probabilities = self.model(x, theta)
- rng = np.random.default_rng(seed)
- simulated = []
- attempts = 0
- while len(simulated) < m and attempts < max_attempts:
- attempts += 1
- y_sim = rng.binomial(1, fitted_probabilities)
- # Degenerate samples do not support the fitted binary model.
- if np.unique(y_sim).size < 2:
- continue
- result = self.fit(
- x,
- y_sim,
- theta0=theta,
- method="local",
- )
- theta_sim = np.asarray(result["theta"], dtype=float)
- if not result["success"] or not np.all(np.isfinite(theta_sim)):
- continue
- simulated.append(2.0 * self.get_nllf(x, y_sim, theta_sim))
- if len(simulated) < m:
- raise RuntimeError(
- f"Generated only {len(simulated)} successful goodness-of-fit "
- f"bootstrap fits from {attempts} attempts; requested {m}."
- )
- simulated = np.asarray(simulated)
- p_value = (1 + np.count_nonzero(simulated >= observed)) / (m + 1)
- return observed, p_value
- def goodness_of_fit(
- self,
- x,
- y,
- theta,
- thresh = 0.5,
- regularization = False,
- bootstrap_samples = 1000,
- bootstrap_seed = 1977,
- bootstrap_max_attempts = None,
- ):
- """Calculate fit summaries and a bootstrap goodness-of-fit test.
- By default, AIC and BIC are based on the unpenalized log-likelihood,
- even when the model was fitted with a regularization penalty. This
- keeps their objective common when comparing models fitted with
- different penalties. Set ``regularization=True`` to include the
- configured penalty in the objective used for AIC and BIC.
- Goodness of fit is assessed using the unpenalized logistic deviance
- D = -2 sum_i [y_i log(p_i) + (1-y_i) log(1-p_i)].
- Its p-value is calibrated by a parametric bootstrap. Each bootstrap
- response is sampled independently from Bernoulli(p_i) and the model is
- refitted with the same constraints and configured penalty before its
- deviance is calculated. The returned p-value is
- (1 + number of simulated deviances >= observed deviance)
- / (bootstrap_samples + 1).
- This replaces the Pearson chi-square approximation, which is not valid
- when continuous predictors give approximately one Bernoulli observation
- per covariate pattern. A large p-value means that the observed
- discrepancy is not unusual under the fitted model; it does not prove
- that the model is correct.
- """
- x, y = self._validate_data(x, y)
- theta = np.asarray(theta, dtype=float)
- # model probabilities
- p = self.model(x, theta)
- # Log likelihood, or the negative penalized objective when explicitly
- # requested. With regularization=False this remains comparable across
- # models fitted using different penalty strengths.
- llf = -(
- self.get_cost(x, y, theta)
- if regularization
- else self.get_nllf(x, y, theta)
- )
- # information criteria
- k, n = len(theta), len(x)
- AIC = 2*k - 2*llf
- BIC = k*np.log(n) - 2*llf
- dof = n - k
- deviance, deviance_p_value = self._parametric_bootstrap_deviance(
- x,
- y,
- theta,
- m=bootstrap_samples,
- seed=bootstrap_seed,
- max_attempts=bootstrap_max_attempts,
- )
- # using model as classifier
- matches = y == np.heaviside(p - thresh, 1)
- # accuracy A
- A = np.count_nonzero(matches)/n
- return {"LLF": llf,
- "AIC": AIC,
- "BIC": BIC,
- "A" : A,
- "deviance": deviance,
- "p-value(deviance_bootstrap)": deviance_p_value,
- "deviance_bootstrap_samples": bootstrap_samples,
- "n": n, "k": k, "dof": dof}
- """
- Calculate hessian of cost function with respect to parameters theta
- Input:
- x: array of n floats
- y: array of n int in {0,1}
- theta: array of r = degree+1 floats, model parameters
- Return:
- H matrix of shape (r, r)
- """
- def get_cost_hessian(self, x, y, theta):
- # coefficients
- beta = self.get_beta(theta)
- # design matrix -- add column of 1's at the beginning of your X_train matrix
- X = np.column_stack([x**i for i in range(len(beta))])
- # Jacobian J = [dbeta_i/dtheta_a]_{ia}
- # Hessian H = [d^2 beta_i/(d(theta_a) d(theta_b))]_{i,a,b}
- J, H = self.get_jac_beta(theta, hess = True)
- # signs
- s = 2.0*y - 1
- # decision function for conditional probability Prob(Y = y| x)
- V = s[:,None]*X
- F = V @ beta
- # probabilities p_i = P(Y=y_i | x_i)
- p = scipy.special.expit(F)
- q = 1 - p
- # calculate hessian
- L = V @ J
- H = (L.T*(q*p))@L - np.tensordot(q@V, H, axes = ([0], [0]))
- if self.lam is not None:
- return H + 2*self.lam[1]*np.eye(len(theta)) # H' = H + lambda id
- return H
- def get_cov(self, x, y, theta, method="model_sandwich"):
- """Estimate the covariance matrix of fitted parameters.
- Parameters
- ----------
- x, y
- Predictor and binary response arrays.
- theta
- Fitted regression parameters.
- method
- ``"model_sandwich"`` (default) uses
- A^+ [sum_i p_i(1-p_i) g_i g_i.T] A^+,
- where ``A`` is the observed Hessian of the penalized objective and
- ``g_i = d eta_i / d theta``. This accounts for the fact that the
- deterministic penalty changes the Hessian but does not contribute
- sampling variability.
- ``"robust_sandwich"`` replaces the middle matrix by the empirical
- outer product of unpenalized scores,
- sum_i [(y_i-p_i) g_i][(y_i-p_i) g_i].T.
- ``"inverse_hessian"`` returns the previous approximation ``A^+``
- for comparison. It should not be treated as the frequentist
- covariance of a penalized estimator.
- Notes
- -----
- ``+`` denotes a symmetric Moore-Penrose pseudoinverse. These are local
- normal approximations. For the monotonic cubic model, they can be
- unreliable near ``epsilon=0``, where the estimate is on the boundary
- and the parameter map loses rank. Bootstrap inference is preferred in
- that case. A nonzero L1 penalty is not supported because its Hessian is
- undefined at zero.
- """
- x, y = self._validate_data(x, y)
- theta = np.asarray(theta, dtype=float)
- valid_methods = {
- "model_sandwich",
- "robust_sandwich",
- "inverse_hessian",
- }
- if method not in valid_methods:
- raise ValueError(
- f"Unknown covariance method {method!r}; expected one of "
- f"{sorted(valid_methods)}."
- )
- if self.lam is not None and self.lam[0] != 0:
- raise ValueError(
- "Covariance estimation does not support a nonzero L1 penalty."
- )
- # Bread: exact observed Hessian of the smooth penalized objective.
- bread = self.get_cost_hessian(x, y, theta)
- bread = 0.5 * (bread + bread.T)
- bread_inv = np.linalg.pinv(bread, hermitian=True)
- if method == "inverse_hessian":
- return bread_inv
- beta = self.get_beta(theta)
- X = np.column_stack([x**i for i in range(len(beta))])
- J = self.get_jac_beta(theta)
- gradient_eta = X @ J
- probabilities = self.model(x, theta)
- if method == "model_sandwich":
- weights = probabilities * (1.0 - probabilities)
- meat = (gradient_eta.T * weights) @ gradient_eta
- else:
- scores = (y - probabilities)[:, None] * gradient_eta
- meat = scores.T @ scores
- cov = bread_inv @ meat @ bread_inv
- return 0.5 * (cov + cov.T)
- """
- Calculating standard errors fo model parameters for normal distribution of parameters:
- theta ~ N(mean_theta, cov_theta)
- Input:
- cov_theta: array of rxr floats, variance-covariance matrix of parameters
- Return:
- array of r floats, standard errors of parameters
- """
- def get_SE_theta_normal(self, cov_theta):
- # computing standard errors of parameters
- return np.sqrt(np.clip(np.diag(cov_theta),a_min=0, a_max = None))
- """
- Calculating quantiles of the model parameters at given probabilities p
- for normal distribution of parameters:
- theta ~ N(mean_theta, cov_theta)
- Input:
- probs: array of m floats, probabilities
- mean_theta: array of r = degree+1 floats, mean model parameters
- cov_theta: array of rxr floats, variance-covariance matrix of parameters
- Return:
- array of mxr floats
- """
- def get_theta_quantiles_normal(self, probs, mean_theta, cov_theta):
- # mean and standard variance parameters
- locs = mean_theta
- scales = np.sqrt(np.clip(np.diag(cov_theta), a_min=0, a_max=None))
- # computing quantiles of parameters
- return locs + np.outer(scipy.stats.norm.ppf(probs), scales)
- """
- Calculating quantiles of the model values
- f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
- with
- F(x|beta) = sum_{i=0}^degree x^i beta_i
- at given probabilities p and values x assuming
- normal distribution of parameters:
- theta ~ N(mean_theta, cov_theta)
- This distribution is asymptotic MLE distribution of parameters.
- Input:
- x: array of n float
- probs: array of m floats, probabilities
- mean_theta: array of r = degree+1 floats, mean model parameters
- cov_theta: array of rxr floats, variance-covariance matrix of parameters
- Return:
- array of mxn floats
- """
- def get_model_quantiles_normal(self, x, probs, mean_theta, cov_theta,
- exact = True, seed = 1977, m = 10**5):
- mean_beta = self.get_beta(mean_theta)
- X = np.column_stack([x**i for i in range(len(mean_beta))])
- if exact and self.mono3:
- # init random generator
- rng = np.random.default_rng(seed)
- theta = rng.multivariate_normal(mean_theta, cov_theta, size = m)
- # get betas
- beta = np.apply_along_axis(self.get_beta, 1, theta)
- # quantiles of decision function
- Q = np.quantile(X@beta.T, probs, axis = 1)
- return np.apply_along_axis(scipy.special.expit, 1, Q)
- # J = d(beta)/d(theta)
- J = self.get_jac_beta(mean_theta)
- # transform data
- S = X@J
- # mean and standard variance of logit (aka log of odds)
- locs = X@mean_beta
- scales = np.sqrt(np.diag(S@cov_theta@S.T))
- # computing quantiles of logit
- Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
- # convert logit to expit
- return scipy.special.expit(Q)
- """
- Calculating quantiles using delta method of the model values
- f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
- with
- F(x|beta) = sum_{i=0}^degree x^i beta_i
- at given probabilities p and values x assuming
- normal distribution of parameters :
- theta ~ N(mean_theta, cov_theta)
- This distribution is asymptotic MLE distribution of parameters.
- We approximate exact model with linear expansion
- f(x|theta) = p(x|theta_mean) + dp/db(x|theta_mean) (theta - theta_mean)
- and the last term is normally distributed.
- Input:
- x: array of n float
- probs: array of m floats, probabilities
- mean_theta: array of r = degree+1 floats, mean model parameters
- cov_theta: array of rxr floats, variance-covariance matrix of parameters
- Return:
- array of mxn floats
- """
- def get_model_quantiles_delta(self, x, probs, mean_theta, cov_theta):
- mean_beta = self.get_beta(mean_theta)
- X = np.column_stack([x**i for i in range(len(mean_beta))])
- F = X@mean_beta
- # J = d(beta)/d(theta)
- J = self.get_jac_beta(mean_theta)
- # S = d(F)/d(theta)
- S = X@J
- # attributes of normal distribution of model values
- locs = scipy.special.expit(F)
- derivative = locs * (1 - locs)
- scales = np.sqrt(np.diag(S @ cov_theta @ S.T)) * derivative
- # computing quantiles of logit
- Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
- return np.clip(Q, a_min = 0, a_max = 1)
- """
- Generate parameters assuming normal distribution.
- Input:
- x : array of n floats
- y : array of n int in {0,1}
- m: integer, number of samples
- seed : int, seed for the random generator
- Return:
- array of mx(degree + 1)
- """
- def get_normal_theta(self, x, y, m, seed = 1977):
- x, y = self._validate_data(x, y)
- m = self._validate_sample_count(m)
- res = self.fit(x, y, method = "diff_evol", seed = seed)
- if not res["success"]:
- raise RuntimeError(f"Fit did not succeed: {res['message']}")
- # optimal parameters
- theta = res["theta"]
- # covariance matrix of parameters
- cov = self.get_cov(x, y, theta)
- # init random generator
- rng = np.random.default_rng(seed)
- return rng.multivariate_normal(theta, cov, size = m)
- """
- Generate m parameters via non-parametric bootstrapping with a minimal constraint
- that both groups should be present in the sampled data:
- boostrapped sample = (xb, yb) by sampling with replacement pairs (x_i, y_i)
- with condition that yb can not be just 0 or just 1
- Input:
- x : array of n floats
- y : array of n int in {0,1}
- m: integer, number of samples
- seed : int, seed for the random generator
- Return:
- array of mx(degree + 1)
- """
- def get_nonparam_boots_theta(
- self,
- x,
- y,
- m,
- seed = 1977,
- max_attempts = None,
- ):
- x, y = self._validate_data(x, y)
- m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
- theta0 = self._fit_bootstrap_reference(x, y, seed)
- n = len(x)
- rng = np.random.default_rng(seed)
- def sampler():
- idx = rng.choice(n, n, replace=True)
- return x[idx], y[idx]
- return self._collect_bootstrap_theta(
- sampler,
- theta0,
- m,
- max_attempts,
- )
- """
- Generate m parameters via non-parametric stratified bootstrapping:
- boostrapped sample = (xb, yb)
- xb = (sampled with replacement from x0, sampled with replacement from x1)
- yb = (0...0, 1...1)
- Input:
- x : array of n floats
- y : array of n int in {0,1}
- m: integer, number of samples
- seed : int, seed for the random generator
- Return:
- array of mx(degree + 1)
- """
- def get_nonparam_stratified_boots_theta(
- self,
- x,
- y,
- m,
- seed = 1977,
- max_attempts = None,
- ):
- x, y = self._validate_data(x, y)
- m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
- theta0 = self._fit_bootstrap_reference(x, y, seed)
- xs = [x[y == i] for i in range(2)]
- ns = [len(group) for group in xs]
- yb = np.concatenate(
- [np.full(ns[i], i, dtype=int) for i in range(2)]
- )
- rng = np.random.default_rng(seed)
- def sampler():
- xb = np.concatenate(
- [rng.choice(xs[i], ns[i], replace=True) for i in range(2)]
- )
- return xb, yb
- return self._collect_bootstrap_theta(
- sampler,
- theta0,
- m,
- max_attempts,
- )
- """
- Generate m parameters via parametric bootstrapping:
- boostrapped sample = (x, yb) yb ~ B(model(x, fitted theta))
- where B is Bernoulli distribution
- Input:
- x : array of n floats
- y : array of n int in {0,1}
- m: integer, number of samples
- seed : int, seed for the random generator
- Return:
- array of mx(degree + 1)
- Ref:
- * https://www.scirp.org/journal/paperinformation?paperid=70962
- * https://en.wikipedia.org/wiki/Bernoulli_distribution
- """
- def get_parametric_boots_theta(
- self,
- x,
- y,
- m,
- seed = 1977,
- max_attempts = None,
- ):
- x, y = self._validate_data(x, y)
- m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
- theta0 = self._fit_bootstrap_reference(x, y, seed)
- p = self.model(x, theta0)
- rng = np.random.default_rng(seed)
- def sampler():
- y_sim = rng.binomial(n = 1, p = p)
- return x, y_sim
- return self._collect_bootstrap_theta(
- sampler,
- theta0,
- m,
- max_attempts,
- )
|