logistic.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239
  1. """
  2. The file provides a class for logistic regression utilities with polynomial
  3. logit (log odds) function:
  4. p(x|theta) = 1/(1 + exp(-F(x|beta(theta))))
  5. with log odds F of polynomial form:
  6. F(x|beta) = sum_{j=0}^degree beta_j x^j
  7. with decision function (aka logit) F and parameters
  8. beta(theta) = [beta(theta)_j]_{j=0}^degree
  9. where theta are regression parameters and len(theta) = degree + 1.
  10. Coefficients beta(theta) can be constrained to be monotonic
  11. function of x by using monotonic cubic transformation:
  12. beta(theta) = mc.forward_map(theta)
  13. where mc is module mono_cubic2.
  14. NOTES: The model defines the conditional probability
  15. Prob(Y = y|x, theta) = 1/(1 + exp(-s(y) F(x| beta(theta))))
  16. where
  17. s(y) = 2*y - 1
  18. with x in R and y in {0,1}. For degree = 1 this is standard logistic regression
  19. beta(theta) = (theta[0], theta[1]).
  20. and generally without monotonicity condition
  21. beta(theta) = [theta_i]_{i=0}^degree
  22. For degree = 3 and mono = True the coefficients beta(theta) are constrained to be
  23. monotonic by using monotonic cubic transformation.
  24. We have data
  25. {(x_i, y_i) : x_i in R, y_i in {0,1}, i = 0, ..., n-1 }
  26. and the model is fit to data by minimizing negative log-likelihood function:
  27. nlff = -sum_i log(Prob(Y = y_i| x_i, theta)) : neg. log likelihood
  28. with respect to parameters theta. We can have regularization term in cost function
  29. and this case we minimize cost function:
  30. cost(theta) = nllf(theta) + lambda_0*|theta| + lambda_1*|theta|_2^2
  31. Author: Martin Horvat, January 2026
  32. """
  33. import numpy as np
  34. import scipy
  35. import scipy.optimize
  36. from . import monotonic as mc
  37. def resize_with_const(v, n, val=0):
  38. """
  39. Resize a 1D vector to a specified length `n`.
  40. If the input vector `v` is longer than `n`, it is truncated.
  41. If it is shorter, it is padded with the constant value `val`.
  42. If it is already of length `n`, it is returned unchanged.
  43. Parameters:
  44. v (array-like): Input 1D vector (list or NumPy array).
  45. n (int): Target length of the output vector.
  46. val (scalar, optional): Value used to pad if `v` is shorter than `n`. Default is 0.
  47. Returns:
  48. np.ndarray: Resized 1D NumPy array of length `n`.
  49. """
  50. v = np.asarray(v)
  51. if len(v) == n: return v
  52. if len(v) > n: return v[:n]
  53. return np.concatenate([v, np.full(n - len(v), val)])
  54. """
  55. Fitting data
  56. {(x_i, y_i) in R x {0,1} : i = 0, ..., n-1}
  57. to model function
  58. f(x|theta) = 1/(1 + exp(-F(x|beta(theta))))
  59. with log odds of polynomial form:
  60. log(f(x|theta)/(1 - f(x|theta))) = F(x| beta(theta))
  61. where F is decision function (aka logit)
  62. F(x|beta) = sum_{i=0}^degree beta_i x^i
  63. and coefficients
  64. beta(theta) = [beta_i(theta)]_{i=0}^degrees
  65. Conditional probability
  66. Prob(Y = y_i|x, theta) = 1/(1 + exp(-s(y) F(x|beta(theta)))
  67. with
  68. s(y) = 2*y -1
  69. """
  70. class LogisticPolyRegression:
  71. """
  72. Class constructor.
  73. Input:
  74. degree: int, degree of polynomial
  75. mono: boolean, default False
  76. lambda: None or tuple float, L2 regularization
  77. """
  78. def __init__(self, degree = 1, mono = False, lam = None):
  79. self.degree = degree
  80. self.mono = mono
  81. self.big = 1e3
  82. self.small = 1e-8
  83. self.lam = lam
  84. if mono and self.degree not in (1, 3):
  85. raise ValueError(
  86. f"Monotonic regression supports only degree 1 or 3, "
  87. f"not degree {self.degree}."
  88. )
  89. self.mono1 = self.mono and (self.degree == 1)
  90. self.mono3 = self.mono and (self.degree == 3)
  91. def _get_bounds(self):
  92. """Return optimizer bounds for the regression parameters."""
  93. bounds = [(-self.big, self.big)] * (self.degree + 1)
  94. # A positive linear coefficient makes both the logit and probability
  95. # strictly increasing in x.
  96. if self.mono1:
  97. bounds[1] = (self.small, self.big)
  98. return bounds
  99. def _validate_data(self, x, y):
  100. """Validate and return one-dimensional predictor and response arrays."""
  101. x = np.asarray(x, dtype=float)
  102. y = np.asarray(y)
  103. if x.ndim != 1 or y.ndim != 1:
  104. raise ValueError("x and y must be one-dimensional arrays.")
  105. if len(x) != len(y):
  106. raise ValueError(
  107. f"x and y must have the same length; got {len(x)} and {len(y)}."
  108. )
  109. if len(x) == 0:
  110. raise ValueError("x and y must not be empty.")
  111. if not np.all(np.isfinite(x)):
  112. raise ValueError("x must contain only finite values.")
  113. if not np.all(np.isfinite(y)):
  114. raise ValueError("y must contain only finite values.")
  115. if not np.all(np.isin(y, (0, 1))):
  116. raise ValueError("y must contain only the binary values 0 and 1.")
  117. return x, y.astype(int, copy=False)
  118. @staticmethod
  119. def _validate_sample_count(m):
  120. """Validate a requested number of generated parameter samples."""
  121. if isinstance(m, (bool, np.bool_)) or not isinstance(m, (int, np.integer)):
  122. raise TypeError("m must be a positive integer.")
  123. if m < 1:
  124. raise ValueError("m must be at least 1.")
  125. return int(m)
  126. def _validate_bootstrap_request(self, m, max_attempts):
  127. """Validate bootstrap size and return an explicit attempt limit."""
  128. m = self._validate_sample_count(m)
  129. if max_attempts is None:
  130. max_attempts = max(100, 10 * m)
  131. elif (
  132. isinstance(max_attempts, (bool, np.bool_))
  133. or not isinstance(max_attempts, (int, np.integer))
  134. ):
  135. raise TypeError("max_attempts must be an integer or None.")
  136. max_attempts = int(max_attempts)
  137. if max_attempts < m:
  138. raise ValueError("max_attempts must be at least m.")
  139. return m, max_attempts
  140. def _collect_bootstrap_theta(
  141. self,
  142. sampler,
  143. theta0,
  144. m,
  145. max_attempts,
  146. ):
  147. """Collect exactly ``m`` successful bootstrap parameter estimates."""
  148. samples = []
  149. attempts = 0
  150. while len(samples) < m and attempts < max_attempts:
  151. attempts += 1
  152. xb, yb = sampler()
  153. # A binary logistic-regression fit requires both classes.
  154. if np.unique(yb).size < 2:
  155. continue
  156. result = self.fit(
  157. xb,
  158. yb,
  159. theta0=theta0,
  160. method="local",
  161. )
  162. theta = np.asarray(result["theta"], dtype=float)
  163. if result["success"] and np.all(np.isfinite(theta)):
  164. samples.append(theta.copy())
  165. if len(samples) < m:
  166. raise RuntimeError(
  167. f"Generated only {len(samples)} successful bootstrap fits "
  168. f"from {attempts} attempts; requested {m}."
  169. )
  170. return np.stack(samples)
  171. def _fit_bootstrap_reference(self, x, y, seed):
  172. """Fit the original data before generating bootstrap samples."""
  173. if np.unique(y).size < 2:
  174. raise ValueError("Bootstrap data must contain both response classes.")
  175. result = self.fit(x, y, method="diff_evol", seed=seed)
  176. if not result["success"]:
  177. raise RuntimeError(
  178. f"Initial fit for bootstrap sampling failed: {result['message']}"
  179. )
  180. return np.asarray(result["theta"], dtype=float)
  181. """
  182. Mapping regression parameters theta to coefficients beta
  183. beta = beta(theta)
  184. used in decision function:
  185. F(x|beta) = sum_{i=0}^degree beta_i x^i
  186. Input:
  187. theta
  188. Return:
  189. beta
  190. """
  191. def get_beta(self, theta):
  192. beta = mc.forward_map(theta) if self.mono3 else theta
  193. return np.array(beta)
  194. """
  195. Mapping beta to regression parameters used in decision function.
  196. Input:
  197. beta: coefficient beta
  198. Return:
  199. theta
  200. """
  201. def get_theta(self, beta):
  202. theta = mc.backward_map(beta) if self.mono3 else beta
  203. return np.array(theta)
  204. """
  205. Calculate jacobian between beta and regression parameters
  206. J = d(beta)/d(theta)
  207. = [d(beta_i)/d(theta_a)]_{i,a}
  208. and
  209. H = [d^2 beta_i/(d(theta_a) d(theta_b))]_{i,a,b}
  210. Input:
  211. theta
  212. hess: boolean, False
  213. Return:
  214. J if hess = True
  215. (J, H) if hess = False
  216. """
  217. def get_jac_beta(self, theta, hess = False):
  218. n = len(theta)
  219. J = mc.forward_map_jacobian(theta) if self.mono3 else np.eye(n)
  220. if not hess: return J
  221. H = mc.forward_map_hessian(theta) if self.mono3 else np.zeros(shape = (n, n, n))
  222. return (J, H)
  223. """
  224. Model function
  225. f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
  226. Input:
  227. x: scalar value or a array of values
  228. theta: array of r = degree + 1 floats, model parameters array of floats
  229. Return:
  230. model function values
  231. """
  232. def model(self, x, theta):
  233. beta = self.get_beta(theta) # beta
  234. X = np.column_stack([x**i for i in range(len(beta))])
  235. F = X @ beta # decision function, X beta
  236. return scipy.special.expit(F)
  237. def get_x50(self, theta):
  238. """Return the first predictor value at which the model equals 0.5.
  239. Since ``expit(F) = 0.5`` exactly when ``F = 0``, ``x50`` is the
  240. smallest real root of the polynomial logit
  241. F(x) = sum_i beta_i(theta) x**i.
  242. For a monotonic fitted model the root is unique. Defining "first" as
  243. the smallest real root also makes the result unambiguous for an
  244. unconstrained polynomial with several 0.5 crossings.
  245. Parameters
  246. ----------
  247. theta
  248. Model parameter vector of length ``degree + 1``.
  249. Returns
  250. -------
  251. float
  252. The smallest real solution of ``model(x, theta) = 0.5``.
  253. Raises
  254. ------
  255. ValueError
  256. If ``theta`` is invalid, the model never reaches 0.5 at a real
  257. predictor value, or the model is identically 0.5 and hence has no
  258. unique first crossing.
  259. """
  260. theta = np.asarray(theta, dtype=float)
  261. expected_shape = (self.degree + 1,)
  262. if theta.shape != expected_shape:
  263. raise ValueError(
  264. f"theta must have shape {expected_shape}; got {theta.shape}."
  265. )
  266. if not np.all(np.isfinite(theta)):
  267. raise ValueError("theta must contain only finite values.")
  268. beta = np.asarray(self.get_beta(theta), dtype=float)
  269. nonzero = np.flatnonzero(beta != 0.0)
  270. if nonzero.size == 0:
  271. raise ValueError(
  272. "x50 is not uniquely defined because the model equals 0.5 "
  273. "for every x."
  274. )
  275. polynomial_degree = int(nonzero[-1])
  276. if polynomial_degree == 0:
  277. raise ValueError("The model does not reach 0.5 for any real x.")
  278. coefficients = beta[:polynomial_degree + 1]
  279. # Avoid the unnecessary loss of precision of a general polynomial
  280. # root solver in the common linear-logistic case.
  281. if polynomial_degree == 1:
  282. return float(-coefficients[0] / coefficients[1])
  283. roots = np.roots(coefficients[::-1])
  284. real_roots = []
  285. machine_tolerance = 100 * np.finfo(float).eps
  286. for root in roots:
  287. candidate = float(root.real)
  288. root_scale = max(1.0, abs(candidate))
  289. # Repeated real roots may acquire a small imaginary part in a
  290. # numerical polynomial-root calculation. In that case, also
  291. # accept the real component when its scaled polynomial residual
  292. # is negligible.
  293. residual = abs(
  294. np.polynomial.polynomial.polyval(candidate, coefficients)
  295. )
  296. coefficient_scale = np.polynomial.polynomial.polyval(
  297. abs(candidate), np.abs(coefficients)
  298. )
  299. small_imaginary_part = (
  300. abs(root.imag) <= machine_tolerance * root_scale
  301. )
  302. small_residual = residual <= 1e-10 * max(
  303. coefficient_scale, np.finfo(float).tiny
  304. )
  305. if small_imaginary_part or small_residual:
  306. real_roots.append(candidate)
  307. if not real_roots:
  308. raise ValueError("The model does not reach 0.5 for any real x.")
  309. return float(min(real_roots))
  310. def get_s50(self, theta):
  311. """Return the probability slope at the model's first 0.5 crossing.
  312. If ``p(x) = expit(F(x))``, then
  313. dp/dx = p(x) * (1 - p(x)) * F'(x).
  314. At ``x50``, ``p(x50) = 0.5``, so the reported midpoint slope is
  315. ``F'(x50) / 4``. The derivative is with respect to the predictor on
  316. the scale supplied to the model.
  317. Parameters
  318. ----------
  319. theta
  320. Model parameter vector of length ``degree + 1``.
  321. Returns
  322. -------
  323. float
  324. ``d model(x, theta) / dx`` evaluated at ``x = get_x50(theta)``.
  325. """
  326. x50 = self.get_x50(theta)
  327. beta = np.asarray(self.get_beta(theta), dtype=float)
  328. derivative_coefficients = np.arange(1, len(beta)) * beta[1:]
  329. logit_slope = np.polynomial.polynomial.polyval(
  330. x50, derivative_coefficients
  331. )
  332. return float(logit_slope / 4.0)
  333. """
  334. Calculate negative log-likelihood function
  335. nllf = -sum_i log(Prob(Y = y_i| x_i, theta)) : neg. log likelihood
  336. grad = [d(nllf)/d(theta_a)]_a : jacobian
  337. where
  338. Prob(Y = y_i| x_i, theta) = 1/(1 + exp(-s_i F(x_i| beta))) beta=beta(theta)
  339. s_i = 2*y_i - 1
  340. Input:
  341. x: array of n floats
  342. y: array of n int in {0,1}
  343. theta: array of r = degree + 1 floats, model parameters array of floats
  344. jac: boolean, default False, if jacobian is needed
  345. Return:
  346. nllf : if jac is false
  347. (nllf, grad) : if jac is true
  348. """
  349. def get_nllf(self, x, y, theta, jac=False):
  350. beta = self.get_beta(theta) # shape (p,)
  351. J = self.get_jac_beta(theta) if jac else None # shape (p, q)
  352. X = np.column_stack([x**i for i in range(len(beta))]) # shape (n, p)
  353. s = 2.0 * y - 1.0
  354. eta = X @ beta
  355. z = s * eta
  356. # stable negative log-likelihood
  357. nllf = -np.sum(scipy.special.log_expit(z))
  358. if not jac:
  359. return nllf
  360. grad = -(s * (1.0 - scipy.special.expit(z))) @ (X @ J)
  361. return nllf, grad
  362. """
  363. Penalty function
  364. Input:
  365. theta: array of r = degree + 1 floats, model parameters array of floats
  366. jac: boolean, default False, if jacobian is needed
  367. Return:
  368. val : if jac is false
  369. (val, grad) : if jac is true
  370. """
  371. def penalty(self, theta, jac = False):
  372. val = self.lam[0]*np.sum(np.abs(theta)) + self.lam[1]*np.sum(theta**2)
  373. if jac:
  374. grad = self.lam[0]*np.sign(theta) + 2*self.lam[1]*theta
  375. return (val, grad)
  376. return val
  377. """
  378. Cost function
  379. """
  380. def get_cost(self, x, y, theta, jac = False):
  381. val = self.get_nllf(x, y, theta, jac)
  382. if self.lam is not None:
  383. pen = self.penalty(theta, jac)
  384. return (val[0] + pen[0], val[1] + pen[1]) if jac else val + pen
  385. return val
  386. """
  387. Estimate parameters.
  388. Input:
  389. x: array of n floats
  390. y: array of n int in {0,1}
  391. Return:
  392. theta0
  393. """
  394. def get_est_theta(self, x, y):
  395. L = np.log(2*len(x) + 1)
  396. if self.mono3:
  397. z = L*np.polyfit(x, 2.0*y - 1, 1)[::-1]
  398. beta = resize_with_const(z, self.degree + 1, 1e-8)
  399. else:
  400. beta = L*np.polyfit(x, 2.0*y - 1, self.degree)[::-1]
  401. return self.get_theta(beta)
  402. """
  403. Performing logistic regression with log odds of polynomial form:
  404. log(f(x|theta)/(1 - f(x|theta))) = F(x|beta) beta = beta(theta)
  405. and this gives
  406. f(x|theta) = 1/(1 + exp(-F(x|beta)))
  407. where coefficient beta = [beta_i(theta)]_{i=0}^degree, with decision
  408. function (aka logit)
  409. F(x|beta) = sum_{i=0}^degree beta_i x^i
  410. Input:
  411. x: array of n floats
  412. y: array of n int in {0, 1}
  413. Return:
  414. dict {"theta", "cost", "success"}
  415. """
  416. def fit(self, x, y, theta0 = None, method = "local", seed = None):
  417. """Fit the model using a local or global optimization method.
  418. ``theta0`` is used as the initial point for local optimization. If it is
  419. omitted, an initial estimate is calculated from the data. ``seed`` is
  420. passed to stochastic global optimizers for reproducible fits.
  421. """
  422. x, y = self._validate_data(x, y)
  423. bnds = self._get_bounds()
  424. if method == "local":
  425. if theta0 is None:
  426. theta0 = self.get_est_theta(x, y)
  427. theta0 = np.asarray(theta0, dtype=float)
  428. expected_shape = (self.degree + 1,)
  429. if theta0.shape != expected_shape:
  430. raise ValueError(
  431. f"theta0 must have shape {expected_shape}; got {theta0.shape}."
  432. )
  433. if not np.all(np.isfinite(theta0)):
  434. raise ValueError("theta0 must contain only finite values.")
  435. # L-BFGS-B requires its initial point to satisfy the bounds.
  436. lower = np.array([bound[0] for bound in bnds])
  437. upper = np.array([bound[1] for bound in bnds])
  438. theta0 = np.clip(theta0, lower, upper)
  439. cf = lambda theta: self.get_cost(x, y, theta, jac = True)
  440. res = scipy.optimize.minimize(
  441. cf,
  442. x0 = theta0,
  443. method = "L-BFGS-B",
  444. jac = True,
  445. bounds = bnds,
  446. tol = 1e-12,
  447. )
  448. elif method == "diff_evol":
  449. cf = lambda theta: self.get_cost(x, y, theta, jac = False)
  450. res_global = scipy.optimize.differential_evolution(
  451. cf,
  452. bounds = bnds,
  453. tol = 1e-8,
  454. polish = False,
  455. seed = seed,
  456. )
  457. cf = lambda theta: self.get_cost(x, y, theta, jac = True)
  458. res = scipy.optimize.minimize(
  459. cf,
  460. x0 = res_global.x,
  461. method = "L-BFGS-B",
  462. jac = True,
  463. bounds = bnds,
  464. tol = 1e-12,
  465. )
  466. elif method == "anneal":
  467. cf = lambda theta: self.get_cost(x, y, theta, jac = False)
  468. res = scipy.optimize.dual_annealing(
  469. cf,
  470. bounds = bnds,
  471. seed = seed,
  472. )
  473. else:
  474. raise ValueError(
  475. f"Unsupported fitting method {method!r}; expected "
  476. "'local', 'diff_evol', or 'anneal'."
  477. )
  478. return {
  479. "theta": res.x,
  480. "cost": res.fun,
  481. "success": res.success,
  482. "message": res.message,
  483. "nit": res.nit,
  484. }
  485. def _parametric_bootstrap_deviance(
  486. self,
  487. x,
  488. y,
  489. theta,
  490. m,
  491. seed,
  492. max_attempts,
  493. ):
  494. """Return observed deviance and its refitted bootstrap p-value."""
  495. m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
  496. observed = 2.0 * self.get_nllf(x, y, theta)
  497. fitted_probabilities = self.model(x, theta)
  498. rng = np.random.default_rng(seed)
  499. simulated = []
  500. attempts = 0
  501. while len(simulated) < m and attempts < max_attempts:
  502. attempts += 1
  503. y_sim = rng.binomial(1, fitted_probabilities)
  504. # Degenerate samples do not support the fitted binary model.
  505. if np.unique(y_sim).size < 2:
  506. continue
  507. result = self.fit(
  508. x,
  509. y_sim,
  510. theta0=theta,
  511. method="local",
  512. )
  513. theta_sim = np.asarray(result["theta"], dtype=float)
  514. if not result["success"] or not np.all(np.isfinite(theta_sim)):
  515. continue
  516. simulated.append(2.0 * self.get_nllf(x, y_sim, theta_sim))
  517. if len(simulated) < m:
  518. raise RuntimeError(
  519. f"Generated only {len(simulated)} successful goodness-of-fit "
  520. f"bootstrap fits from {attempts} attempts; requested {m}."
  521. )
  522. simulated = np.asarray(simulated)
  523. p_value = (1 + np.count_nonzero(simulated >= observed)) / (m + 1)
  524. return observed, p_value
  525. def goodness_of_fit(
  526. self,
  527. x,
  528. y,
  529. theta,
  530. thresh = 0.5,
  531. regularization = False,
  532. bootstrap_samples = 1000,
  533. bootstrap_seed = 1977,
  534. bootstrap_max_attempts = None,
  535. ):
  536. """Calculate fit summaries and a bootstrap goodness-of-fit test.
  537. By default, AIC and BIC are based on the unpenalized log-likelihood,
  538. even when the model was fitted with a regularization penalty. This
  539. keeps their objective common when comparing models fitted with
  540. different penalties. Set ``regularization=True`` to include the
  541. configured penalty in the objective used for AIC and BIC.
  542. Goodness of fit is assessed using the unpenalized logistic deviance
  543. D = -2 sum_i [y_i log(p_i) + (1-y_i) log(1-p_i)].
  544. Its p-value is calibrated by a parametric bootstrap. Each bootstrap
  545. response is sampled independently from Bernoulli(p_i) and the model is
  546. refitted with the same constraints and configured penalty before its
  547. deviance is calculated. The returned p-value is
  548. (1 + number of simulated deviances >= observed deviance)
  549. / (bootstrap_samples + 1).
  550. This replaces the Pearson chi-square approximation, which is not valid
  551. when continuous predictors give approximately one Bernoulli observation
  552. per covariate pattern. A large p-value means that the observed
  553. discrepancy is not unusual under the fitted model; it does not prove
  554. that the model is correct.
  555. """
  556. x, y = self._validate_data(x, y)
  557. theta = np.asarray(theta, dtype=float)
  558. # model probabilities
  559. p = self.model(x, theta)
  560. # Log likelihood, or the negative penalized objective when explicitly
  561. # requested. With regularization=False this remains comparable across
  562. # models fitted using different penalty strengths.
  563. llf = -(
  564. self.get_cost(x, y, theta)
  565. if regularization
  566. else self.get_nllf(x, y, theta)
  567. )
  568. # information criteria
  569. k, n = len(theta), len(x)
  570. AIC = 2*k - 2*llf
  571. BIC = k*np.log(n) - 2*llf
  572. dof = n - k
  573. deviance, deviance_p_value = self._parametric_bootstrap_deviance(
  574. x,
  575. y,
  576. theta,
  577. m=bootstrap_samples,
  578. seed=bootstrap_seed,
  579. max_attempts=bootstrap_max_attempts,
  580. )
  581. # using model as classifier
  582. matches = y == np.heaviside(p - thresh, 1)
  583. # accuracy A
  584. A = np.count_nonzero(matches)/n
  585. return {"LLF": llf,
  586. "AIC": AIC,
  587. "BIC": BIC,
  588. "A" : A,
  589. "deviance": deviance,
  590. "p-value(deviance_bootstrap)": deviance_p_value,
  591. "deviance_bootstrap_samples": bootstrap_samples,
  592. "n": n, "k": k, "dof": dof}
  593. """
  594. Calculate hessian of cost function with respect to parameters theta
  595. Input:
  596. x: array of n floats
  597. y: array of n int in {0,1}
  598. theta: array of r = degree+1 floats, model parameters
  599. Return:
  600. H matrix of shape (r, r)
  601. """
  602. def get_cost_hessian(self, x, y, theta):
  603. # coefficients
  604. beta = self.get_beta(theta)
  605. # design matrix -- add column of 1's at the beginning of your X_train matrix
  606. X = np.column_stack([x**i for i in range(len(beta))])
  607. # Jacobian J = [dbeta_i/dtheta_a]_{ia}
  608. # Hessian H = [d^2 beta_i/(d(theta_a) d(theta_b))]_{i,a,b}
  609. J, H = self.get_jac_beta(theta, hess = True)
  610. # signs
  611. s = 2.0*y - 1
  612. # decision function for conditional probability Prob(Y = y| x)
  613. V = s[:,None]*X
  614. F = V @ beta
  615. # probabilities p_i = P(Y=y_i | x_i)
  616. p = scipy.special.expit(F)
  617. q = 1 - p
  618. # calculate hessian
  619. L = V @ J
  620. H = (L.T*(q*p))@L - np.tensordot(q@V, H, axes = ([0], [0]))
  621. if self.lam is not None:
  622. return H + 2*self.lam[1]*np.eye(len(theta)) # H' = H + lambda id
  623. return H
  624. def get_cov(self, x, y, theta, method="model_sandwich"):
  625. """Estimate the covariance matrix of fitted parameters.
  626. Parameters
  627. ----------
  628. x, y
  629. Predictor and binary response arrays.
  630. theta
  631. Fitted regression parameters.
  632. method
  633. ``"model_sandwich"`` (default) uses
  634. A^+ [sum_i p_i(1-p_i) g_i g_i.T] A^+,
  635. where ``A`` is the observed Hessian of the penalized objective and
  636. ``g_i = d eta_i / d theta``. This accounts for the fact that the
  637. deterministic penalty changes the Hessian but does not contribute
  638. sampling variability.
  639. ``"robust_sandwich"`` replaces the middle matrix by the empirical
  640. outer product of unpenalized scores,
  641. sum_i [(y_i-p_i) g_i][(y_i-p_i) g_i].T.
  642. ``"inverse_hessian"`` returns the previous approximation ``A^+``
  643. for comparison. It should not be treated as the frequentist
  644. covariance of a penalized estimator.
  645. Notes
  646. -----
  647. ``+`` denotes a symmetric Moore-Penrose pseudoinverse. These are local
  648. normal approximations. For the monotonic cubic model, they can be
  649. unreliable near ``epsilon=0``, where the estimate is on the boundary
  650. and the parameter map loses rank. Bootstrap inference is preferred in
  651. that case. A nonzero L1 penalty is not supported because its Hessian is
  652. undefined at zero.
  653. """
  654. x, y = self._validate_data(x, y)
  655. theta = np.asarray(theta, dtype=float)
  656. valid_methods = {
  657. "model_sandwich",
  658. "robust_sandwich",
  659. "inverse_hessian",
  660. }
  661. if method not in valid_methods:
  662. raise ValueError(
  663. f"Unknown covariance method {method!r}; expected one of "
  664. f"{sorted(valid_methods)}."
  665. )
  666. if self.lam is not None and self.lam[0] != 0:
  667. raise ValueError(
  668. "Covariance estimation does not support a nonzero L1 penalty."
  669. )
  670. # Bread: exact observed Hessian of the smooth penalized objective.
  671. bread = self.get_cost_hessian(x, y, theta)
  672. bread = 0.5 * (bread + bread.T)
  673. bread_inv = np.linalg.pinv(bread, hermitian=True)
  674. if method == "inverse_hessian":
  675. return bread_inv
  676. beta = self.get_beta(theta)
  677. X = np.column_stack([x**i for i in range(len(beta))])
  678. J = self.get_jac_beta(theta)
  679. gradient_eta = X @ J
  680. probabilities = self.model(x, theta)
  681. if method == "model_sandwich":
  682. weights = probabilities * (1.0 - probabilities)
  683. meat = (gradient_eta.T * weights) @ gradient_eta
  684. else:
  685. scores = (y - probabilities)[:, None] * gradient_eta
  686. meat = scores.T @ scores
  687. cov = bread_inv @ meat @ bread_inv
  688. return 0.5 * (cov + cov.T)
  689. """
  690. Calculating standard errors fo model parameters for normal distribution of parameters:
  691. theta ~ N(mean_theta, cov_theta)
  692. Input:
  693. cov_theta: array of rxr floats, variance-covariance matrix of parameters
  694. Return:
  695. array of r floats, standard errors of parameters
  696. """
  697. def get_SE_theta_normal(self, cov_theta):
  698. # computing standard errors of parameters
  699. return np.sqrt(np.clip(np.diag(cov_theta),a_min=0, a_max = None))
  700. """
  701. Calculating quantiles of the model parameters at given probabilities p
  702. for normal distribution of parameters:
  703. theta ~ N(mean_theta, cov_theta)
  704. Input:
  705. probs: array of m floats, probabilities
  706. mean_theta: array of r = degree+1 floats, mean model parameters
  707. cov_theta: array of rxr floats, variance-covariance matrix of parameters
  708. Return:
  709. array of mxr floats
  710. """
  711. def get_theta_quantiles_normal(self, probs, mean_theta, cov_theta):
  712. # mean and standard variance parameters
  713. locs = mean_theta
  714. scales = np.sqrt(np.clip(np.diag(cov_theta), a_min=0, a_max=None))
  715. # computing quantiles of parameters
  716. return locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  717. """
  718. Calculating quantiles of the model values
  719. f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
  720. with
  721. F(x|beta) = sum_{i=0}^degree x^i beta_i
  722. at given probabilities p and values x assuming
  723. normal distribution of parameters:
  724. theta ~ N(mean_theta, cov_theta)
  725. This distribution is asymptotic MLE distribution of parameters.
  726. Input:
  727. x: array of n float
  728. probs: array of m floats, probabilities
  729. mean_theta: array of r = degree+1 floats, mean model parameters
  730. cov_theta: array of rxr floats, variance-covariance matrix of parameters
  731. Return:
  732. array of mxn floats
  733. """
  734. def get_model_quantiles_normal(self, x, probs, mean_theta, cov_theta,
  735. exact = True, seed = 1977, m = 10**5):
  736. mean_beta = self.get_beta(mean_theta)
  737. X = np.column_stack([x**i for i in range(len(mean_beta))])
  738. if exact and self.mono3:
  739. # init random generator
  740. rng = np.random.default_rng(seed)
  741. theta = rng.multivariate_normal(mean_theta, cov_theta, size = m)
  742. # get betas
  743. beta = np.apply_along_axis(self.get_beta, 1, theta)
  744. # quantiles of decision function
  745. Q = np.quantile(X@beta.T, probs, axis = 1)
  746. return np.apply_along_axis(scipy.special.expit, 1, Q)
  747. # J = d(beta)/d(theta)
  748. J = self.get_jac_beta(mean_theta)
  749. # transform data
  750. S = X@J
  751. # mean and standard variance of logit (aka log of odds)
  752. locs = X@mean_beta
  753. scales = np.sqrt(np.diag(S@cov_theta@S.T))
  754. # computing quantiles of logit
  755. Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  756. # convert logit to expit
  757. return scipy.special.expit(Q)
  758. """
  759. Calculating quantiles using delta method of the model values
  760. f(x|theta) = 1/(1 + exp(-F(x|beta))) beta = beta(theta)
  761. with
  762. F(x|beta) = sum_{i=0}^degree x^i beta_i
  763. at given probabilities p and values x assuming
  764. normal distribution of parameters :
  765. theta ~ N(mean_theta, cov_theta)
  766. This distribution is asymptotic MLE distribution of parameters.
  767. We approximate exact model with linear expansion
  768. f(x|theta) = p(x|theta_mean) + dp/db(x|theta_mean) (theta - theta_mean)
  769. and the last term is normally distributed.
  770. Input:
  771. x: array of n float
  772. probs: array of m floats, probabilities
  773. mean_theta: array of r = degree+1 floats, mean model parameters
  774. cov_theta: array of rxr floats, variance-covariance matrix of parameters
  775. Return:
  776. array of mxn floats
  777. """
  778. def get_model_quantiles_delta(self, x, probs, mean_theta, cov_theta):
  779. mean_beta = self.get_beta(mean_theta)
  780. X = np.column_stack([x**i for i in range(len(mean_beta))])
  781. F = X@mean_beta
  782. # J = d(beta)/d(theta)
  783. J = self.get_jac_beta(mean_theta)
  784. # S = d(F)/d(theta)
  785. S = X@J
  786. # attributes of normal distribution of model values
  787. locs = scipy.special.expit(F)
  788. derivative = locs * (1 - locs)
  789. scales = np.sqrt(np.diag(S @ cov_theta @ S.T)) * derivative
  790. # computing quantiles of logit
  791. Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  792. return np.clip(Q, a_min = 0, a_max = 1)
  793. """
  794. Generate parameters assuming normal distribution.
  795. Input:
  796. x : array of n floats
  797. y : array of n int in {0,1}
  798. m: integer, number of samples
  799. seed : int, seed for the random generator
  800. Return:
  801. array of mx(degree + 1)
  802. """
  803. def get_normal_theta(self, x, y, m, seed = 1977):
  804. x, y = self._validate_data(x, y)
  805. m = self._validate_sample_count(m)
  806. res = self.fit(x, y, method = "diff_evol", seed = seed)
  807. if not res["success"]:
  808. raise RuntimeError(f"Fit did not succeed: {res['message']}")
  809. # optimal parameters
  810. theta = res["theta"]
  811. # covariance matrix of parameters
  812. cov = self.get_cov(x, y, theta)
  813. # init random generator
  814. rng = np.random.default_rng(seed)
  815. return rng.multivariate_normal(theta, cov, size = m)
  816. """
  817. Generate m parameters via non-parametric bootstrapping with a minimal constraint
  818. that both groups should be present in the sampled data:
  819. boostrapped sample = (xb, yb) by sampling with replacement pairs (x_i, y_i)
  820. with condition that yb can not be just 0 or just 1
  821. Input:
  822. x : array of n floats
  823. y : array of n int in {0,1}
  824. m: integer, number of samples
  825. seed : int, seed for the random generator
  826. Return:
  827. array of mx(degree + 1)
  828. """
  829. def get_nonparam_boots_theta(
  830. self,
  831. x,
  832. y,
  833. m,
  834. seed = 1977,
  835. max_attempts = None,
  836. ):
  837. x, y = self._validate_data(x, y)
  838. m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
  839. theta0 = self._fit_bootstrap_reference(x, y, seed)
  840. n = len(x)
  841. rng = np.random.default_rng(seed)
  842. def sampler():
  843. idx = rng.choice(n, n, replace=True)
  844. return x[idx], y[idx]
  845. return self._collect_bootstrap_theta(
  846. sampler,
  847. theta0,
  848. m,
  849. max_attempts,
  850. )
  851. """
  852. Generate m parameters via non-parametric stratified bootstrapping:
  853. boostrapped sample = (xb, yb)
  854. xb = (sampled with replacement from x0, sampled with replacement from x1)
  855. yb = (0...0, 1...1)
  856. Input:
  857. x : array of n floats
  858. y : array of n int in {0,1}
  859. m: integer, number of samples
  860. seed : int, seed for the random generator
  861. Return:
  862. array of mx(degree + 1)
  863. """
  864. def get_nonparam_stratified_boots_theta(
  865. self,
  866. x,
  867. y,
  868. m,
  869. seed = 1977,
  870. max_attempts = None,
  871. ):
  872. x, y = self._validate_data(x, y)
  873. m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
  874. theta0 = self._fit_bootstrap_reference(x, y, seed)
  875. xs = [x[y == i] for i in range(2)]
  876. ns = [len(group) for group in xs]
  877. yb = np.concatenate(
  878. [np.full(ns[i], i, dtype=int) for i in range(2)]
  879. )
  880. rng = np.random.default_rng(seed)
  881. def sampler():
  882. xb = np.concatenate(
  883. [rng.choice(xs[i], ns[i], replace=True) for i in range(2)]
  884. )
  885. return xb, yb
  886. return self._collect_bootstrap_theta(
  887. sampler,
  888. theta0,
  889. m,
  890. max_attempts,
  891. )
  892. """
  893. Generate m parameters via parametric bootstrapping:
  894. boostrapped sample = (x, yb) yb ~ B(model(x, fitted theta))
  895. where B is Bernoulli distribution
  896. Input:
  897. x : array of n floats
  898. y : array of n int in {0,1}
  899. m: integer, number of samples
  900. seed : int, seed for the random generator
  901. Return:
  902. array of mx(degree + 1)
  903. Ref:
  904. * https://www.scirp.org/journal/paperinformation?paperid=70962
  905. * https://en.wikipedia.org/wiki/Bernoulli_distribution
  906. """
  907. def get_parametric_boots_theta(
  908. self,
  909. x,
  910. y,
  911. m,
  912. seed = 1977,
  913. max_attempts = None,
  914. ):
  915. x, y = self._validate_data(x, y)
  916. m, max_attempts = self._validate_bootstrap_request(m, max_attempts)
  917. theta0 = self._fit_bootstrap_reference(x, y, seed)
  918. p = self.model(x, theta0)
  919. rng = np.random.default_rng(seed)
  920. def sampler():
  921. y_sim = rng.binomial(n = 1, p = p)
  922. return x, y_sim
  923. return self._collect_bootstrap_theta(
  924. sampler,
  925. theta0,
  926. m,
  927. max_attempts,
  928. )