Skip to content

Commit 82ec819

Browse files
authored
Merge branch 'main' into all-contributors/add-kshedden
2 parents 2d6fc63 + 0c7c52d commit 82ec819

File tree

6 files changed

+355
-3
lines changed

6 files changed

+355
-3
lines changed

.all-contributorsrc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
"contributions": [
3737
"doc",
3838
"code"
39+
},
40+
{
41+
"login": "mschauer",
42+
"name": "Moritz Schauer",
43+
"avatar_url": "https://avatars.githubusercontent.com/u/1923437?v=4",
44+
"profile": "http://www.math.chalmers.se/~smoritz/index.html",
45+
"contributions": [
46+
"review"
3947
]
4048
}
4149
],

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Survival analysis interface in Julia, still very experimental. Tries to build on
4848
## Contributors
4949

5050
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
51-
[![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-)
51+
[![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-)
5252
<!-- ALL-CONTRIBUTORS-BADGE:END -->
5353

5454
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
@@ -59,6 +59,7 @@ Survival analysis interface in Julia, still very experimental. Tries to build on
5959
<tr>
6060
<td align="center"><a href="http://www.raphaelsonabend.co.uk"><img src="https://avatars.githubusercontent.com/u/25639974?v=4?s=100" width="100px;" alt="Raphael Sonabend"/><br /><sub><b>Raphael Sonabend</b></sub></a><br /><a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/issues?q=author%3ARaphaelS1" title="Bug reports">🐛</a> <a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/commits?author=RaphaelS1" title="Code">💻</a> <a href="#content-RaphaelS1" title="Content">🖋</a> <a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/commits?author=RaphaelS1" title="Documentation">📖</a> <a href="#design-RaphaelS1" title="Design">🎨</a> <a href="#example-RaphaelS1" title="Examples">💡</a> <a href="#ideas-RaphaelS1" title="Ideas, Planning, & Feedback">🤔</a> <a href="#maintenance-RaphaelS1" title="Maintenance">🚧</a> <a href="#projectManagement-RaphaelS1" title="Project Management">📆</a> <a href="#question-RaphaelS1" title="Answering Questions">💬</a> <a href="#research-RaphaelS1" title="Research">🔬</a> <a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/pulls?q=is%3Apr+reviewed-by%3ARaphaelS1" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/commits?author=RaphaelS1" title="Tests">⚠️</a> <a href="#tutorial-RaphaelS1" title="Tutorials">✅</a></td>
6161
<td align="center"><a href="https://github.com/kshedden"><img src="https://avatars.githubusercontent.com/u/2666691?v=4?s=100" width="100px;" alt="Kerby Shedden"/><br /><sub><b>Kerby Shedden</b></sub></a><br /><a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/commits?author=kshedden" title="Documentation">📖</a> <a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/commits?author=kshedden" title="Code">💻</a></td>
62+
<td align="center"><a href="http://www.math.chalmers.se/~smoritz/index.html"><img src="https://avatars.githubusercontent.com/u/1923437?v=4?s=100" width="100px;" alt="Moritz Schauer"/><br /><sub><b>Moritz Schauer</b></sub></a><br /><a href="https://github.com/RaphaelS1/SurvivalAnalysis.jl/pulls?q=is%3Apr+reviewed-by%3Amschauer" title="Reviewed Pull Requests">👀</a></td>
6263
</tr>
6364
</tbody>
6465
</table>

src/Surv.jl

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,59 @@ function _tabulate_surv(T, Δ)
363363
noutcomes = noutcomes)
364364
end
365365

366+
"""
367+
expandstats(stats, times)
368+
369+
Insert new times into 'stats' (a named tuple compatible with the 'stats' field of 'RCSurv').
370+
371+
Zero observation/event counts are used for the newly-inserted times. Time values in 'times' that are already in 'stats.time' are ignored.
372+
373+
# Examples
374+
```jldoctest
375+
julia> srv = Surv([1, 4], [false, true], :r);
376+
377+
julia> srv.stats
378+
(time = [1.0, 4.0], nrisk = [2, 1], ncens = [1, 0], nevents = [0, 1], noutcomes = [1, 1])
379+
380+
julia> SurvivalAnalysis.expandstats(srv.stats, [3, 4, 5])
381+
(time = [1.0, 3.0, 4.0, 5.0], nrisk = [2.0, 1.0, 1.0, 0.0], ncens = [1.0, 0.0, 0.0, 0.0], nevents = [0.0, 0.0, 1.0, 0.0], noutcomes = [1.0, 0.0, 1.0, 0.0])
382+
```
383+
"""
384+
function expandstats(stats, times)
385+
386+
ut = unique(vcat(stats.time, times))
387+
sort!(ut)
388+
n = length(ut)
389+
nrisk = zeros(n)
390+
ncens = zeros(n)
391+
nevents = zeros(n)
392+
noutcomes = zeros(n)
393+
394+
for (i,t) in enumerate(ut)
395+
j = searchsortedfirst(stats.time, t)
396+
if j <= length(stats.time) && stats.time[j] == t
397+
nrisk[i] = stats.nrisk[j]
398+
ncens[i] = stats.ncens[j]
399+
nevents[i] = stats.nevents[j]
400+
noutcomes[i] = stats.noutcomes[j]
401+
end
402+
end
403+
404+
nr = last(stats.nrisk)
405+
for i in reverse(eachindex(nrisk))
406+
if ut[i] > last(stats.time)
407+
continue
408+
elseif nrisk[i] == 0
409+
nrisk[i] = nr
410+
else
411+
nr = nrisk[i]
412+
end
413+
end
414+
415+
return (time = ut, nrisk = nrisk, ncens = ncens, nevents = nevents,
416+
noutcomes = noutcomes)
417+
end
418+
366419
"""
367420
merge(A::OneSidedSurv...)
368421
merge(A::TwoSidedSurv...)

src/SurvivalAnalysis.jl

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
module SurvivalAnalysis
22

3-
using DataFrames: DataFrame
3+
using DataFrames: DataFrame, groupby
44
using Distributions
5-
using LinearAlgebra: diag
5+
using LinearAlgebra: diag, pinv
66
using NLSolversBase: hessian!
77
using Optim
88
using RecipesBase
@@ -26,6 +26,7 @@ module SurvivalAnalysis
2626
export SurvivalPrediction
2727
export SurvivalMeasure, concordance, ConcordanceWeights
2828
export SurvivalTimeMeasure, MSE, RMSE, MAE
29+
export logrank
2930

3031
## undocumented
3132
# reexports

src/SurvivalEstimator.jl

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,165 @@ function StatsBase.fit!(obj::KaplanMeier, Y::RCSurv)
338338
)
339339
end
340340

341+
#-------------------
342+
# Log-rank tests
343+
#-------------------
344+
function _update_wt(wt, nevents, nrisk, wtmethod)
345+
if wtmethod == :logrank
346+
return 1.0
347+
elseif wtmethod == :wilcoxon
348+
return nrisk
349+
elseif wtmethod == :tw
350+
return sqrt(nrisk)
351+
elseif wtmethod == :peto
352+
return wt * (1 - nevents / (nrisk + 1))
353+
else
354+
error("wtmethod must be one of logrank, wilcoxon, tw, or peto")
355+
end
356+
end
357+
358+
# Helper function to calculate the score vector u and covariance matrix V for a logrank test.
359+
function logrank_moments(Y::RCSurv...; wtmethod::Symbol=:logrank)
360+
m = length(Y)
361+
A = merge(Y...)
362+
ti = unique_outcome_times(A)
363+
sta = A.stats
364+
st = [expandstats(y.stats, ti) for y in Y]
365+
366+
u = zeros(m)
367+
V = zeros(m, m)
368+
wt = 1.0
369+
for i in eachindex(ti)
370+
d, n = sta.nevents[i], sta.nrisk[i]
371+
wt = _update_wt(wt, d, n, wtmethod)
372+
for j in 1:m
373+
dd, nnj = st[j].nevents[i], st[j].nrisk[i]
374+
rj = dd / nnj
375+
fj = nnj / n
376+
u[j] += wt * (dd - d*fj)
377+
for k in 1:m
378+
nnk = st[k].nrisk[i]
379+
fk = nnk / n
380+
q = j == k ? 1.0 : 0.0
381+
if n > 1
382+
V[j,k] += wt^2 * (q - fj) * fk * d * (n - d) / (n - 1)
383+
end
384+
end
385+
end
386+
end
387+
388+
return u, V
389+
end
390+
391+
"""
392+
logrank(Y::RCSurv...; wtmethod=:logrank)
393+
logrank(time, status, group, strata=zeros(0); wtmethod=:logrank)
394+
395+
Test the null hypothesis that two or more survival functions are identical.
396+
397+
When providing `time` and `status` as vectors, the `status` argument is coded 0/1 corresponding to censoring (0) and event (1).
398+
399+
The `strata` argument is optional and contains labels defining strata for a stratified test.
400+
401+
`wtmethod` selects one of four different weighting methods: logrank (uniform weighting), Wilcoxon (weight by number at risk), Tarone-Ware (weight by the square root of the number at risk), Peto-Peto (weight by the estimated marginal survival function).
402+
403+
# Examples
404+
```jldoctest
405+
julia> srv1 = Surv([1, 3, 4], [false, true, true], :r);
406+
407+
julia> srv2 = Surv([4, 5, 6], [true, true, false], :r);
408+
409+
julia> pr = x -> (stat=round(x.stat; sigdigits=4), dof=x.dof, pvalue=round(x.pvalue; sigdigits=4));
410+
411+
julia> r = logrank(srv1, srv2; wtmethod=:wilcoxon);
412+
413+
julia> pr(r)
414+
(stat = 2.5, dof = 1, pvalue = 0.1138)
415+
416+
julia> r = logrank([1, 3, 4, 4, 5, 6], [false, true, true, true, true, false], [1, 1, 1, 2, 2, 2]; wtmethod=:wilcoxon);
417+
418+
julia> pr(r)
419+
(stat = 2.5, dof = 1, pvalue = 0.1138)
420+
421+
julia> r = logrank([1, 3, 4, 4, 5, 6], [false, true, true, true, true, false], [1, 1, 1, 2, 2, 2], [1, 1, 2, 1, 2, 2]; wtmethod=:wilcoxon);
422+
423+
julia> pr(r)
424+
(stat = 3.0, dof = 1, pvalue = 0.08326)
425+
```
426+
"""
427+
function logrank(Y::RCSurv...; wtmethod=:logrank)
428+
429+
length(Y) > 1 || throw(ArgumentError("logrank requires two or more groups"))
430+
431+
u, V = logrank_moments(Y...; wtmethod=wtmethod)
432+
433+
# Chi-square statistic
434+
csq = u' * pinv(V) * u
435+
436+
# Degrees of freedom
437+
dof = length(Y) - 1
438+
439+
# P-value
440+
p = 1 - cdf(Chisq(dof), csq)
441+
442+
return (stat=csq, dof=dof, pvalue=p)
443+
end
444+
445+
# Returns a list of Surv values, each of which contains the survival data for one group.
446+
# Also returns a vector containing the group labels
447+
function _build_surv(time, status, group)
448+
da = DataFrame(time=time, status=status, group=group)
449+
Y = Surv[]
450+
grp = []
451+
for dz in groupby(da, :group)
452+
push!(Y, Surv(dz[:, :time], dz[:, :status], :r))
453+
push!(grp, first(dz[:, :group]))
454+
end
455+
return Y, grp
456+
end
457+
458+
function logrank(time, status, group, strata=zeros(0); wtmethod=:logrank)
459+
460+
length(time) == length(status) == length(group) || throw(ArgumentError("time, status, and group must have the same length"))
461+
length(strata) in [0, length(time)] || throw(ArgumentError("If provided, strata must have the same length as time, status, and group"))
462+
463+
# Unstratified test
464+
if length(strata) == 0
465+
Y, _ = _build_surv(time, status, group)
466+
return logrank(Y...; wtmethod=wtmethod)
467+
end
468+
469+
# Dictionary mapping group labels to integer positions 1, 2, ...
470+
gpix = Dict{eltype(group),Int}()
471+
for g in sort(unique(group))
472+
gpix[g] = length(gpix) + 1
473+
end
474+
475+
# Stratified test
476+
da = DataFrame(time=time, status=status, group=group, strata=strata)
477+
m = length(gpix)
478+
u = zeros(m)
479+
V = zeros(m, m)
480+
for dx in groupby(da, :strata)
481+
Y, grp = _build_surv(dx[:, :time], dx[:, :status], dx[:, :group])
482+
u0, V0 = logrank_moments(Y...; wtmethod=wtmethod)
483+
ii = [gpix[g] for g in grp]
484+
u[ii] .+= u0
485+
V[ii, ii] .+= V0
486+
end
487+
488+
# Chi-square statistic
489+
csq = u' * pinv(V) * u
490+
491+
# Degrees of freedom
492+
dof = length(gpix) - 1
493+
494+
# P-value
495+
p = 1 - cdf(Chisq(dof), csq)
496+
497+
return (stat=csq, dof=dof, pvalue=p)
498+
end
499+
341500
"""
342501
confint(km::KaplanMeier; level::Float64 = 0.95)
343502
confint(km::KaplanMeier, t::Number; level::Float64 = 0.95)

0 commit comments

Comments
 (0)