logit_utils_gen.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737
  1. import numpy as np
  2. import scipy
  3. import scipy.optimize
  4. import mono_cubic2 as mc
  5. def resize_with_const(v, n, val=0):
  6. """
  7. Resize a 1D vector to a specified length `n`.
  8. If the input vector `v` is longer than `n`, it is truncated.
  9. If it is shorter, it is padded with the constant value `val`.
  10. If it is already of length `n`, it is returned unchanged.
  11. Parameters:
  12. v (array-like): Input 1D vector (list or NumPy array).
  13. n (int): Target length of the output vector.
  14. val (scalar, optional): Value used to pad if `v` is shorter than `n`. Default is 0.
  15. Returns:
  16. np.ndarray: Resized 1D NumPy array of length `n`.
  17. """
  18. v = np.asarray(v)
  19. if len(v) == n: return v
  20. if len(v) > n: return v[:n]
  21. return np.concatenate([v, np.full(n - len(v), val)])
  22. def safe_exp(x, max_exp = 700):
  23. """
  24. A numerically robust version of np.exp that avoids overflow by clipping the input.
  25. Parameters:
  26. x : array_like
  27. Input value or array.
  28. max_exp : float
  29. Maximum allowed exponent value. np.exp(709) ≈ 8.2e307 (close to float64 max).
  30. Returns:
  31. array_like
  32. The exponential of the input with overflow protection.
  33. """
  34. return np.exp(np.clip(x, -max_exp, max_exp))
  35. def safe_expit(x, max_exp = 700): return 1/(1 + safe_exp(-x, max_exp))
  36. """
  37. Fitting data
  38. {(x_i, y_i) : x_i in R, y_i in {0,1}, i = 0, ..., n-1 }
  39. to model function
  40. f(x|pars) = 1/(1 + exp(-F(x|beta))) beta = beta(pars)
  41. with log odds of polynomial form:
  42. log(f(x|pars)/(1 - f(x|pars))) = F(x|beta) beta = beta(pars)
  43. where F is decision function (aka logit)
  44. F(x|beta) = sum_{i=0}^degree beta_i x^i
  45. and coefficients
  46. beta = [beta_i(pars)]_{i=0}^degrees
  47. Conditional probability
  48. Prob(Y = y_i|x, pars) = 1/(1 + exp(-s(y) F(x|beta(pars)))
  49. with
  50. s(y) = 2*y -1
  51. """
  52. class LogisticPolyRegression:
  53. """
  54. Constructor
  55. Input:
  56. degree: int, degree of polynomial
  57. mono: boolean, default False
  58. lambda: None or tuple float, L1 and L2 regularization
  59. """
  60. def __init__(self, degree = 1, mono = False, lam = None):
  61. self.degree = degree
  62. self.mono = mono
  63. self.big = 200
  64. self.small = 1e-8
  65. self.lam = lam
  66. if mono:
  67. assert self.degree in [1, 3], f"Degree {self.degree} not supported in mono!"
  68. self.mono3 = self.mono and (self.degree == 3)
  69. """
  70. Mapping regression parameters pars to coefficients beta
  71. beta = beta(pars)
  72. used in decision function:
  73. F(x|beta) = sum_{i=0}^degree beta_i x^i
  74. Input:
  75. pars
  76. Return:
  77. beta
  78. """
  79. def get_beta(self, pars):
  80. beta = mc.forward_map(pars) if self.mono3 else pars
  81. return np.array(beta)
  82. """
  83. Mapping beta to regression parameters used in decision function.
  84. Input:
  85. beta: coefficient beta
  86. Return:
  87. pars
  88. """
  89. def get_pars(self, beta):
  90. pars = mc.backward_map(beta) if self.mono3 else beta
  91. return np.array(pars)
  92. """
  93. Calculate jacobian between beta and regression parameters
  94. J = d(beta)/d(pars)
  95. = [d(beta_i)/d(pars_a)]_{i,a}
  96. and
  97. H = [d^2 beta_i/(d(pars_a) d(pars_b))]_{i,a,b}
  98. Input:
  99. pars
  100. hess: boolean, False
  101. Return:
  102. J if hess = True
  103. (J, H) if hess = False
  104. """
  105. def get_jac_beta(self, pars, hess = False):
  106. n = len(pars)
  107. J = mc.forward_map_jacobian(pars) if self.mono3 else np.eye(n)
  108. if not hess: return J
  109. H = mc.forward_map_hessian(pars) if self.mono3 else np.zeros(shape = (n, n, n))
  110. return (J, H)
  111. """
  112. Model function
  113. f(x|pars) = 1/(1 + exp(-F(x|beta))) beta = beta(pars)
  114. Input:
  115. x: scalar value or a array of values
  116. pars: array of r = degree + 1 floats, model parameters array of floats
  117. Return:
  118. model function values
  119. """
  120. def model(self, x, pars):
  121. beta = self.get_beta(pars) # beta
  122. X = np.column_stack([x**i for i in range(len(beta))])
  123. F = X @ beta # decision function, X beta
  124. return safe_expit(F)
  125. """
  126. Calculate negative log-likelihood function
  127. nllf = -sum_i log(Prob(Y = y_i| x_i, pars)) : neg. log likelihood
  128. grad = [d(nllf)/d(pars_a)]_a : jacobian
  129. where
  130. Prob(Y = y_i| x_i, pars) = 1/(1 + exp(-s_i F(x_i| beta))) beta=beta(pars)
  131. s_i = 2*y_i - 1
  132. Input:
  133. x: array of n floats
  134. y: array of n int in {0,1}
  135. pars: array of r = degree + 1 floats, model parameters array of floats
  136. jac: boolean, default False, if jacobian is needed
  137. Return:
  138. nllf : if jac is false
  139. (nllf, grad) : if jac is true
  140. """
  141. def nllf(self, x, y, pars, jac = False):
  142. beta = self.get_beta(pars)
  143. X = np.column_stack([x**i for i in range(len(beta))])
  144. # signs
  145. s = 2.0*y - 1
  146. # decision function for conditional probability Prob(Y = y| x)
  147. F = s*(X @ beta)
  148. nllf = np.sum(np.log(1 + safe_exp(-F)))
  149. if self.lam is not None:
  150. nllf += self.lam[0]*np.sum(np.abs(pars)) + self.lam[1]*np.sum(pars**2)
  151. if not jac: return nllf
  152. J = self.get_jac_beta(pars)
  153. grad = -(s*safe_expit(-F)) @ (X @ J)
  154. if self.lam is not None:
  155. grad += self.lam[0]*np.sign(pars) + 2*self.lam[1]*pars
  156. return (nllf, grad)
  157. """
  158. Estimate parameters.
  159. Input:
  160. x: array of n floats
  161. y: array of n int in {0,1}
  162. Return:
  163. pars0
  164. """
  165. def get_est_pars(self, x, y):
  166. L = np.log(2*len(x) + 1)
  167. if self.mono3:
  168. z = L*np.polyfit(x, 2.0*y - 1, 1)[::-1]
  169. beta = resize_with_const(z, self.degree + 1, 1e-8)
  170. else:
  171. beta = L*np.polyfit(x, 2.0*y - 1, self.degree)[::-1]
  172. return self.get_pars(beta)
  173. """
  174. Performing logistic regression with log odds of polynomial form:
  175. log(f(x|pars)/(1 - f(x|pars))) = F(x|beta) beta = beta(pars)
  176. and this gives
  177. f(x|pars) = 1/(1 + exp(-F(x|beta)))
  178. where coefficient beta = [beta_i(pars)]_{i=0}^degree, with decision
  179. function (aka logit)
  180. F(x|beta) = sum_{i=0}^degree beta_i x^i
  181. Input:
  182. x: array of n floats
  183. y: array of n int in {0, 1}
  184. Return:
  185. pars
  186. """
  187. def fit(self, x, y, pars0 = None, method = "local"):
  188. bnds = [(-self.big, self.big)]*(self.degree + 1)
  189. if method == "local":
  190. pars0 = self.get_est_pars(x, y)
  191. cost = lambda pars: self.nllf(x, y, pars, jac = True)
  192. res = scipy.optimize.minimize(cost, x0 = pars0, method = 'L-BFGS-B', jac = True, bounds = bnds, tol=1e-12)
  193. elif method == "diff_evol":
  194. cost = lambda pars: self.nllf(x, y, pars, jac = False)
  195. res = scipy.optimize.differential_evolution(cost, bounds = bnds, tol = 1e-8, polish=False)
  196. cost = lambda pars: self.nllf(x, y, pars, jac = True)
  197. res = scipy.optimize.minimize(cost, x0 = res.x, method = 'L-BFGS-B', jac = True, bounds = bnds, tol=1e-12)
  198. elif method == "anneal":
  199. cost = lambda pars: self.nllf(x, y, pars, jac = False)
  200. res = scipy.optimize.dual_annealing(cost, bounds = bnds)
  201. else:
  202. assert False, "This method is not supported."
  203. return {"pars": res.x, "cost": res.fun, "success": res.success}
  204. """
  205. Producing goodness of fit measures:
  206. LLF = log_likelihood function
  207. AIC = Akaike information criterion
  208. BIC = Bayesian information criterion
  209. Input:
  210. x: array of n floats
  211. y: array of n int in {0,1}
  212. pars: array of r = degree+1 floats, model parameters
  213. thresh: float, default 0.5, threshold value for classification
  214. Return:
  215. {"n": n, "k":k, "dof":n-k,
  216. "LLF": log_likelihood,
  217. "AIC": AIC,
  218. "BIC": BIC,
  219. "A": classification accuracy (threshold values = 0.5 prob)}
  220. Ref:
  221. https://en.wikipedia.org/wiki/Logistic_regression
  222. https://en.wikipedia.org/wiki/Akaike_information_criterion
  223. https://www.medicine.mcgill.ca/epidemiology/joseph/courses/epib-621/logfit.pdf
  224. """
  225. def goodness_of_fit(self, x, y, pars, thresh = 0.5):
  226. # model probabilities
  227. p = self.model(x, pars)
  228. # log likelihood
  229. llf = -self.nllf(x, y, pars)
  230. # information criteria
  231. k, n = len(pars), len(x)
  232. AIC = 2*k - 2*llf
  233. BIC = k*np.log(n) - 2*llf
  234. # chi2
  235. dof = n - k
  236. r = (y - p)/np.sqrt(p*(1-p) + self.small)
  237. chi2 = np.sum(r**2)
  238. p_val = scipy.stats.chi2.sf(chi2, dof)
  239. # using model as classifier
  240. matches = y == np.heaviside(p - thresh, 1)
  241. # accuracy A
  242. A = np.count_nonzero(matches)/n
  243. return {"LLF": llf,
  244. "AIC": AIC,
  245. "BIC": BIC,
  246. "A" : A,
  247. "chi2": chi2,
  248. "p-value(chi2)": p_val, # not very useful
  249. "n": n, "k": k, "dof": dof}
  250. """
  251. Calculation of asymptotic variance-covariance matrix of parameters pars
  252. cov_{asymp}[pars] = H^{-1}
  253. where H is hessian of nllf
  254. H = [d^2(nllf)/(d(pars)_a d(pars)_b ]_{a,b}
  255. for the logistic regression of the polynomial model:
  256. log(f(x)/(1 - f(x))) ~ sum_{i=0}^degree b_i(pars) x^i
  257. Input:
  258. x: array of n floats
  259. pars: array of r = degree+1 floats, model parameters
  260. Return:
  261. array of rxr floats; r = degree + 1
  262. Ref:
  263. https://stats.stackexchange.com/questions/89484/how-to-compute-the-standard-errors-of-a-logistic-regressions-coefficients
  264. https://goodboychan.github.io/machine_learning/2020/09/14/02-Regularized-likelihood-methods.html
  265. """
  266. def cov(self, x, y, pars):
  267. # coefficients
  268. beta = self.get_beta(pars)
  269. # design matrix -- add column of 1's at the beginning of your X_train matrix
  270. X = np.column_stack([x**i for i in range(len(beta))])
  271. # Jacobian J = [dbeta_i/dpars_j]_{ij}
  272. J, H = self.get_jac_beta(pars, hess = True)
  273. # signs
  274. s = 2.0*y - 1
  275. # decision function for conditional probability Prob(Y = y| x)
  276. F = s*(X @ beta)
  277. # probabilities p_i = P(Y=y_i | x_i)
  278. p = safe_expit(F)
  279. q = 1 - p
  280. # calculate hessian
  281. L = X @ J
  282. H = (L.T*(q*p))@L - np.tensordot((s*q)@X, H, axes = ([0], [0]))
  283. if self.lam is not None:
  284. Hp = H + 2*self.lam[1]*np.eye(len(pars)) # H' = H + lambda id
  285. iHp = np.linalg.inv(Hp) # inv(H')
  286. return iHp@H@iHp
  287. # covariance matrix C_params = H^-1
  288. return np.linalg.inv(H)
  289. """
  290. Calculating quantiles of the model parameters at given probabilities p
  291. for normal distribution of parameters:
  292. pars ~ N(mean_pars, cov_pars)
  293. Input:
  294. probs: array of m floats, probabilities
  295. mean_pars: array of r = degree+1 floats, mean model parameters
  296. cov_pars: array of rxr floats, variance-covariance matrix of parameters
  297. Return:
  298. array of mxr floats
  299. """
  300. def get_pars_quantiles_normal(self, probs, mean_pars, cov_pars):
  301. # mean and standard variance parameters
  302. locs = mean_pars
  303. scales = np.sqrt(np.diag(cov_pars))
  304. # computing quantiles of parameters
  305. return locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  306. """
  307. Calculating quantiles of the model values
  308. f(x|pars) = 1/(1 + exp(-F(x|beta))) beta = beta(pars)
  309. with
  310. F(x|beta) = sum_{i=0}^degree x^i beta_i
  311. at given probabilities p and values x assuming
  312. normal distribution of parameters:
  313. pars ~ N(mean_pars, cov_pars)
  314. This distribution is asymptotic MLE distribution of parameters.
  315. Input:
  316. x: array of n float
  317. probs: array of m floats, probabilities
  318. mean_pars: array of r = degree+1 floats, mean model parameters
  319. cov_pars: array of rxr floats, variance-covariance matrix of parameters
  320. Return:
  321. array of mxn floats
  322. """
  323. def get_model_quantiles_normal(self, x, probs, mean_pars, cov_pars,
  324. exact = True, seed = 1977, m = 10**5):
  325. mean_beta = self.get_beta(mean_pars)
  326. X = np.column_stack([x**i for i in range(len(mean_beta))])
  327. if exact and self.mono3:
  328. # init random generator
  329. rng = np.random.default_rng(seed)
  330. pars = rng.multivariate_normal(mean_pars, cov_pars, size = m)
  331. # get betas
  332. beta = np.apply_along_axis(self.get_beta, 1, pars)
  333. # quantiles of decision function
  334. Q = np.quantile(X@beta.T, probs, axis = 1)
  335. return np.apply_along_axis(safe_expit, 1, Q)
  336. # J = d(beta)/d(pars)
  337. J = self.get_jac_beta(mean_pars)
  338. # transform data
  339. S = X@J
  340. # mean and standard variance of logit (aka log of odds)
  341. locs = X@mean_beta
  342. scales = np.sqrt(np.diag(S@cov_pars@S.T))
  343. # computing quantiles of logit
  344. Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  345. # convert logit to expit
  346. return safe_expit(Q)
  347. """
  348. Calculating quantiles using delta method of the model values
  349. f(x|pars) = 1/(1 + exp(-F(x|beta))) beta = beta(pars)
  350. with
  351. F(x|beta) = sum_{i=0}^degree x^i beta_i
  352. at given probabilities p and values x assuming
  353. normal distribution of parameters :
  354. pars ~ N(mean_pars, cov_pars)
  355. This distribution is asymptotic MLE distribution of parameters.
  356. We approximate exact model with linear expansion
  357. f(x|pars) = p(x|pars_mean) + dp/db(x|pars_mean) (pars - pars_mean)
  358. and the last term is normally distributed.
  359. Input:
  360. x: array of n float
  361. probs: array of m floats, probabilities
  362. mean_pars: array of r = degree+1 floats, mean model parameters
  363. cov_pars: array of rxr floats, variance-covariance matrix of parameters
  364. Return:
  365. array of mxn floats
  366. """
  367. def get_model_quantiles_delta(self, x, probs, mean_pars, cov_pars):
  368. mean_beta = self.get_beta(mean_pars)
  369. X = np.column_stack([x**i for i in range(len(mean_beta))])
  370. F = X@mean_beta
  371. # J = d(beta)/d(pars)
  372. J = self.get_jac_beta(mean_pars)
  373. # S = d(F)/d(pars)
  374. S = X@J
  375. # attributes of normal distribution of model values
  376. locs = safe_expit(F)
  377. scales = np.sqrt(np.diag(S@cov_pars@S.T))/(4*np.cosh(F/2)**2)
  378. # computing quantiles of logit
  379. Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
  380. return np.clip(Q, a_min = 0, a_max = 1)
  381. """
  382. Generate parameters assuming normal distribution.
  383. Input:
  384. x : array of n floats
  385. y : array of n int in {0,1}
  386. m: integer, number of samples
  387. seed : int, seed for the random generator
  388. Return:
  389. array of mx(degree + 1)
  390. """
  391. def get_normal_pars(self, x, y, m, seed = 1977):
  392. res = self.fit(x, y, method = "diff_evol")
  393. assert res["success"], "Fit did not succeed."
  394. # optimal parameters
  395. pars = res["pars"]
  396. # covariance matrix of parameters
  397. cov = self.cov(x, y, pars)
  398. # init random generator
  399. rng = np.random.default_rng(seed)
  400. return rng.multivariate_normal(pars, cov, size = m)
  401. """
  402. Generate m parameters via non-parametric bootstrapping with a minimal constraint
  403. that both groups should be present in the sampled data:
  404. boostrapped sample = (xb, yb) by sampling with replacement pairs (x_i, y_i)
  405. with condition that yb can not be just 0 or just 1
  406. Input:
  407. x : array of n floats
  408. y : array of n int in {0,1}
  409. m: integer, number of samples
  410. seed : int, seed for the random generator
  411. Return:
  412. array of mx(degree + 1)
  413. """
  414. def get_nonparam_boots_pars(self, x, y, m, seed = 1977):
  415. # fitting original data
  416. res_fit = self.fit(x, y, method = "diff_evol")
  417. assert res_fit["success"]
  418. pars0 = res_fit["pars"]
  419. n = len(x)
  420. rng = np.random.default_rng(seed)
  421. # generate parameters
  422. lst = [pars0]
  423. while True:
  424. # create set indices for sampling with replacement + constraint
  425. idx = rng.choice(n, n)
  426. if np.sum(y[idx]) in [0, n]: continue
  427. # do fitting
  428. res_fit = self.fit(x[idx], y[idx], pars0, method="local")
  429. if not res_fit["success"]: continue
  430. # store pars
  431. lst.append(res_fit["pars"])
  432. if len(lst) == m: break
  433. return np.array(lst)
  434. """
  435. Generate m parameters via non-parametric stratified bootstrapping:
  436. boostrapped sample = (xb, yb)
  437. xb = (sampled with replacement from x0, sampled with replacement from x1)
  438. yb = (0...0, 1...1)
  439. Input:
  440. x : array of n floats
  441. y : array of n int in {0,1}
  442. m: integer, number of samples
  443. seed : int, seed for the random generator
  444. Return:
  445. array of mx(degree + 1)
  446. """
  447. def get_nonparam_stratified_boots_pars(self, x, y, m, seed = 1977):
  448. # pars of original data
  449. res_fit = self.fit(x, y, method = "diff_evol")
  450. assert res_fit["success"]
  451. pars0 = res_fit["pars"]
  452. # statistics about groups
  453. xs = [x[y == i] for i in range(2)]
  454. ns = [len(e) for e in xs]
  455. # common vector states
  456. yb = np.concatenate([np.full(ns[i], i) for i in range(2)])
  457. rng = np.random.default_rng(seed)
  458. # generate parameters
  459. lst = [pars0]
  460. for _ in range(m):
  461. # stratified sampling with replacement
  462. xb = np.concatenate([rng.choice(xs[i], ns[i]) for i in range(2)])
  463. # do fitting
  464. res_fit = self.fit(xb, yb, pars0, method="local")
  465. if not res_fit["success"]: continue
  466. # store pars
  467. lst.append(res_fit["pars"])
  468. if len(lst) == m: break
  469. return np.array(lst)
  470. """
  471. Generate m parameters via parametric bootstrapping:
  472. boostrapped sample = (x, yb) yb ~ B(model(x, fitted pars))
  473. where B is Bernoulli distribution
  474. Input:
  475. x : array of n floats
  476. y : array of n int in {0,1}
  477. m: integer, number of samples
  478. seed : int, seed for the random generator
  479. Return:
  480. array of mx(degree + 1)
  481. Ref:
  482. * https://www.scirp.org/journal/paperinformation?paperid=70962
  483. * https://en.wikipedia.org/wiki/Bernoulli_distribution
  484. """
  485. def get_parametric_boots_pars(self, x, y, m, seed = 1977):
  486. # first discuss original dataset
  487. res_fit = self.fit(x, y, method = "diff_evol")
  488. assert res_fit["success"]
  489. pars0 = res_fit["pars"]
  490. p = self.model(x, pars0)
  491. n = len(x)
  492. rng = np.random.default_rng(seed)
  493. # generate parameters
  494. lst = [pars0]
  495. while True:
  496. # Generate new binary outcomes from Bernoulli(p_i)
  497. y_sim = rng.binomial(n = 1, p = p)
  498. if np.sum(y_sim) in [0, n]: continue
  499. # do fitting
  500. res_fit = self.fit(x, y_sim, pars0, method="local")
  501. if not res_fit["success"]: continue
  502. # store pars
  503. lst.append(res_fit["pars"])
  504. if len(lst) == m: break
  505. return np.array(lst)