cv_coxnet()

Select the CoxNet penalizer by k-fold cross-validation.

Usage

Source

cv_coxnet(
    surv,
    covariates,
    *,
    data=None,
    l1_ratio=1.0,
    penalizers=None,
    n_penalizers=50,
    eps=0.001,
    k=5,
    metric="concordance",
    times=None,
    standardize=True,
    max_iter=1000,
    tol=1e-07,
    stratified=True,
    seed=None
)

Evaluates a log-spaced grid of penalizer values (or a user-supplied sequence) with stratified k-fold cross-validation and returns the value that maximises concordance (or minimises the integrated Brier score).

Parameters

surv: Surv

A right-censored or counting-process Surv response.

covariates: Any

Covariate design (dataframe, 2-D array, or formula string with data).

data: Any = None

DataFrame to evaluate a formula covariates string against.

l1_ratio: float = 1.0

Elastic-net mixing parameter (default 1.0 = lasso). Fixed across the entire path (only the penalizer is varied).

penalizers: Any = None

An explicit sequence of penalizer values to evaluate. When None (the default) a log-spaced path of n_penalizers= values is generated automatically from lambda_max down to eps * lambda_max.

n_penalizers: int = 50

Number of log-spaced values in the auto-generated path (the default is 50). Ignored when penalizers= is supplied.

eps: float = 0.001

Ratio of smallest to largest auto-generated penalizer (default of 1e-3). Smaller values extend the path toward weaker regularisation. Ignored when penalizers= is supplied.

k: int = 5

Number of cross-validation folds (the default is 5).

metric: str = "concordance"

Scoring metric:

  • "concordance" (the default): Harrell’s C-statistic, where higher is better.
  • "brier": integrated IPCW Brier score, which requires times= (here lower is better).
times: Any = None

Evaluation times for metric="brier" (1-D array-like, \ge 2 values).

standardize: bool = True

Standardize covariates before penalising (default is True). Must match the standardize= argument used for the final CoxNet fit.

max_iter: int = 1000

FISTA iteration limit and convergence tolerance for each inner fit.

tol: int = 1000

FISTA iteration limit and convergence tolerance for each inner fit.

stratified: bool = True

Use stratified k-fold to preserve the event rate in every fold (the default is True).

seed: int | None = None
Random seed for reproducible fold assignments.

Returns

CoxNetCVResult
Object exposing best_penalizer_, penalizer_1se_, mean_scores_, std_scores_, n_nonzero_, and a to_frame() method for the path.

Details

Lambda path. When penalizers= is not supplied the path is

\lambda_{\max} = \frac{\max_j \bigl|\nabla_j\,\ell(\beta=0)\bigr|}{n \cdot \max(\alpha,\, 0.01)}, \quad \lambda_i = \lambda_{\max} \cdot \varepsilon^{i/(m-1)}, \quad i = 0,\ldots,m-1

where \nabla\ell(\beta=0) is the score of the partial log-likelihood at the null model, \alpha = l1_ratio, \varepsilon = eps, and m = n_penalizers.

1-SE rule. penalizer_1se_ is the largest penalizer (most regularized) whose mean score lies within one standard error of best_score_. Prefer it when parsimony matters as it yields a sparser model at no significant loss of predictive accuracy.

Fitting the final model. After selecting a penalizer, refit on all data:

result = gw.cv_coxnet(y, x, seed=23)
final = gw.CoxNet(penalizer=result.best_penalizer_).fit(y, x)

Examples

Run cross-validated lasso selection on the lung dataset:

import greenwood as gw

# Load data and build a right-censored response
lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
cols = ["age", "sex", "ph.ecog", "ph.karno", "wt.loss"]

# Run cross-validated lasso selection with 3 folds
result = gw.cv_coxnet(y, lung[cols], l1_ratio=1.0, n_penalizers=10, k=3, seed=23)
result
CoxNetCV (concordance, ↑ higher is better, l1_ratio=1.0, 3-fold)

  best penalizer : 0.046941  (mean concordance: 0.6377)
  1-SE  penalizer : 0.10113  (mean concordance: 0.6358)

  10 penalizers tested, range [0.000218, 0.218]

Inspect the full penalizer path:

# Inspect the full penalizer path
result.to_frame(format="polars")
shape: (10, 4)
penalizermean_scorestd_scoren_nonzero
f64f64f64f64
0.2178820.516310.028250.333333
0.1011320.6357980.0465192.333333
0.0469410.6377120.0329184.0
0.0217880.6362730.0328884.333333
0.0101130.6329440.0328195.0
0.0046940.6347510.0325185.0
0.0021790.63330.0338555.0
0.0010110.6325270.0346775.0
0.0004690.6325010.0347255.0
0.0002180.6330180.0350035.0

Fit the final model at the best penalizer:

# Refit at the best penalizer on all data
final = gw.CoxNet(penalizer=result.best_penalizer_, l1_ratio=1.0).fit(y, lung[cols])
final
CoxNet (elastic-net Cox, penalizer=0.046941245091417096, l1_ratio=1.0)

               coef
age        0.006585
sex         -0.4198
ph.ecog      0.3761
ph.karno          0
wt.loss   -0.001172

n = 213, events = 151, nonzero coefficients = 4

Use the 1-SE rule for a sparser model:

# Refit at the 1-SE penalizer for a sparser model
sparse = gw.CoxNet(penalizer=result.penalizer_1se_, l1_ratio=1.0).fit(y, lung[cols])
sparse
CoxNet (elastic-net Cox, penalizer=0.10113184681823788, l1_ratio=1.0)

             coef
age             0
sex       -0.2499
ph.ecog    0.2646
ph.karno       -0
wt.loss        -0

n = 213, events = 151, nonzero coefficients = 2