Quellcode durchsuchen

Adding monotonic cubic decision function case

Martin Horvat vor 1 Jahr
Ursprung
Commit
8c8e89e11e

+ 11 - 20
python/bayesian/bayesian.ipynb

@@ -106,7 +106,7 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "odds = 10.6\n",
+      "odds = 0.09433962264150944\n",
       "ns = [53, 5]\n"
      ]
     }
@@ -117,7 +117,7 @@
     "organ = \"lung\"\n",
     "\n",
     "x, y = data_utils.get_data(organ, perc, suv_dict, flags_dict)\n",
-    "odds = len(x[y == 0])/len(x[y == 1])  # Prob(Y=0)/Prob(Y=1)\n",
+    "odds = len(x[y == 1])/len(x[y == 0])  # Prob(Y=1)/Prob(Y=0)\n",
     "xs = [x[y == i] for i in range(2)]\n",
     "ns = [len(e) for e in xs]\n",
     "\n",
@@ -349,7 +349,7 @@
          "type": "integer"
         }
        ],
-       "ref": "595a6472-58d7-4b3b-b692-7af5ca6eccfa",
+       "ref": "4d5418c3-36ca-46c4-b408-d197447de6c9",
        "rows": [
         [
          "0",
@@ -485,7 +485,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 11,
+   "execution_count": null,
    "id": "8386c0d0",
    "metadata": {},
    "outputs": [
@@ -505,15 +505,6 @@
       "/home/horvat/venvs/base/lib/python3.12/site-packages/scipy/optimize/_numdiff.py:592: RuntimeWarning: invalid value encountered in subtract\n",
       "  df = fun(x1) - f0\n"
      ]
-    },
-    {
-     "name": "stdout",
-     "output_type": "stream",
-     "text": [
-      "boots:get_nonparam_strat_boots_pars\n",
-      "boots:get_param_boots_pars\n",
-      "boots:get_param_strat_boots_pars\n"
-     ]
     }
    ],
    "source": [
@@ -549,7 +540,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 12,
+   "execution_count": null,
    "id": "506a1d8d",
    "metadata": {},
    "outputs": [
@@ -582,7 +573,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 13,
+   "execution_count": null,
    "id": "95681175",
    "metadata": {},
    "outputs": [
@@ -646,7 +637,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 14,
+   "execution_count": null,
    "id": "1248787c",
    "metadata": {},
    "outputs": [],
@@ -673,7 +664,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 15,
+   "execution_count": null,
    "id": "a3fcebc3",
    "metadata": {},
    "outputs": [],
@@ -684,7 +675,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 16,
+   "execution_count": null,
    "id": "2a85bf04",
    "metadata": {},
    "outputs": [
@@ -708,7 +699,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 17,
+   "execution_count": null,
    "id": "b257da7f",
    "metadata": {},
    "outputs": [
@@ -854,7 +845,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 18,
+   "execution_count": null,
    "id": "57a50c56",
    "metadata": {},
    "outputs": [

+ 7 - 5
python/bayesian/bayesian.py

@@ -36,15 +36,15 @@ def setup_scipy_distr(distr_str, n_pars, parse_pars):
         Prob(X|Y = 1) 
             = Prob(Y=1)p(X|Y=1)/(Prob(Y=0) p(X|Y=0) + Prob(Y=1) p(X|Y=1))
             = 1 /(1 + O p(X|Y=0)/p(X|Y=1)) 
-            = 1/(1 + exp( -F))
+            = 1/(1 + exp(-F))
 
     where F is the decision function
 
-        F =  log(p(X|Y=1)) - log(p(X|Y=0)) - log(O)
+        F =  log(p(X|Y=1)) - log(p(X|Y=0)) + log(O)
     
     and O are the odds
 
-        O = Prob(Y=0)/Prob(Y=1)
+        O = Prob(Y=1)/Prob(Y=0)
 """
 class BayesianModelRegression:
     
@@ -74,11 +74,12 @@ class BayesianModelRegression:
     """
         Calculate decision function:
 
-             decision = log(p(X|Y=1)) - log(p(X|Y=0)) - log(odds)
+             decision = log(p(X|Y=1)) - log(p(X|Y=0)) + log(odds)
 
         Input:
             x: float or array of floats
             pars: parameters for log_pdfs
+
         Return:
             float or array of float
     """
@@ -87,10 +88,11 @@ class BayesianModelRegression:
         # log of pdf for each group
         lf0, lf1 = self.log_pdfs(x, pars)
         
-        return lf1 - lf0 - self.log_odds
+        return lf1 - lf0 + self.log_odds
     
     """
         Calculate model of the conditional probability Prob(X|Y = 1)
+        
         Input:
             x: float or array of floats
             pars: parameters for log_pdfs

BIN
python/bayesian/results/bayesian_fit.pdf


Datei-Diff unterdrückt, da er zu groß ist
+ 598 - 25
python/logistic/logit_reg_fit.ipynb


Datei-Diff unterdrückt, da er zu groß ist
+ 879 - 0
python/logistic/logit_reg_fit_gen.ipynb


+ 30 - 20
python/logistic/logit_utils.py

@@ -2,6 +2,8 @@ import numpy as np
 import scipy
 import scipy.stats
 
+from sklearn import linear_model
+
 """
     Model function 
 
@@ -49,20 +51,22 @@ def logit_poly_model(x, b):
     Input:
         lm: instance linear_model.LogisticRegression
         x: array of n floats
-        y: array of n floats
+        y: array of n int in {0,1}
         d: degree of decision function
     
     Return:
         params = [b_0, ..., b_degree], array of r = degree + 1 floats
 """
 
-def logit_poly_fit(lm, x, y, degree = 1):
+def logit_poly_fit(x, y, degree = 1, lm  = None):
    
-   X_feature = np.column_stack([x**i for i in range(1, degree+1)])
+    if lm is None: lm = linear_model.LogisticRegression(penalty = None)
 
-   lm.fit(X_feature, y)
+    X_feature = np.column_stack([x**i for i in range(1, degree+1)])
 
-   return np.r_[lm.intercept_[0], lm.coef_[0,:]]
+    lm.fit(X_feature, y)
+
+    return np.r_[lm.intercept_[0], lm.coef_[0,:]]
 
 """
     Producing goodness of fit measures:
@@ -263,26 +267,28 @@ def logit_poly_model_quantiles_delta(x, probs, mean_b, cov):
     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:
-        lm: linear_model.LogisticRegression
+    Input:
         x : array of n floats
         y : array of n int in {0,1}
         m: integer, number of samples
         degree: int,  degree of decision function
         seed : int, seed for the random generator
-    
+        lm: linear_model.LogisticRegression
+  
     Return:
         array of mx(degree + 1)
     
     Return:
         array of mx(degree + 1)
 """
-def get_nonparam_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
+def get_nonparam_boots_pars(x, y, m, degree = 1, seed = 1977, lm = None):
+
+    if lm is None: lm = linear_model.LogisticRegression(penalty = None)
 
     rng = np.random.default_rng(seed)
     
     # fitting original data
-    pars = logit_poly_fit(lm, x, y, degree = degree)
+    pars = logit_poly_fit(x, y, degree = degree, lm = lm)
     
     n = len(x)
 
@@ -294,7 +300,7 @@ def get_nonparam_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
         idx = rng.choice(n, n)
         if np.sum(y[idx]) in [0, n]: continue
 
-        lst.append(logit_poly_fit(lm, x[idx], y[idx], degree=degree))
+        lst.append(logit_poly_fit(x[idx], y[idx], degree=degree, lm = lm))
         if len(lst) == m: break
 
     return np.array(lst)
@@ -309,22 +315,24 @@ def get_nonparam_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
             yb = (0...0, 1...1)
 
     Input: 
-        lm: linear_model.LogisticRegression
         x : array of n floats
         y : array of n int in {0,1}
         m: integer, number of samples
         degree: int,  degree of decision function
         seed : int, seed for the random generator
+        lm: linear_model.LogisticRegression
     
     Return:
         array of mx(degree + 1)
 """
-def get_nonparam_stratified_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
+def get_nonparam_stratified_boots_pars(x, y, m, degree = 1, seed = 1977, lm = None):
+
+    if lm is None: lm = linear_model.LogisticRegression(penalty = None)
 
     rng = np.random.default_rng(seed)
 
     # pars of original data
-    pars = logit_poly_fit(lm, x, y, degree = degree)
+    pars = logit_poly_fit(x, y, degree = degree, lm = lm)
     
     # statistics about groups
     xs = [x[y == i] for i in range(2)]
@@ -339,7 +347,7 @@ def get_nonparam_stratified_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
         # stratified sampling with replacement
         xb = np.concatenate([rng.choice(xs[i], ns[i]) for i in range(2)])
         # do fitting
-        lst.append(logit_poly_fit(lm, xb, yb, degree = degree))
+        lst.append(logit_poly_fit(xb, yb, degree = degree, lm = lm))
 
     return np.array(lst)
 
@@ -351,13 +359,13 @@ def get_nonparam_stratified_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
     where B is Bernoulli distribution
 
     Input: 
-        lm: linear_model.LogisticRegression
         x : array of n floats
         y : array of n int in {0,1}
         m: integer, number of samples
         degree: int,  degree of decision function
         seed : int, seed for the random generator
-
+        lm: linear_model.LogisticRegression
+    
     Return:
         array of mx(degree + 1)
 
@@ -366,10 +374,12 @@ def get_nonparam_stratified_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
       * https://www.scirp.org/journal/paperinformation?paperid=70962
       * https://en.wikipedia.org/wiki/Bernoulli_distribution
 """
-def get_parametric_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
+def get_parametric_boots_pars(x, y, m, degree = 1, seed = 1977, lm = None):
+
+    if lm is None: lm = linear_model.LogisticRegression(penalty = None)
 
     # first discuss original dataset
-    pars = logit_poly_fit(lm, x, y, degree = degree)
+    pars = logit_poly_fit(x, y, degree = degree, lm = lm)
     p = logit_poly_model(x, pars)
 
     rng = np.random.default_rng(seed)
@@ -384,7 +394,7 @@ def get_parametric_boots_pars(lm, x, y, m, degree = 1, seed = 1977):
 
         if np.sum(y_sim) in [0, n]: continue
         
-        lst.append(logit_poly_fit(lm, x, y_sim, degree = degree))
+        lst.append(logit_poly_fit(x, y_sim, degree = degree, lm = lm))
 
         if len(lst) == m: break
 

+ 737 - 0
python/logistic/logit_utils_gen.py

@@ -0,0 +1,737 @@
+import numpy as np
+import scipy
+import scipy.optimize
+
+import mono_cubic2 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)])
+
+def safe_exp(x, max_exp = 700):
+    """
+    A numerically robust version of np.exp that avoids overflow by clipping the input.
+    
+    Parameters:
+        x : array_like
+            Input value or array.
+        max_exp : float
+            Maximum allowed exponent value. np.exp(709) ≈ 8.2e307 (close to float64 max).
+    
+    Returns:
+        array_like
+            The exponential of the input with overflow protection.
+    """
+
+    return np.exp(np.clip(x, -max_exp, max_exp))
+
+def safe_expit(x, max_exp = 700): return 1/(1 + safe_exp(-x, max_exp))
+
+"""
+    Fitting data
+
+        {(x_i, y_i) : x_i in R, y_i in {0,1}, i = 0, ..., n-1 }
+    
+    to model function 
+
+        f(x|pars) = 1/(1 + exp(-F(x|beta)))         beta = beta(pars)
+
+    with log odds of polynomial form:
+
+        log(f(x|pars)/(1 - f(x|pars))) = F(x|beta)  beta = beta(pars)
+
+    where F is decision function (aka logit)
+        
+        F(x|beta) = sum_{i=0}^degree beta_i x^i
+                
+    and coefficients
+        
+        beta = [beta_i(pars)]_{i=0}^degrees
+    
+    Conditional probability
+
+        Prob(Y = y_i|x, pars) = 1/(1 + exp(-s(y) F(x|beta(pars))) 
+
+    with
+
+            s(y) = 2*y -1
+"""
+class LogisticPolyRegression:
+
+    """
+        Constructor
+
+        Input:
+            degree: int, degree of polynomial
+            mono: boolean, default False
+            lambda: None or tuple float, L1 and L2 regularization
+
+    """
+    def __init__(self, degree = 1, mono = False, lam = None):
+
+        self.degree = degree
+        self.mono = mono
+        self.big = 200
+        self.small = 1e-8
+        self.lam = lam
+
+        if mono:
+            assert self.degree in [1, 3], f"Degree {self.degree} not supported in mono!"
+
+        self.mono3 = self.mono and (self.degree == 3)
+
+    """
+        Mapping regression parameters pars to coefficients beta 
+            
+            beta = beta(pars)
+        
+        used in decision function:
+
+            F(x|beta) = sum_{i=0}^degree beta_i x^i
+
+        Input:
+            pars
+        
+        Return:
+            beta
+    """
+    def get_beta(self, pars):
+        
+        beta = mc.forward_map(pars) if self.mono3 else pars
+        return np.array(beta)
+    
+    """
+        Mapping beta to regression parameters used in decision function.
+
+        Input:
+            beta: coefficient beta
+        
+        Return:
+            pars
+    """
+    def get_pars(self, beta):
+
+        pars = mc.backward_map(beta) if self.mono3 else beta
+        return np.array(pars)
+
+    """
+        Calculate jacobian between beta and  regression parameters
+            
+            J = d(beta)/d(pars)
+              = [d(beta_i)/d(pars_a)]_{i,a}
+        and
+            
+            H = [d^2 beta_i/(d(pars_a) d(pars_b))]_{i,a,b}
+
+        Input:
+            pars
+            hess: boolean, False
+        
+        Return:
+            J       if hess = True
+            (J, H)  if hess = False
+    """
+    def get_jac_beta(self, pars, hess = False):
+        
+        n = len(pars)
+            
+        J = mc.forward_map_jacobian(pars) if self.mono3 else np.eye(n)
+            
+        if not hess: return J
+        
+        H = mc.forward_map_hessian(pars) if self.mono3 else np.zeros(shape = (n, n, n))
+
+        return (J, H)
+
+
+    """
+        Model function 
+
+            f(x|pars) = 1/(1 + exp(-F(x|beta)))  beta = beta(pars)
+
+        Input:
+            x: scalar value or a array of values
+            pars: array of r = degree + 1 floats, model parameters array of floats
+
+        Return:
+            model function values 
+    """
+    def model(self, x, pars):
+
+        beta = self.get_beta(pars)  # beta
+        X = np.column_stack([x**i for i in range(len(beta))])
+        F = X @ beta              # decision function, X beta
+
+        return safe_expit(F)
+
+    """
+        Calculate negative log-likelihood function
+
+            nllf = -sum_i log(Prob(Y = y_i| x_i, pars))   : neg. log likelihood
+            
+            grad = [d(nllf)/d(pars_a)]_a                  : jacobian
+        
+        where
+
+            Prob(Y = y_i| x_i, pars) = 1/(1 + exp(-s_i F(x_i| beta)))  beta=beta(pars)
+            s_i = 2*y_i  - 1
+     
+        Input:
+            x: array of n floats
+            y: array of n int in {0,1}  
+            pars: 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 nllf(self, x, y, pars, jac = False):
+
+        beta = self.get_beta(pars)
+        X = np.column_stack([x**i for i in range(len(beta))])
+    
+        # signs
+        s = 2.0*y - 1
+        
+        # decision function for conditional probability Prob(Y = y| x)
+        F = s*(X @ beta)
+
+        nllf = np.sum(np.log(1 + safe_exp(-F))) 
+
+        if self.lam is not None:
+            nllf += self.lam[0]*np.sum(np.abs(pars)) + self.lam[1]*np.sum(pars**2)
+
+        if not jac: return nllf
+        
+        J = self.get_jac_beta(pars)
+
+        grad = -(s*safe_expit(-F)) @ (X @ J)
+
+        if self.lam is not None:
+            grad += self.lam[0]*np.sign(pars) + 2*self.lam[1]*pars
+
+        return (nllf, grad)
+    
+    """
+        Estimate parameters.
+
+        Input:
+            x: array of n floats
+            y: array of n int in {0,1}
+        
+        Return:
+            pars0
+    """
+    def get_est_pars(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_pars(beta)
+    
+    """
+        Performing logistic regression with log odds of polynomial form:
+
+            log(f(x|pars)/(1 - f(x|pars))) = F(x|beta)  beta = beta(pars)
+        
+        and this gives           
+            
+            f(x|pars) = 1/(1 + exp(-F(x|beta)))
+
+        where coefficient beta = [beta_i(pars)]_{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:
+            pars
+    """
+    def fit(self, x, y, pars0 = None, method = "local"):
+        
+        bnds = [(-self.big, self.big)]*(self.degree + 1)
+  
+        if method == "local":
+
+            pars0 = self.get_est_pars(x, y)
+            cost = lambda pars: self.nllf(x, y, pars, jac = True)
+            res = scipy.optimize.minimize(cost, x0 = pars0, method = 'L-BFGS-B', jac = True, bounds = bnds, tol=1e-12)
+
+        elif method == "diff_evol":
+            
+            cost = lambda pars: self.nllf(x, y, pars, jac = False)
+            res = scipy.optimize.differential_evolution(cost, bounds = bnds, tol = 1e-8, polish=False)
+
+            cost = lambda pars: self.nllf(x, y, pars, jac = True)
+            res = scipy.optimize.minimize(cost, x0 = res.x, method = 'L-BFGS-B', jac = True, bounds = bnds, tol=1e-12)
+
+        elif method == "anneal":
+
+            cost = lambda pars: self.nllf(x, y, pars, jac = False)
+            res = scipy.optimize.dual_annealing(cost, bounds = bnds)
+        
+        else:
+            assert False, "This method is not supported."
+        
+        return {"pars": res.x, "cost": res.fun, "success": res.success}
+
+    """
+        Producing goodness of fit measures:
+        
+            LLF = log_likelihood function
+            AIC = Akaike information criterion
+            BIC = Bayesian information criterion
+
+        Input:
+            x: array of n floats
+            y: array of n int in {0,1}
+            pars: array of r = degree+1 floats, model parameters
+            thresh: float, default 0.5, threshold value for classification
+
+        Return:
+            {"n": n, "k":k, "dof":n-k, 
+            "LLF": log_likelihood, 
+            "AIC": AIC, 
+            "BIC": BIC, 
+            "A": classification accuracy (threshold values = 0.5 prob)}
+        
+        Ref:
+            https://en.wikipedia.org/wiki/Logistic_regression
+            https://en.wikipedia.org/wiki/Akaike_information_criterion
+            https://www.medicine.mcgill.ca/epidemiology/joseph/courses/epib-621/logfit.pdf
+    """
+    def goodness_of_fit(self, x, y, pars, thresh = 0.5):
+        
+        # model probabilities
+        p = self.model(x, pars)
+
+        # log likelihood
+        llf = -self.nllf(x, y, pars)
+        
+        # information criteria
+        k, n = len(pars), len(x)
+        AIC = 2*k - 2*llf
+        BIC = k*np.log(n) - 2*llf
+
+        # chi2
+        dof = n - k
+        r = (y - p)/np.sqrt(p*(1-p) + self.small)
+        chi2 = np.sum(r**2)
+        p_val = scipy.stats.chi2.sf(chi2, dof)
+
+        # 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, 
+                "chi2": chi2, 
+                "p-value(chi2)": p_val,  # not very useful
+                "n": n, "k": k, "dof": dof}
+
+    """
+        Calculation of asymptotic variance-covariance matrix of parameters pars
+
+           cov_{asymp}[pars] = H^{-1}
+
+        where H is hessian of nllf 
+
+           H = [d^2(nllf)/(d(pars)_a d(pars)_b ]_{a,b}
+        
+        for the logistic regression of the polynomial model:
+        
+            log(f(x)/(1 - f(x))) ~ sum_{i=0}^degree b_i(pars) x^i
+
+        Input:
+            x: array of n floats
+            pars: array of r = degree+1 floats, model parameters
+            
+        Return:
+            array of rxr floats; r = degree + 1
+        
+        Ref:
+            https://stats.stackexchange.com/questions/89484/how-to-compute-the-standard-errors-of-a-logistic-regressions-coefficients
+            https://goodboychan.github.io/machine_learning/2020/09/14/02-Regularized-likelihood-methods.html
+    """
+    def cov(self, x, y, pars):
+        
+        # coefficients
+        beta = self.get_beta(pars)
+        
+        # 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/dpars_j]_{ij}
+        J, H = self.get_jac_beta(pars, hess = True)
+
+        # signs
+        s = 2.0*y - 1
+        
+        # decision function for conditional probability Prob(Y = y| x)
+        F = s*(X @ beta)
+
+        # probabilities p_i = P(Y=y_i | x_i)
+        p = safe_expit(F)
+        q = 1 - p
+
+        # calculate hessian
+        L = X @ J
+        H = (L.T*(q*p))@L - np.tensordot((s*q)@X, H, axes = ([0], [0]))
+
+        if self.lam is not None: 
+            Hp = H + 2*self.lam[1]*np.eye(len(pars))   # H' = H + lambda id 
+            iHp = np.linalg.inv(Hp)                    # inv(H')
+
+            return iHp@H@iHp
+        
+        # covariance matrix C_params = H^-1
+        return np.linalg.inv(H)
+
+
+    """
+        Calculating quantiles of the model parameters at given probabilities p 
+        for normal distribution of parameters:
+
+            pars ~ N(mean_pars, cov_pars)
+        
+        Input:
+            probs: array of m floats, probabilities
+            mean_pars: array of r = degree+1 floats, mean model parameters
+            cov_pars: array of rxr floats, variance-covariance matrix of parameters
+        
+        Return:
+            array of mxr floats
+    """
+    def get_pars_quantiles_normal(self, probs, mean_pars, cov_pars):
+        
+        # mean and standard variance parameters
+        locs = mean_pars
+        scales = np.sqrt(np.diag(cov_pars))
+
+        # computing quantiles of parameters
+        return locs + np.outer(scipy.stats.norm.ppf(probs), scales)
+
+    """
+        Calculating quantiles of the model values 
+        
+            f(x|pars) = 1/(1 + exp(-F(x|beta)))     beta = beta(pars)
+        
+        with
+
+            F(x|beta) = sum_{i=0}^degree x^i beta_i
+            
+        at given probabilities p and values x assuming 
+        normal distribution of parameters:
+
+            pars ~ N(mean_pars, cov_pars)
+        
+        This distribution is asymptotic MLE distribution of parameters.
+
+        Input:
+            x: array of n float
+            probs: array of m floats, probabilities
+            mean_pars: array of r = degree+1 floats, mean model parameters
+            cov_pars: array of rxr floats, variance-covariance matrix of parameters
+        
+        Return:
+            array of mxn floats
+    """
+
+    def get_model_quantiles_normal(self, x, probs, mean_pars, cov_pars, 
+                                   exact = True, seed = 1977, m = 10**5):
+        
+        mean_beta = self.get_beta(mean_pars)
+        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)
+            pars = rng.multivariate_normal(mean_pars, cov_pars, size = m)
+
+            # get betas
+            beta = np.apply_along_axis(self.get_beta, 1, pars)
+            
+            # quantiles of decision function
+            Q = np.quantile(X@beta.T, probs, axis = 1)
+            
+            return np.apply_along_axis(safe_expit, 1, Q)
+
+        # J = d(beta)/d(pars)
+        J = self.get_jac_beta(mean_pars)
+        
+        # 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_pars@S.T))
+
+        # computing quantiles of logit
+        Q = locs + np.outer(scipy.stats.norm.ppf(probs), scales)
+        
+        # convert logit to expit
+        return safe_expit(Q)
+
+    """
+        Calculating quantiles using delta method of the model values 
+        
+            f(x|pars) = 1/(1 + exp(-F(x|beta))) beta = beta(pars)
+
+        with
+
+            F(x|beta) = sum_{i=0}^degree x^i beta_i
+
+        at given probabilities p and values x assuming 
+        normal distribution of parameters :
+
+            pars ~ N(mean_pars, cov_pars)
+
+        This distribution is asymptotic MLE distribution of parameters.
+        We approximate exact model with linear expansion
+
+            f(x|pars) = p(x|pars_mean) + dp/db(x|pars_mean) (pars - pars_mean)
+        
+        and the last term is normally distributed.
+
+        Input:
+            x: array of n float
+            probs: array of m floats, probabilities
+            mean_pars: array of r = degree+1 floats, mean model parameters
+            cov_pars: array of rxr floats, variance-covariance matrix of parameters 
+        
+        Return:
+            array of mxn floats
+    """
+    def get_model_quantiles_delta(self, x, probs, mean_pars, cov_pars):
+        
+        mean_beta = self.get_beta(mean_pars)
+        X = np.column_stack([x**i for i in range(len(mean_beta))])
+        F = X@mean_beta
+
+        # J = d(beta)/d(pars)
+        J = self.get_jac_beta(mean_pars)
+
+        # S = d(F)/d(pars)
+        S = X@J
+
+        # attributes of normal distribution of model values
+        locs = safe_expit(F)
+        scales = np.sqrt(np.diag(S@cov_pars@S.T))/(4*np.cosh(F/2)**2)
+
+        # 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_pars(self, x, y, m, seed = 1977):
+        
+        res = self.fit(x, y, method = "diff_evol")
+
+        assert res["success"], "Fit did not succeed."
+
+        # optimal parameters
+        pars = res["pars"]
+
+        # covariance matrix of parameters
+        cov = self.cov(x, y, pars)
+
+        # init random generator
+        rng = np.random.default_rng(seed)
+
+        return rng.multivariate_normal(pars, 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_pars(self, x, y, m, seed = 1977):
+        
+        # fitting original data
+        res_fit = self.fit(x, y, method = "diff_evol")
+        assert res_fit["success"]
+
+        pars0 = res_fit["pars"]
+
+        n = len(x)
+
+        rng = np.random.default_rng(seed)
+
+        # generate parameters
+        lst = [pars0]
+        while True:
+
+            # create set indices for sampling with replacement + constraint
+            idx = rng.choice(n, n)
+            if np.sum(y[idx]) in [0, n]: continue
+
+            # do fitting 
+            res_fit = self.fit(x[idx], y[idx], pars0, method="local")
+            if not res_fit["success"]: continue
+            
+            # store pars
+            lst.append(res_fit["pars"])
+            if len(lst) == m: break
+
+        return np.array(lst)
+
+
+    """
+        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_pars(self, x, y, m, seed = 1977):
+
+        # pars of original data
+        res_fit = self.fit(x, y, method = "diff_evol")
+        assert res_fit["success"]
+
+        pars0 = res_fit["pars"]
+        
+        # statistics about groups
+        xs = [x[y == i] for i in range(2)]
+        ns = [len(e) for e in xs]
+
+        # common vector states
+        yb = np.concatenate([np.full(ns[i], i) for i in range(2)])
+
+        rng = np.random.default_rng(seed)
+
+        # generate parameters
+        lst = [pars0]
+        for _ in range(m):
+            # stratified sampling with replacement
+            xb = np.concatenate([rng.choice(xs[i], ns[i]) for i in range(2)])
+
+            # do fitting 
+            res_fit = self.fit(xb, yb, pars0, method="local")
+            if not res_fit["success"]: continue
+            
+            # store pars
+            lst.append(res_fit["pars"])
+            if len(lst) == m: break
+
+        return np.array(lst)
+
+    """
+        Generate m parameters via parametric bootstrapping:
+
+            boostrapped sample = (x, yb) yb ~ B(model(x, fitted pars))  
+        
+        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_pars(self, x, y, m, seed = 1977):
+
+        # first discuss original dataset
+        res_fit = self.fit(x, y, method = "diff_evol")
+        assert res_fit["success"]
+
+        pars0 = res_fit["pars"]
+
+        p = self.model(x, pars0)
+    
+        n = len(x)
+        
+        rng = np.random.default_rng(seed)
+
+        # generate parameters
+        lst = [pars0]
+        while True:
+
+            # Generate new binary outcomes from Bernoulli(p_i)
+            y_sim = rng.binomial(n = 1, p = p)
+            if np.sum(y_sim) in [0, n]: continue
+            
+            # do fitting 
+            res_fit = self.fit(x, y_sim, pars0, method="local")
+            if not res_fit["success"]: continue
+            
+            # store pars
+            lst.append(res_fit["pars"])
+            if len(lst) == m: break
+
+        return np.array(lst)

+ 120 - 0
python/logistic/mono_cubic1.py

@@ -0,0 +1,120 @@
+# Function supporting monotonic cubic polynomials: first parametrization
+# We define the polynomial:
+#       poly(x) = sum_i beta_i * x^i
+# where the coefficients beta_i are parameterized by the vector 'pars'.
+
+import numpy as np
+
+def forward_map(pars, small=1e-8):
+    """
+    Map parameter vector pars to coefficients beta.
+
+    beta = [p0, (p2^2 + p1^2) / a, p2, p3^2],
+    where a = 3*p3^2 + small.
+    """
+    pars = np.asarray(pars, dtype=float)
+    p0, p1, p2, p3 = pars
+    a = 3.0 * p3**2 + small
+    beta = np.array([
+        p0,
+        (p2**2 + p1**2) / a,
+        p2,
+        p3**2
+    ], dtype=float)
+    return beta
+
+def backward_map(beta, small=1e-8):
+    """
+    Map coefficients beta back to parameter vector pars.
+    Nonnegative roots are used for p1 and p3.
+    """
+    beta = np.asarray(beta, dtype=float)
+    b0, b1, b2, b3 = beta
+    p0 = b0
+    p3 = np.sqrt(b3)
+    a = 3.0 * b3 + small
+    p2 = b2
+    p1_sq = b1 * a - p2**2
+    if p1_sq < 0:
+        raise ValueError(f"Negative square root encountered for p1^2 = {p1_sq}")
+    p1 = np.sqrt(p1_sq)
+    return np.array([p0, p1, p2, p3], dtype=float)
+
+def forward_map_jacobian(pars, small=1e-8):
+    """
+    Compute Jacobian of forward_map at given pars.
+    Returns J: d(beta)/d(pars), shape (4, 4).
+    """
+    p0, p1, p2, p3 = np.asarray(pars, dtype=float)
+    a = 3.0 * p3**2 + small
+    J = np.zeros((4, 4), dtype=float)
+
+    # beta0 row
+    J[0, 0] = 1.0
+
+    # beta1 row
+    J[1, 1] = 2.0 * p1 / a
+    J[1, 2] = 2.0 * p2 / a
+    J[1, 3] = -6.0 * p3 * (p2**2 + p1**2) / a**2
+
+    # beta2 row
+    J[2, 2] = 1.0
+
+    # beta3 row
+    J[3, 3] = 2.0 * p3
+
+    return J
+
+def forward_map_hessian(pars, small=1e-8):
+    """
+    Compute Hessians of forward_map at given pars.
+
+    Returns H: shape (4, 4, 4),
+    where H[i] is the 4x4 Hessian of beta[i] w.r.t. pars.
+    """
+    p0, p1, p2, p3 = np.asarray(pars, dtype=float)
+    a = 3.0 * p3**2 + small
+    N = p1**2 + p2**2
+    H = np.zeros((4, 4, 4), dtype=float)
+
+    # beta0: all zeros
+
+    # beta1 Hessian
+    H1 = np.zeros((4, 4), dtype=float)
+    H1[1, 1] = 2.0 / a
+    H1[2, 2] = 2.0 / a
+    H1[1, 3] = -12.0 * p1 * p3 / a**2
+    H1[3, 1] = H1[1, 3]
+    H1[2, 3] = -12.0 * p2 * p3 / a**2
+    H1[3, 2] = H1[2, 3]
+    H1[3, 3] = 6.0 * N * (12.0 * p3**2 - a) / a**3
+    H[1] = H1
+
+    # beta2: all zeros
+
+    # beta3 Hessian
+    H3 = np.zeros((4, 4), dtype=float)
+    H3[3, 3] = 2.0
+    H[3] = H3
+
+    return H
+
+
+# -------------------------
+# Round-trip test
+# -------------------------
+if __name__ == "__main__":
+    pars_original = np.array([1.0, 2.0, 3.0, 4.0])
+    beta = forward_map(pars_original)
+    pars_recovered = backward_map(beta)
+
+    print("Original pars: ", pars_original)
+    print("Beta:          ", beta)
+    print("Recovered pars:", pars_recovered)
+    print("Difference:    ", pars_recovered - pars_original)
+
+    print("\nJacobian at pars:")
+    print(forward_map_jacobian(pars_original))
+
+    print("\nHessian for beta1:")
+    print(forward_map_hessian(pars_original)[1])

+ 196 - 0
python/logistic/mono_cubic2.py

@@ -0,0 +1,196 @@
+# Function supporting monotonic cubic polynomials: first parametrization
+# We define the polynomial:
+#       poly(x) = sum_i beta_i * x^i
+# where the coefficients beta_i are parameterized by the vector 'pars'.
+
+import numpy as np
+
+# Forward map: Parameters to polynomial coefficients
+def forward_map(pars):
+    """
+    Converts parameters (pars = [C, epsilon, k1, k2]) into polynomial coefficients (beta = [d, c, b, a]).
+    
+    Polynomial Definition:
+    - d = C: Constant term of the polynomial.
+    - c = k2^2 + epsilon^2: Coefficient of the linear term.
+    - b = k1 * k2: Coefficient of the quadratic term.
+    - a = k1**2 / 3: Coefficient of the cubic term.
+
+    Parameters:
+    - pars: NumPy array of parameters [C, epsilon, k1, k2].
+
+    Returns:
+    - beta: NumPy array of polynomial coefficients [d, c, b, a].
+    """
+    C, epsilon, k1, k2 = pars  # Unpack the parameter vector
+    
+    # Compute coefficients
+    d = C
+    c = k2**2 + epsilon**2
+    b = k1 * k2
+    a = k1**2 / 3
+    
+    # Return coefficients as a NumPy array
+    beta = np.array([d, c, b, a])
+    return beta
+
+
+# Jacobian of the forward map: First-order derivatives
+def forward_map_jacobian(pars):
+    """
+    Computes the Jacobian matrix of the forward map analytically.
+    
+    Parameters:
+    - pars: NumPy array of parameters [C, epsilon, k1, k2].
+    
+    Returns:
+    - J: NumPy 4x4 Jacobian matrix, where J[i, j] = d(beta[i])/d(pars[j]).
+    """
+    C, epsilon, k1, k2 = pars  # Unpack parameters
+    
+    # Initialize Jacobian matrix
+    J = np.zeros((4, 4))  # 4x4 matrix
+    
+    # Partial derivatives for d = C
+    J[0, 0] = 1  # d(d)/dC
+    J[0, 1] = 0  # d(d)/d(epsilon)
+    J[0, 2] = 0  # d(d)/d(k1)
+    J[0, 3] = 0  # d(d)/d(k2)
+
+    # Partial derivatives for c = k2^2 + epsilon^2
+    J[1, 0] = 0  # d(c)/dC
+    J[1, 1] = 2 * epsilon  # d(c)/d(epsilon)
+    J[1, 2] = 0  # d(c)/d(k1)
+    J[1, 3] = 2 * k2  # d(c)/d(k2)
+
+    # Partial derivatives for b = k1 * k2
+    J[2, 0] = 0  # d(b)/dC
+    J[2, 1] = 0  # d(b)/d(epsilon)
+    J[2, 2] = k2  # d(b)/d(k1)
+    J[2, 3] = k1  # d(b)/d(k2)
+
+    # Partial derivatives for a = k1^2 / 3
+    J[3, 0] = 0  # d(a)/dC
+    J[3, 1] = 0  # d(a)/d(epsilon)
+    J[3, 2] = 2 * k1 / 3  # d(a)/d(k1)
+    J[3, 3] = 0  # d(a)/d(k2)
+    
+    return J
+
+
+# Hessian of the forward map: Second-order derivatives
+def forward_map_hessian(pars):
+    """
+    Computes the Hessian tensor of the forward map analytically.
+    
+    Parameters:
+    - pars: NumPy array of parameters [C, epsilon, k1, k2].
+    
+    Returns:
+    - H: NumPy 4x4x4 Hessian tensor, where H[i, j, k] = d^2(beta[i])/d(pars[j])d(pars[k]).
+    """
+    C, epsilon, k1, k2 = pars  # Unpack parameters
+    
+    # Initialize Hessian tensor (4 x 4 x 4)
+    H = np.zeros((4, 4, 4))
+    
+    # Hessian for d = C: All second derivatives are zero
+    # Already H[0, :, :] is initialized to zero
+    
+    # Hessian for c = k2^2 + epsilon^2
+    H[1, 1, 1] = 2  # d^2(c)/d(epsilon^2)
+    H[1, 3, 3] = 2  # d^2(c)/d(k2^2)
+    
+    # Hessian for b = k1 * k2: All second derivatives are zero
+    # Already H[2, :, :] is initialized to zero
+    
+    # Hessian for a = k1^2 / 3
+    H[3, 2, 2] = 2 / 3  # d^2(a)/d(k1^2)
+    
+    return H
+
+
+# Backward map: Polynomial coefficients to parameters
+def backward_map(beta, only_one = True):
+    """
+    Computes the parameters (pars = [C, epsilon, k1, k2]) from the polynomial coefficients (beta = [d, c, b, a]).
+    
+    Polynomial Definition:
+    - d = C: Constant term of the polynomial.
+    - c = k2^2 + epsilon^2: Used to recover k2 and epsilon.
+    - b = k1 * k2: Used to recover k1 and k2.
+    - a = k1^2 / 3: Used to recover k1.
+
+    Parameters:
+    - beta: NumPy array of polynomial coefficients [d, c, b, a].
+
+    Returns:
+    - List of possible parameter sets [(C, epsilon, k1, k2)].
+    """
+    d, c, b, a = beta  # Unpack the coefficients
+    
+    # Recover k1 from a (two possible values due to ± sqrt)
+    if a < 0:
+        raise ValueError("Coefficient 'a' must be non-negative for monotonic polynomials.")
+    
+    k1_options = np.unique([np.sqrt(3 * a), -np.sqrt(3 * a)])  # Two possible k1 values
+    
+    possible_parameters = []
+    
+    # For each possible k1, compute k2 and epsilon
+    for k1 in k1_options:
+        k2 = None
+        if k1 != 0:  # Ensure k1 is non-zero (avoids division by zero)
+            k2 = b / k1  # Compute k2 from b and k1
+        elif b == 0:
+            k2 = 0
+
+        if k2 is None: continue    
+       
+        # Check if c >= k2^2 for valid epsilon computation
+        if c >= k2**2:
+            epsilon_options = np.unique([np.sqrt(c - k2**2), -np.sqrt(c - k2**2)])  # Two possible epsilon values
+            
+            for epsilon in epsilon_options:
+                # Constant term d maps directly to C
+                C = d
+                sol = np.array([C, epsilon, k1, k2])
+
+                if only_one: return sol
+                possible_parameters.append(sol)
+
+
+    return possible_parameters
+
+
+# -------------------------
+# Round-trip test
+# -------------------------
+if __name__ == "__main__":
+    pars_original = np.array([1.0, 2.0, 3.0, 4.0])
+    beta = forward_map(pars_original)
+    pars_recovered = backward_map(beta)
+
+    print("Original pars: ", pars_original)
+    print("Beta:          ", beta)
+    print("Recovered pars:", pars_recovered)
+    print("Difference:    ", pars_recovered - pars_original)
+
+    print("\nJacobian at pars:")
+    print(forward_map_jacobian(pars_original))
+
+    print("\nHessian for beta1:")
+    print(forward_map_hessian(pars_original)[1])
+
+    print("\nLinear func:")
+    lin_fun_beta = [1,0.2,0,0]
+    only_one = False
+
+    lin_fun_pars = backward_map(lin_fun_beta, only_one=only_one)
+    lin_fun_beta_recover = lin_fun_pars if only_one else np.unique([forward_map(pars) for pars in lin_fun_pars], axis=0)
+    
+    print(f"  {only_one = }")
+    print("  lin_fun_beta:", lin_fun_beta)
+    print("  backwards:", lin_fun_pars)
+    print("  forwards:", lin_fun_beta_recover)
+

BIN
python/logistic/results/logit_fit.pdf


Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.