import greenwood as gw
lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
ySurv(type=right, n=228, events=165)
Once you have survival curves for two or more groups, the natural question is whether the differences between them are real or could be explained by chance. The log-rank test and its relatives answer this. They compare the observed number of events in each group against the number expected if the groups shared a common survival experience, accumulated across every event time. This page explains the test, its weighted variants, and how to read the results.
We continue with the lung dataset, comparing survival between the two sexes.
import greenwood as gw
lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
ySurv(type=right, n=228, events=165)
The response y is a Surv object pairing each subject’s follow-up time with an event indicator, where a trailing + marks a censored observation. The grouping variable lung["sex"] splits these same subjects into the two groups we are about to compare.
The log-rank test is the standard method for comparing survival across groups. At each event time it forms a small contingency table of who was at risk and who had an event in each group, computes the events expected under the null hypothesis of no difference, and combines the observed-minus-expected discrepancies into a single chi-square statistic.
The call returns a TestResult object, which we store in result. Displaying it prints a readable summary of the whole test at once.
The result carries the test statistic, its degrees of freedom (one less than the number of groups), and the p-value. A small p-value is evidence that the survival curves differ. You can also inspect the weighted observed and expected event counts per group, which show the direction of the difference. These live on the result object as the observed and expected attributes, one entry per group.
observed: {1: 112.0, 2: 53.0}
expected: {1: 91.58173902957279, 2: 73.41826097042721}
Here one group has more observed events than expected and the other fewer, which is the signature of a survival difference. The test works for any number of groups; with three or more it produces an overall test on the corresponding degrees of freedom.
The test tells you whether the curves differ overall. It does not tell you by how much, at what times, or in which direction beyond the observed-versus-expected counts. Pair it with a Kaplan-Meier plot and with effect measures such as a hazard ratio from Cox regression or a restricted-mean-survival difference.
The standard log-rank test weights every event time equally, which makes it most sensitive to differences in the tails of the curves where events accumulate. Sometimes you care more about early differences. The Fleming-Harrington, or G-rho, family generalizes the log-rank test by weighting each event time by a power of the pooled survival probability.
The rho parameter controls this. With rho=0 you get the ordinary log-rank test. With rho=1 you get the Peto-Peto test, a Wilcoxon-type test that gives more weight to early event times and is therefore more sensitive to early separation of the curves.
This returns the same kind of TestResult as before, so you can read it the same way. Only the weighting has changed, so compare its statistic and p-value against the unweighted test above to see how much the emphasis on early event times matters here.
lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
gw.logrank_test(y, group=lung["sex"], rho=1)TestResult(method='G-rho test (rho=1, gamma=0.0)', statistic=12.7142, df=1, p_value=0.0003629)
Choosing between them is a matter of what alternative you want to detect, and the choice should be made before looking at the results rather than by picking whichever gives the smaller p-value.
Running several weighted tests and reporting the most significant one inflates the false positive rate. Decide on the test, and its rho, based on subject-matter reasoning before you see the data.
Greenwood validates input data upfront, so weighted tests handle edge cases robustly. Times can be zero (events at baseline are valid in survival analysis), and weighted tests like Fleming-Harrington work correctly without silent data loss or dimension mismatches. This robustness extends to all censoring patterns and follows the validation rules documented in the Validation and reproducibility section.
The log-rank test extends to any number of groups, giving one overall test. We switch to the veteran dataset, which records a cell type with four levels.
vet = gw.load_dataset("veteran", backend="polars")
y_vet = gw.Surv.right(vet["time"], event=vet["status"])
gw.logrank_test(y_vet, group=vet["celltype"])TestResult(method='Log-rank test', statistic=25.4037, df=3, p_value=1.271e-05)
A small p-value here says the four cell types do not all share the same survival, but it does not say which ones differ. For that, test each pair and correct for the fact that you are running several tests at once. pairwise_logrank_test does both.
PolarsRows6Columns5 | |||||
group1 str |
group2 str |
statistic f64 |
p_value f64 |
p_adjusted f64 |
|
|---|---|---|---|---|---|
| 0 | adeno | large | 17.6693215293 | 2.62831687848e-05 | 0.000157699012709 |
| 1 | adeno | smallcell | 0.096843191971 | 0.755651328687 | 0.755651328687 |
| 2 | adeno | squamous | 12.0454836411 | 0.000519180149284 | 0.00259590074642 |
| 3 | large | smallcell | 9.37090414818 | 0.00220456751896 | 0.00661370255689 |
| 4 | large | squamous | 0.822593978656 | 0.364422837484 | 0.728845674968 |
| 5 | smallcell | squamous | 11.57367392 | 0.000668921225041 | 0.00267568490017 |
Each row is one pair, with its own statistic, the raw p_value, and a p_adjusted value that accounts for the multiple comparisons. The default correction is Holm’s; pass correction="bh" for Benjamini-Hochberg, "bonferroni", or "none". Read significance from the adjusted column, not the raw one.
While the log-rank test tells you whether groups differ, it does not tell you by how much. The restricted mean survival time (RMST) is an intuitive effect measure: the expected survival time over a fixed time window tau, computed as the area under the survival curve. For example, 1-year RMST is the average survival time during the first year. Unlike hazard ratios, RMST is on the survival time scale, making it easy to interpret and explain to non-technical audiences.
Compare RMST between two groups with confidence intervals and hypothesis tests using rmst_test(). Here we compare 1-year RMST between the two sexes in the lung dataset.
lung = gw.load_dataset("lung", backend="polars")
y = gw.Surv.right(lung["time"], event=(lung["status"] == 2))
# Compare 1-year RMST between groups
result = gw.rmst_test(y, tau=365, group=lung["sex"])
resultRMSTResult(method='RMST difference (tau=365)', estimate=-55.9703, se=14.9581, 95% CI=[-85.2877, -26.6529], p_value=0.0001827)
The result shows the RMST for each group, their difference, standard error, 95% confidence interval, and a two-sided p-value testing equality. A positive difference means group 1 has higher RMST (longer average survival).
You can extract specific components:
By default, rmst_test() returns the RMST difference. You can also compute the ratio or percentage difference using the estimand parameter.
Ratio of RMSTs (group 1 / group 2):
result_ratio = gw.rmst_test(y, tau=365, group=lung["sex"], estimand="ratio")
result_ratio.estimate # Ratio0.8118425788462916
A ratio of 0.8 means group 1 has 80% of the RMST of group 2 (20% shorter average survival).
Percentage difference ((group 1 - group 2) / group 2 × 100):
result_pct = gw.rmst_test(y, tau=365, group=lung["sex"], estimand="percentage_difference")
result_pct.estimate # Percentage-18.81574211537084
All three estimands use the delta method to construct standard errors and confidence intervals, and support flexible confidence levels via the conf_level= parameter.
When you have three or more groups, use pairwise_rmst_test() to compare all pairs with multiple-comparison adjustment. Here we test which cell types in the veteran dataset differ in 1-year RMST.
vet = gw.load_dataset("veteran", backend="polars")
y_vet = gw.Surv.right(vet["time"], event=vet["status"])
# Pairwise RMST differences with Holm adjustment
pairs = gw.pairwise_rmst_test(y_vet, tau=365, group=vet["celltype"])
pairsPolarsRows6Columns11 | |||||||||||
group1 str |
group2 str |
rmst1 f64 |
rmst2 f64 |
estimate f64 |
se f64 |
lower_ci f64 |
upper_ci f64 |
statistic f64 |
p_value f64 |
p_adjusted f64 |
|
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | adeno | large | 65.5555555556 | 162.234567901 | -96.6790123457 | 22.7443674012 | -141.257153303 | -52.1008713881 | -4.25067932821 | 2.13123118245e-05 | 0.000127873870947 |
| 1 | adeno | smallcell | 65.5555555556 | 77.6352880658 | -12.0797325103 | 17.1396326857 | -45.6727952825 | 21.5133302619 | -0.704783628202 | 0.480944898525 | 0.961889797051 |
| 2 | adeno | squamous | 65.5555555556 | 170.642372598 | -105.086817043 | 26.1899192619 | -156.418115554 | -53.7555185313 | -4.01249106543 | 6.00813536891e-05 | 0.000300406768445 |
| 3 | large | smallcell | 162.234567901 | 77.6352880658 | 84.5992798354 | 24.7760388636 | 36.0391359831 | 133.159423688 | 3.41456034603 | 0.000638850399879 | 0.00255540159952 |
| 4 | large | squamous | 162.234567901 | 170.642372598 | -8.40780469693 | 31.7174551961 | -70.5728745625 | 53.7572651687 | -0.265084466738 | 0.790944393171 | 0.961889797051 |
| 5 | smallcell | squamous | 77.6352880658 | 170.642372598 | -93.0070845323 | 27.9724458036 | -147.832070867 | -38.1820981978 | -3.32495360561 | 0.000884332493635 | 0.0026529974809 |
Each row compares one pair of groups, showing the RMST estimates, difference, and both raw and adjusted p-values. The adjustment method (default: Holm) controls for multiple testing. Pass correction="bh" for Benjamini-Hochberg or "bonferroni" for Bonferroni correction.
The choice of tau is critical: RMST at tau=365 only includes follow-up through 1 year, while tau=1825 extends to 5 years. Choose tau based on clinical relevance before looking at the data. Common choices are clinically meaningful milestones (1 year, 5 years, etc.).
# Compare 5-year RMST instead
result_5y = gw.rmst_test(y, tau=1825, group=lung["sex"])
result_5y.estimate-172.70616141861058
Use both: run the log-rank test for significance, then estimate RMST difference for the magnitude and direction of the effect. They often agree, but RMST is easier to communicate to stakeholders unfamiliar with hazards.
Sometimes you want to compare groups while controlling for a nuisance variable, so that the comparison is made within each level of that variable and then combined. A stratified log-rank does this. Here we compare the two treatments while stratifying by cell type, which removes any cell-type imbalance from the comparison.
vet = gw.load_dataset("veteran", backend="polars")
y_vet = gw.Surv.right(vet["time"], event=vet["status"])
gw.logrank_test(y_vet, group=vet["trt"], strata=vet["celltype"])TestResult(method='Stratified log-rank test', statistic=0.7017, df=1, p_value=0.4022)
The statistic is pooled across the strata, matching R’s survdiff with a strata() term. Stratification is the test-level counterpart to a stratified Cox model, where each stratum has its own baseline hazard.
When your groups are naturally ordered—disease stages (I, II, III, IV), dose levels (low, medium, high), or educational attainment (primary, secondary, tertiary), a general log-rank test asks the right question but it doesn’t use the information about ordering. A trend test is more powerful because it looks specifically for a linear relationship across the ordered levels.
The trend test compares a linear contrast across groups to see if survival changes systematically with group ordering. It produces a one-degree-of-freedom chi-square test, which is more powerful than the multi-degree-of-freedom log-rank test when groups are truly ordered.
By default, groups are sorted and assigned scores 0, 1, 2, …, (k - 1). Here we test whether survival differs across cell types in the veteran dataset, treating them as an ordered grouping.
vet = gw.load_dataset("veteran", backend="polars")
y = gw.Surv.right(vet["time"], event=vet["status"])
# Test linear trend across ordered cell types
gw.trend_test(y, group=vet["celltype"])TestResult(method='Linear trend test', statistic=2.3681, df=1, p_value=0.1238)
The result is a TestResult with df=1, indicating a one-degree-of-freedom test. The observed and expected event counts per group reveal which groups contribute most to the trend, and the p-value tells you if the trend is statistically significant.
If the ordering is not uniform—for instance, some groups have very different prognoses—you can specify custom scores. Scores must be numeric and reflect your belief about the ordering.
vet = gw.load_dataset("veteran", backend="polars")
y = gw.Surv.right(vet["time"], event=vet["status"])
# Custom scores: adeno and large get weight 1, smallcell and squamous get weight 3
scores = {"adeno": 1, "large": 1, "smallcell": 3, "squamous": 3}
gw.trend_test(y, group=vet["celltype"], scores=scores)TestResult(method='Linear trend test', statistic=0.0263, df=1, p_value=0.8713)
Different scores can change the test statistic, reflecting your scientific hypothesis about how the groups are ordered.
Like the log-rank test, the trend test can use Fleming-Harrington weights to emphasize early or late differences. With rho=1, the Peto-Peto variant emphasizes early event times, which is useful if you suspect early separation of the curves.
vet = gw.load_dataset("veteran", backend="polars")
y = gw.Surv.right(vet["time"], event=vet["status"])
# Peto-Peto trend test: weights early events more heavily
gw.trend_test(y, group=vet["celltype"], rho=1)TestResult(method='G-rho trend test (rho=1, gamma=0.0)', statistic=0.6858, df=1, p_value=0.4076)
You can also stratify a trend test to control for confounders while still testing for linear trend across ordered groups. The stratified test computes the trend separately in each stratum and then combines the results.
vet = gw.load_dataset("veteran", backend="polars")
y = gw.Surv.right(vet["time"], event=vet["status"])
# Test trend in celltype while controlling for treatment
gw.trend_test(y, group=vet["celltype"], strata=vet["trt"])TestResult(method='Stratified linear trend test', statistic=1.7525, df=1, p_value=0.1856)
The log-rank test also answers a planning question asked before any data are collected: how large must a study be? Under proportional hazards, its power depends on the data only through the number of events, so Greenwood provides Schoenfeld’s calculators. To detect a given hazard ratio at a target power, logrank_n_events returns the events required.
Running it the other way, logrank_power gives the power a planned study would have for a given number of events, which is useful for checking whether an existing dataset can support a comparison.
Events are not the same as subjects. To turn the required events into a sample size, divide by the probability that a subject has the event during the study, which you estimate from the expected survival and the planned follow-up. The logrank_sample_size() function does this.
These formulas assume proportional hazards, a two-group comparison, and (by default) a balanced, two-sided test at alpha=0.05. Use allocation for an unbalanced design and sides=1 for a one-sided test. A balanced design needs the fewest events.
A group comparison test is the right tool when you have one categorical grouping and want a yes-or-no answer about a difference. When you need to adjust for other variables, handle a continuous predictor, or estimate the size of an effect, you move to regression. The log-rank test has a close connection to the Cox model: the score test from a Cox model with a single group indicator is essentially the log-rank test, which is why the two usually agree.
You can now describe and formally compare survival across groups.