This case study is an illustrative walkthrough of
rnaSentry on the public GSE20685 breast-cancer dataset,
provided to show how the pipeline behaves on real data. It is
not peer-reviewed and the signature reported here is
provisional. Treat every number below as a demonstration, not a clinical
claim.
The narrative follows the three-tier structure used across the case studies: a naive analysis first, then rnaSentry standing guard, then the counterfactual – what a researcher would have concluded without the guardrails, and what the guarded analysis actually recovers.
We use GSE20685 (Li et al., 2010), an Affymetrix GPL570
(U133 Plus 2.0) microarray series of 327 primary breast tumors with
overall-survival follow-up. When GEOquery is available, the
full series matrix is downloaded from GEO, processed to gene-level
expression (probe-to-gene collapse by largest mean expression), and
subset to the top 3000 most variable genes. The result is a
SummarizedExperiment with an exprs assay
(microarray log2 intensities, used as-is) plus time
(years), event (1 = death), age, and
subtype in the sample metadata. GEOquery
caches the series matrix itself, so repeat builds reuse the local copy.
Only if GEOquery is unavailable or the download fails, an
internal synthetic fallback of identical structure
(.brca_fallback_cohort(), fixed seed for reproducibility)
is used instead so the vignette still builds offline.
library(rnaSentry)
library(SummarizedExperiment)
#> Loading required package: MatrixGenerics
#> Loading required package: matrixStats
#>
#> Attaching package: 'MatrixGenerics'
#> The following objects are masked from 'package:matrixStats':
#>
#> colAlls, colAnyNAs, colAnys, colAvgsPerRowSet, colCollapse,
#> colCounts, colCummaxs, colCummins, colCumprods, colCumsums,
#> colDiffs, colIQRDiffs, colIQRs, colLogSumExps, colMadDiffs,
#> colMads, colMaxs, colMeans2, colMedians, colMins, colOrderStats,
#> colProds, colQuantiles, colRanges, colRanks, colSdDiffs, colSds,
#> colSums2, colTabulates, colVarDiffs, colVars, colWeightedMads,
#> colWeightedMeans, colWeightedMedians, colWeightedSds,
#> colWeightedVars, rowAlls, rowAnyNAs, rowAnys, rowAvgsPerColSet,
#> rowCollapse, rowCounts, rowCummaxs, rowCummins, rowCumprods,
#> rowCumsums, rowDiffs, rowIQRDiffs, rowIQRs, rowLogSumExps,
#> rowMadDiffs, rowMads, rowMaxs, rowMeans2, rowMedians, rowMins,
#> rowOrderStats, rowProds, rowQuantiles, rowRanges, rowRanks,
#> rowSdDiffs, rowSds, rowSums2, rowTabulates, rowVarDiffs, rowVars,
#> rowWeightedMads, rowWeightedMeans, rowWeightedMedians,
#> rowWeightedSds, rowWeightedVars
#> Loading required package: GenomicRanges
#> Loading required package: stats4
#> Loading required package: BiocGenerics
#> Loading required package: generics
#>
#> Attaching package: 'generics'
#> The following objects are masked from 'package:base':
#>
#> as.difftime, as.factor, as.ordered, intersect, is.element, setdiff,
#> setequal, union
#>
#> Attaching package: 'BiocGenerics'
#> The following objects are masked from 'package:stats':
#>
#> IQR, mad, sd, var, xtabs
#> The following object is masked from 'package:utils':
#>
#> data
#> The following objects are masked from 'package:base':
#>
#> anyDuplicated, aperm, append, as.data.frame, basename, cbind,
#> colnames, dirname, do.call, duplicated, eval, evalq, Filter, Find,
#> get, grep, grepl, is.unsorted, lapply, Map, mapply, match, mget,
#> order, paste, pmax, pmax.int, pmin, pmin.int, Position, rank,
#> rbind, Reduce, rownames, sapply, saveRDS, scale, sequence, table,
#> tapply, transform, unique, unsplit, which.max, which.min
#> Loading required package: S4Vectors
#>
#> Attaching package: 'S4Vectors'
#> The following object is masked from 'package:utils':
#>
#> findMatches
#> The following objects are masked from 'package:base':
#>
#> expand.grid, I, unname
#> Loading required package: IRanges
#> Loading required package: Seqinfo
#> Loading required package: Biobase
#> Welcome to Bioconductor
#>
#> Vignettes contain introductory material; view with
#> 'browseVignettes()'. To cite Bioconductor, see
#> 'citation("Biobase")', and for packages 'citation("pkgname")'.
#>
#> Attaching package: 'Biobase'
#> The following object is masked from 'package:MatrixGenerics':
#>
#> rowMedians
#> The following objects are masked from 'package:matrixStats':
#>
#> anyMissing, rowMedians
library(survival)
se_real <- fetch_gse20685()
#> Setting options('download.file.method.GEOquery'='auto')
#> Setting options('GEOquery.inmemory.gpl'=FALSE)
#> Downloading GSE20685 from GEO ...
#> Found 1 file(s)
#> GSE20685_series_matrix.txt.gz
#> getGEO() now returns SummarizedExperiment objects by default. Pass returnType = 'ExpressionSet' for the previous behavior.
#> This message is displayed once per session.
using_real <- !is.null(se_real)
if (using_real) {
se <- se_real
} else {
message("GEOquery unavailable or download failed; using synthetic fallback.")
se <- rnaSentry:::.brca_fallback_cohort()
}
se
#> class: SummarizedExperiment
#> dim: 3000 327
#> metadata(0):
#> assays(1): exprs
#> rownames(3000): CRISP3 S100A7 ... HYDIN MAGEB3
#> rowData names(0):
#> colnames(327): GSM519117 GSM519118 ... GSM519442 GSM519443
#> colData names(4): time event age subtype
table(se$subtype)
#>
#> type I type II type III type IV type V type VI
#> 37 34 41 81 41 93
sum(se$event)
#> [1] 83The cohort analyzed below is the live GSE20685 series matrix from GEO (downloaded via GEOquery, which caches the series matrix locally). It records 83 deaths, so every number printed in this vignette reproduces whichever source was used.
A standard, unguarded analysis of this cohort would proceed along these lines: pick a few highly variable genes, screen them against survival with univariate Cox regressions, average the survivors into a risk score, and quote the concordance that R prints by default. Let us run exactly that.
lg <- assays(se)$exprs
vars <- apply(lg, 1, stats::var, na.rm = TRUE)
cand <- names(sort(vars, decreasing = TRUE))[seq_len(200)]
stat <- do.call(rbind, lapply(cand, function(g) {
s <- summary(coxph(Surv(se$time, se$event) ~ lg[g, ]))
c(beta = s$coefficients[1, 1], p = s$coefficients[1, 5])
}))
rownames(stat) <- cand
top <- stat[order(stat[, "p"]), , drop = FALSE][seq_len(5), , drop = FALSE]
naive_score <- colMeans(sign(top[, "beta"]) * lg[rownames(top), , drop = FALSE])
c_default <- as.numeric(concordance(Surv(se$time, se$event) ~
naive_score)$concordance[1])
c_reverse <- as.numeric(concordance(Surv(se$time, se$event) ~ naive_score,
reverse = TRUE)$concordance[1])
c(c_default = c_default, c_reverse = c_reverse, sum = c_default + c_reverse)
#> c_default c_reverse sum
#> 0.323754 0.676246 1.000000survival::concordance() defaults to the convention
larger score means longer survival
(reverse = FALSE). A Cox risk score means the opposite –
larger score means shorter survival – so the default read is
inverted: C_default = 1 - Harrell's C. The naive analyst
sees C_default < 0.5 and concludes “below chance, poor
generalization, the signature is a failure.”
This is not hypothetical. The first real-data face-validity run of
this very cohort reported CV concordance 0.217 and a
naive reading would rationalize it as extreme selection-induced winner’s
curse; the correct value is 1 - 0.217 = 0.783, a
directionally correct held-out result (see Limitations on what CV alone
can and cannot show). The full diagnosis, including the
C + C_rev = 1 invariant you can see holding above, is
recorded in the repository’s validation dossier. The parity test suite
now asserts that invariant on every fold so the convention cannot
silently flip again.
Even read in the correct direction, a naive analyst stops at the
cross- validated mean and treats it as an out-of-sample estimate. As the
LUAD honest-negative case study documents, a screening-internal CV on a
large candidate panel is optimistic: even survival-permuted
data yields a null CV around 0.8 on ~21k genes, not 0.5. Any concordance
below must be read relative to a matched null, and the only fully
out-of-sample number is validate_external() on an
independent cohort.
Now the same cohort through the guarded pipeline: confounder scan,
PCA audit, signature construction with repeated cross-validation, and
Kaplan-Meier risk groups. Because the cohort metadata records
age and subtype, both are supplied as design
variables so the audit can check them against the expression
surrogate.
run <- run_rnaSentry(se, time_col = "time", event_col = "event",
outcome_col = "overall_survival",
design_vars = c("age", "subtype"),
top_n = 20, repeats = 3, folds = 3, seed = 42,
render_report = FALSE)
#> Warning: Only 83 event(s) for a 20-gene signature (4.2 events per parameter),
#> below 'min_events_per_parameter' = 5. Cross-validated concordance and hazard
#> ratios are unstable at this event count; consider fewer genes or a larger
#> cohort.
sig <- run$stages$build_signature
head(sig$genes)
#> [1] "C20orf96" "SPRR3" "EPHX2" "CD1E" "CASP14" "ARNT2"
sig$cv_summary
#> mean sd
#> 0.78469786 0.03000978
sig$flags
#> check severity
#> 1 events_per_parameter warning
#> 2 signature_locked info
#> detail
#> 1 Only 83 event(s) for a 20-gene signature (4.2 events per parameter), below 'min_events_per_parameter' = 5. Cross-validated concordance and hazard ratios are unstable at this event count; consider fewer genes or a larger cohort.
#> 2 Signature locked against downstream mutation.
#> stage
#> 1 build_signature
#> 2 lock_signatureThe run locks the discovered signature, so a second discovery in the same session is refused until the lock is released (see the main vignette). Because all vignettes build in one session, we release it here so the remaining vignettes can run their own pipeline calls:
The guardrails that the naive analysis missed are now explicit in the flag ledger:
reverse = TRUE; the sign convention cannot
silently flip (a parity test asserts C + C_rev = 1 on every
fold).events_per_parameter warning is expected and matches the
documented limitation: 83 deaths for a 20-gene signature is 4.2 events
per parameter, below the guardrail of 5. The signature should be read as
hypothesis-generating, and the cross-validated concordance and hazard
ratios as unstable at this event count.The discovered signature separates the cohort into risk groups whose survival curves differ dramatically at the discovery cutpoint:
Kaplan-Meier survival curves for the low- and high-risk groups at the discovery cutpoint, with the log-rank p-value annotated. The risk groups diverge sharply.
The adjusted Cox model re-estimates each signature gene’s association with survival jointly with the other signature genes, so the forest plot below shows which genes carry their effect even after adjustment for the rest of the signature:
Adjusted Cox forest plot for the signature genes: log hazard ratio with 95% confidence interval, ordered by p-value. The vertical dashed line marks no effect (log HR = 0).
The parametric stage fits a parametric survival model (exponential and Weibull) to the same risk groups and overlays it on the Kaplan-Meier curves, providing a smooth estimate of the survival function when a parametric shape is a reasonable description:
Kaplan-Meier curves with parametric survival-model overlay for the low- and high-risk groups.
da <- run$stages$design_audit
da$confounder_table
#> variable type n test statistic df p_value effect_size
#> 1 age numeric 327 lm 0.7932527 1, 325 3.737772e-01 0.002434835
#> 2 subtype categorical 327 aov 245.7217644 5, 321 2.008554e-107 0.792850947
#> effect_size_type flagged adj_p
#> 1 r_squared FALSE 3.737772e-01
#> 2 eta_squared TRUE 4.017108e-107
da$formula_text
#> [1] "~ age + subtype"subtype is flagged as associated with the expression
surrogate (very large eta-squared) and the recommended design formula
therefore keeps it, so that univariate gene screening is adjusted for
subtype. age is not flagged. This is exactly the kind of
design decision rnaSentry is meant to surface before any
differential-expression or signature work.
Coloring the PCA audit scores by subtype shows the same structure the
surrogate scan detects: the breast-cancer subtypes occupy distinct
regions of expression space, which is why omitting subtype
from the design would let subtype-driven expression masquerade as
survival association:
PC1-PC2 scatter of the GSE20685 subset colored by breast-cancer subtype.
The subtypes separate along the leading PCs, mirroring the design
audit’s surrogate-association flag for subtype.
| Naive analysis (Tier 1) | rnaSentry (Tier 2) | |
|---|---|---|
| Score direction | default convention reads the score as protective;
C < 0.5 looks like failure |
reverse = TRUE; C_rev = 1 - C_default,
direction correct |
| Headline concordance | C_default quoted as gospel |
C_rev quoted with its convention, and flagged
screening-internal |
| Out-of-sample claim | none / implicit | only validate_external() counts; matched-null
calibration required |
| Events per parameter | not reported | 4.2 < 5, warned in the ledger |
| Design | ad-hoc | subtype flagged and adjusted for |
What a guarded analysis can claim: on the full-cohort face-validity run of this dataset rnaSentry reports a directionally correct held-out concordance of 0.783 against a 0.582 random-gene control (and a 0.490 simulated null) – while the naive pipeline either reports a “failed” signature (inverted convention) or an uncalibrated 0.80 (screening-internal optimism). Caveat (see Limitations): the rerun CV of 0.785 sits inside, not above, a matched permuted-selection noise band (0.744–0.785), so the CV alone is not evidence of discrimination – the signal evidence is the hazard directions and the risk split, and transfer remains unvalidated here. The events-per-parameter limitation is stated, not hidden.
exprs
are already log2 intensities, used as-is (the microarray assay name, not
log-counts).GEOquery::getGEO()).sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] survival_3.8-12 SummarizedExperiment_1.43.0
#> [3] Biobase_2.73.2 GenomicRanges_1.65.4
#> [5] Seqinfo_1.3.2 IRanges_2.47.5
#> [7] S4Vectors_0.51.10 BiocGenerics_0.59.12
#> [9] generics_0.1.4 MatrixGenerics_1.25.0
#> [11] matrixStats_1.5.0 rnaSentry_0.99.19
#> [13] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 ggplot2_4.0.3 xfun_0.61
#> [4] bslib_0.12.0 httr2_1.3.0 lattice_0.23-1
#> [7] tzdb_0.5.0 vctrs_0.7.3 tools_4.6.1
#> [10] curl_8.0.0 tibble_3.3.1 R.oo_1.27.1
#> [13] pkgconfig_2.0.3 Matrix_1.7-6 data.table_1.18.6.1
#> [16] RColorBrewer_1.1-3 rentrez_1.2.4 S7_0.2.2
#> [19] lifecycle_1.0.5 farver_2.1.2 compiler_4.6.1
#> [22] statmod_1.5.2 htmltools_0.5.9 sys_3.4.3
#> [25] buildtools_1.0.0 sass_0.4.10 yaml_2.3.12
#> [28] pillar_1.11.1 jquerylib_0.1.4 tidyr_1.3.2
#> [31] R.utils_2.13.0 DelayedArray_0.39.8 cachem_1.1.0
#> [34] limma_3.99.0 abind_1.4-8 tidyselect_1.2.1
#> [37] digest_0.6.39 dplyr_1.2.1 purrr_1.2.2
#> [40] labeling_0.4.3 maketools_1.3.2 splines_4.6.1
#> [43] fastmap_1.2.0 grid_4.6.1 GEOquery_2.81.28
#> [46] cli_3.6.6 SparseArray_1.13.4 magrittr_2.0.5
#> [49] S4Arrays_1.13.2 XML_3.99-0.25 readr_2.2.0
#> [52] withr_3.0.3 scales_1.4.0 rmarkdown_2.32
#> [55] XVector_0.53.0 otel_0.2.0 R.methodsS3_1.8.2
#> [58] hms_1.1.4 evaluate_1.0.5 knitr_1.52
#> [61] rlang_1.3.0 glue_1.8.1 BiocManager_1.30.27
#> [64] xml2_1.6.0 jsonlite_2.0.0 R6_2.6.1