--- title: "1. Introduction to rnaSentry" author: "Abdelkarim Hani Ghrieb" date: "`r Sys.Date()`" output: BiocStyle::html_document: toc: true toc_float: true vignette: > %\VignetteIndexEntry{1. Introduction to rnaSentry} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r rnaSentry-setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` # Introduction `rnaSentry` automates the statistical pipeline used to go from a raw bulk RNA-seq count matrix to a validated prognostic gene signature: quality control and sample-identity auditing, confounder detection prior to differential expression, signature construction with cross-validation, survival modeling, and external validation. Every automated decision is reported with the statistic that justified it. The pipeline is built on core Bioconductor infrastructure — `SummarizedExperiment` for data representation, `BiocParallel` for optional parallel cross-validation, and `GEOquery` for public-cohort reuse — which motivates its inclusion in Bioconductor over CRAN. The five-stage workflow exposes eight exported functions: `load_counts()` (intake audit), `qc_explore()` / `sex_check()` (quality checks), `design_audit()` / `pca_audit()` (confounder detection), and `build_signature()` / `km_curve()` / `cox_model()` / `survival_parametric()` / `validate_external()` (linear Cox `survival::coxph` + `survival::survreg` risk scores, repeated stratified `repeats`/`folds` cross-validation with Harrell's concordance `reverse = TRUE`, and honest external validation). A flag ledger records every guardrail decision (e.g. `events_per_parameter`, `possibly_log_scaled`, `redundant_variable`) for the HTML audit report. Key points: the score is sparse and inspectable (univariate screen + joint Cox), the cross-validated concordance is screening-internal and must be calibrated against a null, and the pipeline never corrects or removes batch effects — it detects and reports them. This vignette demonstrates the pipeline's first stage: quality control and biological-sex verification. Three companion case studies show the pipeline on real and adversarial data and are meant to be read alongside this walkthrough: `vignette("case-study-brca")` (the full pipeline on a breast-cancer cohort), `vignette("case-study-confounder-audit")` (a planted batch confound), and `vignette("case-study-small-cohort")` (why small cohorts fail silently). # Positioning and prior art `rnaSentry`'s niche is **signature discovery with an enforced audit trail and honest failure modes**: quality control, sample-identity checks, confounder detection, and explicit gating are first-class product features rather than side effects. Two Bioconductor neighbours are closest: - **asuri** (Bioc 3.23, `10.18129/B9.bioc.asuri`) — de novo survival-marker discovery from a `SummarizedExperiment` via subsampling glmnet + univariate Cox. The closest methodological analogue; `rnaSentry` differs by design in its sparse, directly inspectable linear score (univariate screen + joint Cox + repeated stratified CV), its concordance/external-validation focus, and its flag-ledger audit layer. - **signifinder** (Bioc 3.16, `10.18129/B9.bioc.signifinder`) — applies 60+ published cancer signatures as single-sample scores (GSVA; single-cell and spatial support). Complementary rather than competing: it answers "what do known signatures say about this sample?" where `rnaSentry` answers "can a new survival signature be learned from this cohort?". It can be used downstream to benchmark a discovered signature. Two prior-art anchors for the single-sample scoring approach itself: **SurvMarker** (Gammune & Gu, 2025, DOI 10.64898/2025.12.31.697184) uses PCA-based weighted scoring, and the **mRNAsi** stemness index (Malta *et al.*, *Cell* 2018;173:338-354.e15) uses OCLR-based scoring. | Tool | Scope | Scoring | Audit | |---|---|---|---| | **rnaSentry** | De novo prognostic signature, bulk RNA-seq | Sparse linear: univariate Cox + joint `coxph`, repeated CV concordance + external `validate_external` | Full flag ledger, gated report | | **asuri** (Bioc 3.23) | De novo marker via subsampling glmnet + Cox | Penalised glmnet + Cox | Subsampling stability, no ledger | | **signifinder** (Bioc 3.16) | Apply 60+ published signatures as single-sample scores (GSVA) | GSVA | N/A (applies known signatures) | | **SurvMarker** (2025) | Single-sample prognostic scoring | PCA-weighted | No audit trail | | **mRNAsi** (2018) | Stemness single-sample score | OCLR | No survival model | Full profiles are in the validation dossier. # Installation ```{r install, eval = FALSE} if (!require("BiocManager", quietly = TRUE)) install.packages("BiocManager") BiocManager::install("rnaSentry") ``` # Design decisions Two deliberate choices in the pipeline are worth stating explicitly, because they are easy to mistake for oversights. First, `build_signature()` screens genes *adjusted* for the design terms supplied via `design_terms` (by default), so that genes whose survival association is driven entirely by a confounder such as batch are not selected. The final signature weights, however, are estimated from a gene-only model. This asymmetry is intentional: the risk score must remain portable to external cohorts that do not record the discovery cohort's design covariates. Adjusted inference on the chosen signature is provided separately by `cox_model()`. Second, `pca_audit()` reports both raw and Benjamini-Hochberg adjusted p-values for its per-PC batch tests but flags on the raw p-value, whereas `design_audit()` flags candidate confounders on BH-adjusted p-values. This, too, is intentional: `pca_audit()` tests at most a handful of PCs against a single batch variable, where a multiple-testing correction would be a near no-op, while `design_audit()` scans many candidate confounders at once and needs the correction to control spurious flags. # Data intake `load_counts()` validates a raw count matrix and its sample metadata before the pipeline runs. It returns a `SummarizedExperiment` and records any intake issues in a flag ledger in the object's metadata, so problems such as duplicate sample identifiers or non-integer counts are surfaced up front and carried into the rendered report's audit trail. ```{r data-intake} library(rnaSentry) library(SummarizedExperiment) cnt <- matrix(rpois(600, lambda = 200), nrow = 60, ncol = 10, dimnames = list(paste0("gene", 1:60), paste0("S", 1:10))) cnt <- cnt * 1.0 cnt[1, ] <- 0L # gene1 has no counts anywhere (empty row) cnt[2, 1] <- cnt[2, 1] + 0.5 # a non-integer value colnames(cnt)[10] <- "S1" # a duplicate sample identifier cd <- S4Vectors::DataFrame(condition = rep(c("tumor", "normal"), each = 5), row.names = paste0("S", 1:10)) se_in <- load_counts(cnt, cd) S4Vectors::metadata(se_in)$flags ``` Each row of the ledger is a `(check, severity, detail, stage)` record; the duplicate sample is dropped (critical), while the non-integer and empty-row issues are flagged as warnings and carried forward. # Quality control `qc_explore()` takes a `SummarizedExperiment` of raw counts and audits it for duplicate sample identifiers, missing metadata, non-integer count values, and library-size outliers. ```{r qc-explore} set.seed(1) counts <- matrix(rpois(600, lambda = 200), nrow = 60, ncol = 10, dimnames = list(paste0("gene", 1:60), paste0("S", 1:10))) counts[, 1] <- counts[, 1] * 50L coldata <- S4Vectors::DataFrame(condition = rep(c("tumor", "normal"), each = 5), row.names = paste0("S", 1:10)) se <- SummarizedExperiment(assays = list(counts = counts), colData = coldata) qc <- qc_explore(se) qc ``` # Biological sex verification `sex_check()` compares reported sex in sample metadata against sex inferred from XIST and Y-chromosome marker gene expression, to catch sample swaps or metadata errors before they propagate downstream. ```{r sex-check} genes <- c("XIST", "RPS4Y1", "DDX3Y", "KDM5D") expr <- matrix(c(950, 5, 5, 5, # sample looks female 5, 400, 380, 410), # sample looks male nrow = 4, dimnames = list(genes, c("S1", "S2"))) coldata2 <- S4Vectors::DataFrame(sex = c("F", "F"), row.names = c("S1", "S2")) # S2 mislabeled se2 <- SummarizedExperiment(assays = list(counts = expr), colData = coldata2) sex_check(se2) ``` # The survival pipeline in one run `run_rnaSentry()` orchestrates the full pipeline on a single cohort: confounder scan, PCA audit, signature construction with cross-validation, Kaplan-Meier risk groups, an adjusted Cox model, and parametric survival models. Here we build a synthetic cohort with an engineered survival signal, split it into discovery and validation halves, and run the pipeline on the discovery half. ```{r run-pipeline} set.seed(1) n_genes <- 120 n_samples <- 120 counts <- matrix(stats::rpois(n_genes * n_samples, lambda = 500), nrow = n_genes, ncol = n_samples, dimnames = list(paste0("gene", 1:n_genes), paste0("S", 1:n_samples))) sig_expr <- colMeans(counts[1:5, , drop = FALSE]) risk <- scale(sig_expr)[, 1] * 0.4 event_time <- stats::rexp(n_samples, rate = 0.03 * exp(0.8 * risk)) censor_time <- stats::rexp(n_samples, rate = 0.02) coldata <- S4Vectors::DataFrame(time = pmin(event_time, censor_time), event = as.integer(event_time < censor_time), condition = factor(rep(c("A", "B"), length.out = n_samples)), batch = factor(rep(c("B1", "B2", "B3"), length.out = n_samples)), row.names = colnames(counts)) se_all <- SummarizedExperiment(assays = list(counts = counts), colData = coldata) discovery <- se_all[, 1:72] external <- se_all[, 73:120] run <- run_rnaSentry(discovery, time_col = "time", event_col = "event", top_n = 5, repeats = 2, folds = 3, seed = 7, design_vars = c("condition", "batch"), design_terms = "condition", render_report = FALSE) run ``` The run locked the discovered signature: `run_rnaSentry()` is single-use per session, and a second call in the same session is refused until the lock is released. This is the reproducibility guardrail against silently re-selecting signature genes after survival analysis. ```{r unlock-signature} # Release the lock so the pipeline can be run again in this session. run$stages$build_signature <- lock_signature(run$stages$build_signature, lock = FALSE) ``` The result's `stages` list holds each stage's full result, including the `flags` data.frame that records every issue raised: ```{r stage-flags} run$stages$build_signature$flags ``` ## External validation The discovery cutpoint is recorded by the `km_curve` stage. Re-scoring the held-out samples against that same cutpoint, without re-estimating anything from the external data, gives an honest portability check: ```{r ext-validate} val <- validate_external(run$stages$build_signature, external, cutpoint = run$stages$km_curve$cutpoint) val ``` The portability check is also visual: scoring the held-out samples against the locked discovery cutpoint, without re-estimating anything from the external data, splits the external cohort into risk groups whose curves can be compared on truly held-out data: ```{r intro-ext-km, fig.width = 7, fig.height = 5, fig.align = "center", fig.cap = "External-cohort Kaplan-Meier curves for the low- and high-risk groups, scored against the locked discovery cutpoint without re-estimation. The log-rank p-value tests the split on held-out data."} plot(val) ``` On this synthetic external half the split is **not** statistically separated (log-rank p and concordance printed above): with 48 held-out samples the toy cohort is far too small to demonstrate transfer. The honest-negative pattern is the point -- an out-of-sample split that does not separate is reported as such, and a real transfer claim requires an independent cohort with enough events to detect the effect. A real discovery-to-external transfer on independent GEO cohorts — where transfer was *not* demonstrated and the reasons (low event count, CV-optimism) are quantified — is documented in the README's case study 4 (LUAD, GSE31210 -> GSE50081). # Assumptions and limitations `rnaSentry` is deliberately narrow. Read this before trusting any output. - **Bulk, right-censored survival only.** The pipeline expects bulk RNA-seq and standard right-censored (time, event) end points. Single-cell data, continuous or binary outcomes, competing risks, time-varying covariates, and left truncation are out of scope. - **Linear Cox risk scores.** A signature is a weighted linear combination of gene expression. Higher scores mean higher hazard (shorter survival); all concordance estimates use this convention. - **No batch correction, no differential expression.** Confounders are *detected and reported*, and univariate screening can be *adjusted* for design terms, but the pipeline never corrects or removes effects and makes no DE calls. - **The small-cohort trap.** With fewer than ~5 events per signature gene, cross-validated concordance and hazard ratios are unstable. `build_signature()` warns (`events_per_parameter`); a flagged signature should be treated as hypothesis-generating, not confirmatory. - **The CV concordance is screening-internal.** Gene selection runs on the full cohort before the CV split, so `build_signature()`'s concordance is optimistic: on a large panel even survival-permuted data yields a high null (≈0.8 on ~21k genes) rather than 0.5. Read it relative to a matched null (permutation or random-gene control), and rely on `validate_external()` on an independent cohort as the fully out-of-sample estimate. - **Right input scale.** Raw counts are log2-transformed internally. If you store already-logged data under a name like `counts`, it will be double-logged; the pipeline warns (`possibly_log_scaled`). Store pre-scaled data under the name `logcounts`. - **One discovery per session.** `run_rnaSentry()` locks the discovered signature and refuses to re-run until `lock_signature(sig, lock = FALSE)` releases the lock. This prevents silent gene-set re-selection after survival analysis. To analyze a second cohort in the same session, unlock the first signature first. ## Avoiding common mistakes Two misuses are easy to hit and easy to avoid. First, wrong input scale. If your expression matrix is already log-transformed but stored under a `counts`-named assay, every downstream stage sees a double-logged matrix. The pipeline catches this and warns: ```{r wrong-scale, warning = TRUE} prelogged <- log2(as.matrix(assay(se_all, "counts")) + 1) se_wrong <- se_all assay(se_wrong, "counts") <- prelogged build_signature(se_wrong, "time", "event", top_n = 5, repeats = 1, folds = 2, seed = 7, min_events_per_parameter = 1) ``` The fix is to name such an assay `logcounts`, which the pipeline uses as-is without a second transform. Second, underpowered discovery. On a small cohort, `build_signature()` warns that the event count per signature gene is low: ```{r underpowered, warning = TRUE} small <- se_all[, 1:40] build_signature(small, "time", "event", top_n = 10, repeats = 1, folds = 2, seed = 7) ``` Both warnings are also recorded in each stage's `flags` data.frame, so they propagate into the rendered report's audit trail. # Session info ```{r session-info} sessionInfo() ```