Overview

This script produces the two clinical characterization tables for the thesis Results section, built on the corrected clinical_clean.rds checkpoint (post pathology-mismatch correction, see 03_Crosswalk_ID_Harmonization.Rmd):

dunn.test and FSA were not available in this environment, so Dunn’s test is implemented manually below (rank-sum z-test with tie correction, Bonferroni-adjusted), following Dunn (1964).

library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(knitr)
clinical_clean <- readRDS("clinical_clean.rds")
dim(clinical_clean)
## [1] 233  54
fmt_iqr <- function(x) {
  x <- x[!is.na(x)]
  if (length(x) == 0) return(NA_character_)
  q <- quantile(x, c(0.25, 0.5, 0.75))
  sprintf("%.1f [%.1f, %.1f]", q[2], q[1], q[3])
}

fmt_p <- function(p) {
  if (is.na(p)) return(NA_character_)
  if (p < 0.001) return("<0.001")
  sprintf("%.3f", p)
}

# Manual Dunn's test (1964): pairwise rank-sum z-tests with tie
# correction, following a significant Kruskal-Wallis omnibus test.
# p-values are two-sided and Bonferroni-adjusted across all pairs.
dunn_test <- function(x, g) {
  ok <- !is.na(x) & !is.na(g)
  x <- x[ok]; g <- factor(g[ok])
  N <- length(x)
  r <- rank(x)
  groups <- levels(g)
  k <- length(groups)
  tie_table <- table(x)
  tie_term <- sum(tie_table^3 - tie_table) / (12 * (N - 1))
  meanranks <- tapply(r, g, mean)
  ns <- tapply(r, g, length)
  res <- data.frame()
  for (i in 1:(k - 1)) for (j in (i + 1):k) {
    se <- sqrt((N * (N + 1) / 12 - tie_term) * (1 / ns[i] + 1 / ns[j]))
    z <- (meanranks[i] - meanranks[j]) / se
    p <- 2 * (1 - pnorm(abs(z)))
    res <- rbind(res, data.frame(group1 = groups[i], group2 = groups[j],
                                  z = z, p_raw = p))
  }
  res$p_bonf <- pmin(res$p_raw * nrow(res), 1)
  res
}

# Variables shared by both tables' lab panel
cont_vars <- c(Age = "age", WCC = "white_cell_count", Haemoglobin = "haemaglobin",
               Creatinine = "creatinine_umol_l", CRP = "c_reactive_protein_mg_l",
               `Total protein` = "total_protein_g_l", Albumin = "albumin_g_l",
               `Total bilirubin` = "total_bilirubin_tbil_umol_l",
               `Conjugated bilirubin` = "conjugated_bilirubin_dbil_umol_l",
               ALT = "alanine_transaminase_alt_u_l", AST = "aspartate_transaminase_ast_u_l",
               ALP = "alkaline_phosphatase_alp_u_l", GGT = "gamma_glutamyl_transferase_ggt_u_l")

Table 1: PDAC vs. BBP (full recruited clinical cohort)

cc1 <- clinical_clean %>%
  mutate(group2 = case_when(
    pathology == "BBP" ~ "BBP",
    pathology %in% c("RPC", "LAPC", "MPC") ~ "PDAC",
    TRUE ~ NA_character_
  )) %>%
  filter(!is.na(group2))

cat("Excluded from this comparison (neither PDAC nor BBP):\n")
## Excluded from this comparison (neither PDAC nor BBP):
clinical_clean %>% filter(!pathology %in% c("BBP","RPC","LAPC","MPC")) %>% count(pathology)
## # A tibble: 2 × 2
##   pathology     n
##   <chr>     <int>
## 1 CP            6
## 2 HC           42
cat("\nTable 1 cohort sizes:\n")
## 
## Table 1 cohort sizes:
table(cc1$group2)
## 
##  BBP PDAC 
##   83  102
table1_cont <- lapply(names(cont_vars), function(nm) {
  v <- cont_vars[[nm]]
  bbp <- cc1[[v]][cc1$group2 == "BBP"]
  pdac <- cc1[[v]][cc1$group2 == "PDAC"]
  p <- tryCatch(wilcox.test(bbp, pdac)$p.value, error = function(e) NA)
  data.frame(Variable = nm, BBP = fmt_iqr(bbp), PDAC = fmt_iqr(pdac), p = fmt_p(p))
}) %>% bind_rows()

kable(table1_cont, caption = "Table 1 continuous variables (Wilcoxon rank-sum test)")
Table 1 continuous variables (Wilcoxon rank-sum test)
Variable BBP PDAC p
Age 39.0 [32.0, 56.0] 62.0 [52.0, 67.2] <0.001
WCC 8.1 [6.5, 9.8] 9.4 [7.1, 13.4] 0.040
Haemoglobin 11.9 [10.7, 13.7] 10.4 [9.0, 12.2] <0.001
Creatinine 69.5 [61.0, 82.8] 71.0 [57.2, 91.8] 0.960
CRP 20.0 [8.0, 73.0] 69.5 [24.8, 141.2] <0.001
Total protein 72.5 [67.2, 78.8] 66.0 [58.0, 69.0] <0.001
Albumin 39.5 [35.2, 42.8] 31.0 [27.0, 36.0] <0.001
Total bilirubin 30.0 [10.0, 92.0] 154.0 [52.5, 269.5] <0.001
Conjugated bilirubin 23.0 [4.0, 73.5] 135.0 [36.0, 242.0] <0.001
ALT 47.5 [17.0, 107.5] 72.0 [34.0, 151.5] 0.052
AST 53.0 [28.0, 90.0] 108.0 [55.0, 171.0] <0.001
ALP 200.0 [111.0, 403.5] 543.0 [286.5, 1144.0] <0.001
GGT 171.0 [80.5, 500.5] 502.0 [231.0, 1088.0] <0.001
sex_known <- cc1 %>% filter(!is.na(gender))
sex_tab <- sex_known %>% count(group2, gender) %>%
  group_by(group2) %>% mutate(pct = round(100 * n / sum(n), 1))
sex_tab
## # A tibble: 4 × 4
## # Groups:   group2 [2]
##   group2 gender     n   pct
##   <chr>  <chr>  <int> <dbl>
## 1 BBP    female    67  80.7
## 2 BBP    male      16  19.3
## 3 PDAC   female    37  39.4
## 4 PDAC   male      57  60.6
fisher_sex <- fisher.test(table(sex_known$group2, sex_known$gender))
fisher_sex$p.value
## [1] 2.276409e-08
cat("Excluded from sex percentages (missing gender):\n")
## Excluded from sex percentages (missing gender):
cc1 %>% filter(is.na(gender)) %>% count(group2)
## # A tibble: 1 × 2
##   group2     n
##   <chr>  <int>
## 1 PDAC       8
cc1 %>% filter(group2 == "PDAC") %>% count(pathology) %>%
  mutate(pct = round(100 * n / sum(n), 1))
## # A tibble: 3 × 3
##   pathology     n   pct
##   <chr>     <int> <dbl>
## 1 LAPC         13  12.7
## 2 MPC          10   9.8
## 3 RPC          79  77.5
pdac_only <- cc1 %>% filter(group2 == "PDAC")

cat("Vital status known:", sum(!is.na(pdac_only$dead_alive)), "of", nrow(pdac_only), "\n")
## Vital status known: 58 of 102
table(pdac_only$dead_alive, useNA = "ifany")  # 0 = Dead, 1 = Alive (verified against date_of_death)
## 
##    0    1 <NA> 
##   36   22   44
cat("\nFollow-up time known:", sum(!is.na(pdac_only$followup)), "of", nrow(pdac_only), "\n")
## 
## Follow-up time known: 33 of 102
fmt_iqr(pdac_only$followup)
## [1] "154.0 [82.0, 356.0]"

Table 2: PDAC disease category (RPC vs. LAPC vs. MPC)

cc2 <- clinical_clean %>% filter(pathology %in% c("RPC", "LAPC", "MPC"))
table(cc2$pathology)
## 
## LAPC  MPC  RPC 
##   13   10   79
table2_cont <- lapply(names(cont_vars), function(nm) {
  v <- cont_vars[[nm]]
  rpc <- cc2[[v]][cc2$pathology == "RPC"]
  lapc <- cc2[[v]][cc2$pathology == "LAPC"]
  mpc <- cc2[[v]][cc2$pathology == "MPC"]
  p <- tryCatch(kruskal.test(list(rpc, lapc, mpc))$p.value, error = function(e) NA)
  data.frame(Variable = nm, RPC = fmt_iqr(rpc), LAPC = fmt_iqr(lapc),
             MPC = fmt_iqr(mpc), p = fmt_p(p))
}) %>% bind_rows()

kable(table2_cont, caption = "Table 2 continuous variables (Kruskal-Wallis test)")
Table 2 continuous variables (Kruskal-Wallis test)
Variable RPC LAPC MPC p
Age 64.0 [55.0, 69.0] 56.0 [51.0, 60.0] 55.5 [49.0, 66.0] 0.065
WCC 8.9 [7.0, 13.2] 10.8 [7.1, 11.4] 13.4 [9.1, 15.5] 0.488
Haemoglobin 10.4 [9.2, 12.2] 9.9 [7.9, 11.6] 11.1 [9.7, 12.2] 0.339
Creatinine 72.0 [58.0, 97.2] 63.0 [56.0, 82.0] 71.0 [57.0, 78.0] 0.523
CRP 56.0 [19.0, 135.0] 94.0 [39.0, 136.0] 113.0 [50.8, 222.8] 0.291
Total protein 64.0 [57.5, 68.0] 65.0 [55.5, 67.5] 72.0 [69.0, 74.2] 0.010
Albumin 31.0 [27.0, 35.0] 27.0 [25.0, 30.0] 36.0 [31.0, 38.0] 0.098
Total bilirubin 160.0 [65.0, 268.0] 100.0 [43.0, 185.0] 60.0 [40.0, 335.0] 0.519
Conjugated bilirubin 141.0 [37.0, 246.0] 90.0 [31.0, 151.0] 51.0 [38.0, 315.0] 0.471
ALT 92.0 [41.0, 151.0] 64.0 [17.0, 101.0] 48.0 [30.0, 362.0] 0.603
AST 104.0 [59.0, 162.0] 106.0 [51.0, 118.0] 123.0 [57.0, 268.0] 0.356
ALP 615.0 [302.0, 1045.0] 386.0 [237.0, 789.0] 397.0 [302.0, 1645.0] 0.433
GGT 502.0 [198.0, 1155.0] 313.0 [151.0, 683.0] 839.0 [400.0, 1021.0] 0.404
sex_known2 <- cc2 %>% filter(!is.na(gender))
sex_known2 %>% count(pathology, gender) %>%
  group_by(pathology) %>% mutate(pct = round(100 * n / sum(n), 1))
## # A tibble: 6 × 4
## # Groups:   pathology [3]
##   pathology gender     n   pct
##   <chr>     <chr>  <int> <dbl>
## 1 LAPC      female     2  15.4
## 2 LAPC      male      11  84.6
## 3 MPC       female     5  50  
## 4 MPC       male       5  50  
## 5 RPC       female    30  42.3
## 6 RPC       male      41  57.7
fisher.test(table(sex_known2$pathology, sex_known2$gender))$p.value
## [1] 0.1569645
# Total protein is the only variable with a significant omnibus result --
# run Dunn's post-hoc to localize which pairs differ
kruskal.test(total_protein_g_l ~ pathology, data = cc2)
## 
##  Kruskal-Wallis rank sum test
## 
## data:  total_protein_g_l by pathology
## Kruskal-Wallis chi-squared = 9.2697, df = 2, p-value = 0.009707
dunn_test(cc2$total_protein_g_l, cc2$pathology)
##       group1 group2          z       p_raw      p_bonf
## LAPC    LAPC    MPC -2.5811724 0.009846540 0.029539620
## LAPC1   LAPC    RPC -0.2407474 0.809750869 1.000000000
## MPC      MPC    RPC  2.9735061 0.002944186 0.008832558
cc2 %>% group_by(pathology) %>%
  summarise(median = median(total_protein_g_l, na.rm = TRUE), n = sum(!is.na(total_protein_g_l)))
## # A tibble: 3 × 3
##   pathology median     n
##   <chr>      <dbl> <int>
## 1 LAPC          65    11
## 2 MPC           72     8
## 3 RPC           64    59

Total protein was the only lab variable with a significant omnibus Kruskal-Wallis result (see table above). Dunn’s post-hoc (Bonferroni- adjusted) localizes this to MPC vs. RPC and MPC vs. LAPC; RPC vs. LAPC does not differ.

bili <- cc2 %>% filter(!is.na(bilirubin_index_clean)) %>%
  mutate(bili_grp = ifelse(bilirubin_index_clean %in% c("1+","2+","3+"),
                            "Positive (1+ to 3+)", bilirubin_index_clean))

bili %>% count(pathology, bili_grp) %>% group_by(pathology) %>%
  mutate(pct = round(100 * n / sum(n), 1))
## # A tibble: 9 × 4
## # Groups:   pathology [3]
##   pathology bili_grp                n   pct
##   <chr>     <chr>               <int> <dbl>
## 1 LAPC      Positive (1+ to 3+)    10  76.9
## 2 LAPC      none                    2  15.4
## 3 LAPC      trace                   1   7.7
## 4 MPC       Positive (1+ to 3+)     6  75  
## 5 MPC       none                    1  12.5
## 6 MPC       trace                   1  12.5
## 7 RPC       Positive (1+ to 3+)    48  77.4
## 8 RPC       none                    4   6.5
## 9 RPC       trace                  10  16.1
fisher.test(table(bili$pathology, bili$bili_grp))$p.value
## [1] 0.6800571
cc2_ord <- cc2 %>% mutate(stage_ord = case_when(
  pathology == "RPC" ~ 1, pathology == "LAPC" ~ 2, pathology == "MPC" ~ 3
))

trend_results <- lapply(names(cont_vars), function(nm) {
  v <- cont_vars[[nm]]
  x <- cc2_ord$stage_ord; y <- cc2_ord[[v]]
  ok <- complete.cases(x, y)
  ct <- tryCatch(cor.test(x[ok], y[ok], method = "spearman"), error = function(e) NULL)
  if (is.null(ct)) return(NULL)
  data.frame(Variable = nm, rho = round(unname(ct$estimate), 3),
             p = fmt_p(ct$p.value), n = sum(ok))
}) %>% bind_rows()
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
## Warning in cor.test.default(x[ok], y[ok], method = "spearman"): cannot compute
## exact p-value with ties
kable(trend_results, caption = "Spearman rank correlation vs. ordinal disease category (RPC=1 < LAPC=2 < MPC=3)")
Spearman rank correlation vs. ordinal disease category (RPC=1 < LAPC=2 < MPC=3)
Variable rho p n
Age -0.212 0.042 92
WCC 0.096 0.372 88
Haemoglobin -0.037 0.731 89
Creatinine -0.122 0.262 86
CRP 0.168 0.141 78
Total protein 0.223 0.050 78
Albumin -0.057 0.599 87
Total bilirubin -0.081 0.457 87
Conjugated bilirubin -0.078 0.474 87
ALT -0.067 0.538 87
AST -0.004 0.972 87
ALP -0.073 0.503 87
GGT -0.011 0.923 87

Age is the only variable showing a significant monotonic trend with disease category (progression-consistent); total protein’s significant omnibus result above is therefore an MPC-specific subgroup effect rather than a graded progression pattern.

Session info

sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
## 
## Matrix products: default
##   LAPACK version 3.12.1
## 
## locale:
## [1] LC_COLLATE=English_United States.utf8 
## [2] LC_CTYPE=English_United States.utf8   
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C                          
## [5] LC_TIME=English_United States.utf8    
## 
## time zone: Africa/Johannesburg
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] knitr_1.51  dplyr_1.2.1
## 
## loaded via a namespace (and not attached):
##  [1] vctrs_0.7.3       cli_3.6.6         rlang_1.2.0       xfun_0.59        
##  [5] otel_0.2.0        generics_0.1.4    jsonlite_2.0.0    glue_1.8.1       
##  [9] htmltools_0.5.9   sass_0.4.10       rmarkdown_2.31    evaluate_1.0.5   
## [13] jquerylib_0.1.4   tibble_3.3.1      fastmap_1.2.0     yaml_2.3.12      
## [17] lifecycle_1.0.5   compiler_4.6.1    pkgconfig_2.0.3   rstudioapi_0.19.0
## [21] digest_0.6.39     R6_2.6.1          utf8_1.2.6        tidyselect_1.2.1 
## [25] pillar_1.11.1     magrittr_2.0.5    bslib_0.11.0      tools_4.6.1      
## [29] cachem_1.1.0