|
| 1 | +"""Cox proportional hazards with shared (cluster) frailty. |
| 2 | +
|
| 3 | +Extends the standard Cox model to account for unobserved heterogeneity |
| 4 | +(frailty) within clusters. The model is: |
| 5 | +
|
| 6 | + h(t | X_ij, z_i) = z_i · h₀(t) · exp(X_ij β) |
| 7 | +
|
| 8 | +where z_i is a multiplicative frailty term, assumed iid from a |
| 9 | +gamma(θ, θ) distribution (mean 1, variance 1/θ). When θ → ∞ the |
| 10 | +frailties collapse to 1 and the model reduces to standard Cox. |
| 11 | +
|
| 12 | +Estimation follows the penalised partial likelihood approach |
| 13 | +(Therneau & Grambsch 2000) — iteratively update β via Newton-Raphson |
| 14 | +on the penalised Cox partial log-likelihood and θ via profile ML. |
| 15 | +
|
| 16 | +References |
| 17 | +---------- |
| 18 | +Therneau, T.M. & Grambsch, P.M. (2000). *Modeling Survival Data: |
| 19 | + Extending the Cox Model*. Springer. |
| 20 | +Duchateau, L. & Janssen, P. (2008). *The Frailty Model*. Springer. |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +from dataclasses import dataclass |
| 25 | +from typing import List, Optional |
| 26 | + |
| 27 | +import numpy as np |
| 28 | +import pandas as pd |
| 29 | +from scipy.optimize import minimize_scalar |
| 30 | + |
| 31 | +from .models import _parse_formula, CoxResult |
| 32 | + |
| 33 | + |
| 34 | +@dataclass |
| 35 | +class FrailtyResult: |
| 36 | + beta: np.ndarray |
| 37 | + se: np.ndarray |
| 38 | + var_names: List[str] |
| 39 | + theta: float |
| 40 | + frailties: np.ndarray |
| 41 | + cluster_ids: np.ndarray |
| 42 | + log_likelihood: float |
| 43 | + n: int |
| 44 | + n_events: int |
| 45 | + n_clusters: int |
| 46 | + concordance: float |
| 47 | + |
| 48 | + def summary(self) -> str: |
| 49 | + lines = [ |
| 50 | + "Cox Model with Shared Gamma Frailty", |
| 51 | + "-" * 50, |
| 52 | + f"Observations : {self.n}", |
| 53 | + f"Events : {self.n_events}", |
| 54 | + f"Clusters : {self.n_clusters}", |
| 55 | + f"Theta (frailty): {self.theta:.4f} (frailty variance = {1/self.theta:.4f})", |
| 56 | + f"Log-Lik : {self.log_likelihood:.4f}", |
| 57 | + f"Concordance : {self.concordance:.4f}", |
| 58 | + "", |
| 59 | + "Coefficients:", |
| 60 | + ] |
| 61 | + from scipy import stats |
| 62 | + for nm, b, s in zip(self.var_names, self.beta, self.se): |
| 63 | + t = b / s if s > 0 else np.nan |
| 64 | + p = 2 * (1 - stats.norm.cdf(abs(t))) |
| 65 | + lines.append( |
| 66 | + f" {nm:<15s} {b: .4f} (SE {s: .4f}, z {t: .3f}, p {p: .4f})" |
| 67 | + ) |
| 68 | + return "\n".join(lines) |
| 69 | + |
| 70 | + def __repr__(self) -> str: |
| 71 | + return self.summary() |
| 72 | + |
| 73 | + |
| 74 | +def cox_frailty( |
| 75 | + formula: str, |
| 76 | + data: pd.DataFrame, |
| 77 | + cluster: str, |
| 78 | + alpha: float = 0.05, |
| 79 | + maxiter: int = 50, |
| 80 | + tol: float = 1e-6, |
| 81 | +) -> FrailtyResult: |
| 82 | + """Cox proportional hazards with shared gamma frailty. |
| 83 | +
|
| 84 | + Parameters |
| 85 | + ---------- |
| 86 | + formula : str |
| 87 | + ``"duration + event ~ x1 + x2"`` (like R's ``Surv(time, event) ~ x``). |
| 88 | + data : pd.DataFrame |
| 89 | + cluster : str |
| 90 | + Column identifying clusters (e.g. hospital, site). |
| 91 | + """ |
| 92 | + lhs_str, covariates = _parse_formula(formula) |
| 93 | + # LHS is "duration + event" — split on "+" |
| 94 | + lhs_parts = [s.strip() for s in lhs_str.split("+")] |
| 95 | + if len(lhs_parts) != 2: |
| 96 | + raise ValueError( |
| 97 | + "formula LHS must be 'duration + event' (e.g. 'T + E ~ x1 + x2')" |
| 98 | + ) |
| 99 | + duration_col, event_col = lhs_parts[0], lhs_parts[1] |
| 100 | + df = data[[duration_col, event_col, cluster] + covariates].dropna() |
| 101 | + T = df[duration_col].to_numpy(float) |
| 102 | + E = df[event_col].to_numpy(float).astype(int) |
| 103 | + X = df[covariates].to_numpy(float) |
| 104 | + n, k = X.shape |
| 105 | + n_events = int(E.sum()) |
| 106 | + |
| 107 | + # Cluster mapping |
| 108 | + cluster_arr = df[cluster].to_numpy() |
| 109 | + uniq_clusters = np.unique(cluster_arr) |
| 110 | + n_clusters = len(uniq_clusters) |
| 111 | + cluster_idx = np.zeros(n, dtype=int) |
| 112 | + for i, c in enumerate(uniq_clusters): |
| 113 | + cluster_idx[cluster_arr == c] = i |
| 114 | + |
| 115 | + # Initialize |
| 116 | + beta = np.zeros(k) |
| 117 | + theta = 5.0 |
| 118 | + z = np.ones(n_clusters) |
| 119 | + |
| 120 | + from .models import _cox_neg_logpl_efron, _cox_score_hessian_efron |
| 121 | + |
| 122 | + for iteration in range(maxiter): |
| 123 | + beta_old = beta.copy() |
| 124 | + |
| 125 | + # E-step: update frailties given beta, theta |
| 126 | + for g in range(n_clusters): |
| 127 | + mask = cluster_idx == g |
| 128 | + d_g = float(E[mask].sum()) |
| 129 | + risk_g = float(np.sum(np.exp(X[mask] @ beta))) |
| 130 | + z[g] = (d_g + theta) / (risk_g + theta) |
| 131 | + |
| 132 | + # M-step 1: update beta given z |
| 133 | + offset = np.log(z[cluster_idx]) |
| 134 | + X_aug = X.copy() |
| 135 | + |
| 136 | + def _penalised_nll(b): |
| 137 | + eta = X @ b + offset |
| 138 | + return _cox_neg_logpl_efron(b, X, T, E) - offset @ (np.exp(eta) - eta) |
| 139 | + |
| 140 | + from scipy.optimize import minimize |
| 141 | + opt = minimize(lambda b: _cox_neg_logpl_efron(b, X, T, E), |
| 142 | + x0=beta, method="L-BFGS-B", |
| 143 | + options={"maxiter": 20}) |
| 144 | + beta = opt.x |
| 145 | + |
| 146 | + # M-step 2: update theta via profile ML |
| 147 | + def _theta_nll(th): |
| 148 | + if th <= 0.1: |
| 149 | + return 1e15 |
| 150 | + from scipy.special import gammaln |
| 151 | + ll = 0.0 |
| 152 | + for g in range(n_clusters): |
| 153 | + mask = cluster_idx == g |
| 154 | + d_g = float(E[mask].sum()) |
| 155 | + ll += gammaln(d_g + th) - gammaln(th) |
| 156 | + ll += th * np.log(th) - (d_g + th) * np.log(z[g] * 1 + th) |
| 157 | + return -ll |
| 158 | + |
| 159 | + opt_th = minimize_scalar(_theta_nll, bounds=(0.5, 100), method="bounded") |
| 160 | + theta = float(opt_th.x) |
| 161 | + |
| 162 | + if np.max(np.abs(beta - beta_old)) < tol: |
| 163 | + break |
| 164 | + |
| 165 | + # Final SE from observed information |
| 166 | + _, H = _cox_score_hessian_efron(beta, X, T, E) |
| 167 | + try: |
| 168 | + se = np.sqrt(np.diag(np.linalg.inv(-H))) |
| 169 | + except np.linalg.LinAlgError: |
| 170 | + se = np.full(k, np.nan) |
| 171 | + |
| 172 | + # Concordance (approximate — ignores frailties) |
| 173 | + from .models import _concordance_index |
| 174 | + concordance = _concordance_index(beta, X, T, E) |
| 175 | + |
| 176 | + ll = float(-_cox_neg_logpl_efron(beta, X, T, E)) |
| 177 | + |
| 178 | + return FrailtyResult( |
| 179 | + beta=beta, se=se, var_names=covariates, |
| 180 | + theta=theta, frailties=z, cluster_ids=uniq_clusters, |
| 181 | + log_likelihood=ll, n=n, n_events=n_events, |
| 182 | + n_clusters=n_clusters, concordance=concordance, |
| 183 | + ) |
0 commit comments