Sample-level RNA-seq quality control#
Sample-level quality control for a bulk expression matrix, applied before normalisation.
Overview#
Before normalisation it is worth asking whether every sample in the matrix belongs there. This step applies sample-level quality control to a bulk expression matrix - flagging samples whose overall expression profile is inconsistent with the rest of the cohort, which usually means a library preparation or labelling problem rather than biology.
Dropping such samples here matters more than it looks: they inflate the residual variance in the association scan and can distort the hidden-factor covariates estimated downstream.
Sample-level QC of a bulk RNA-seq expression matrix, following the GTEx V8 approach. It runs in two parts: qc_1 removes low-expression genes (genes expressed below the TPM threshold in too many samples), and qc_2 detects and removes outlier samples using three checks (a D-statistic histogram, a Relative Log Expression (RLE) plot, and hierarchical clustering). A raw count matrix, if provided, is filtered to the same genes and samples as the QC’d TPM matrix.
When to run it. After expression quantification, before normalisation and covariate preprocessing.
Input#
--tpm-gctinput/rnaseq/protocol_example.rnaseq.tpm_matrix.bed(gene expression in TPM; gene ID in the first column, one column per sample. Required.)
gene_ID SAMPLE_001 SAMPLE_002 SAMPLE_003 SAMPLE_004 SAMPLE_005 SAMPLE_006 SAMPLE_007 ...
ENSG00000284070 4.46908614068004 3.6324384389436 0 0.926449274182614 8.44864560635242 16.3456237133014 4.07740959667351 ...
ENSG00000273342 0 19.892726241898 13.6287618206549 28.5428919916226 10.1099209273507 17.0947587639983 0 ...
--counts-gctinput/rnaseq/protocol_example.rnaseq.count_matrix.bed(raw gene count matrix. Optional; if given it is filtered to match the QC’d TPM genes and samples.)
gene_ID SAMPLE_001 SAMPLE_002 SAMPLE_003 SAMPLE_004 SAMPLE_005 SAMPLE_006 SAMPLE_007 ...
ENSG00000284070 197 193 122 180 217 257 195 ...
ENSG00000273342 80 274 243 318 226 260 133 ...
--cwd output/rnaseq(working directory all outputs are written under. Defaults tooutput.)--container(an empty string runs the workflow with the locally installed tools instead of a container)
The filtering thresholds all have defaults and only need setting to override them:
--low-expr-TPM 0.1(a gene must exceed this TPM to count as expressed)--low-expr-TPM-percent 0.2(a gene is kept only if it is expressed in at least this fraction of samples)--RLEFilterPercent 0.05(fraction of samples dropped by the relative log expression filter)--DSFilterPercent 0.05(fraction of samples dropped by the D-statistic filter. For a small cohort raise it, e.g.0.1, so a single sample is not over-flagged.)--topk-genes 100(number of top-expressed genes used for the sample clustering check)--cluster-percent 0.6(fraction of the top genes a sample must share to join a cluster)--pvalue-cutoff 0.05(significance threshold for the outlier test)--cluster-level 5(tree depth at which clusters are cut)
Output#
protocol_example.low_expression_filtered.tpm.gct.gz(qc_1) (TPM matrix after low-expression gene filtering. All outputs go to--cwd; the basename comes from the TPM input name truncated at the first dot, so here it isprotocol_example.)protocol_example.low_expression_filtered.outlier_removed.tpm.gct.gz(qc_2) (TPM matrix after outlier-sample removal)protocol_example.low_expression_filtered.outlier_removed.geneCount.gct.gz(qc_3) (raw count matrix filtered to the same genes and samples)
Two diagnostic plots are also written but are not declared in any step output: statement, so SoS does not track them:
protocol_example.low_expression_filtered.outlier_removed.tpm.gct.RLEplot.pdf(relative log expression plot)protocol_example.low_expression_filtered.outlier_removed.tpm.gct.D_stat_hist.pdf(D-statistic histogram; not present in the example output)
The qc_3 step additionally:
Filter out the geneCount table based on TPM table.
Adds two comment lines above the header of TPM and geneCount table to mimick the original output from RNASeQC.
Minimal Working Example#
Timing: ~2-5 min (on toy dataset)
sos run pipeline/bulk_expression_QC.ipynb qc \
--cwd output/rnaseq \
--tpm-gct input/rnaseq/protocol_example.rnaseq.tpm_matrix.bed \
--counts-gct input/rnaseq/protocol_example.rnaseq.count_matrix.bed
Command Interface#
sos run pipeline/bulk_expression_QC.ipynb -h
usage: sos run pipeline/bulk_expression_QC.ipynb
[workflow_name | -t targets] [options] [workflow_options]
workflow_name: Single or combined workflows defined in this script
targets: One or more targets to generate
options: Single-hyphen sos parameters (see "sos run -h" for details)
workflow_options: Double-hyphen workflow-specific parameters
Workflows:
qc
Global Workflow Options:
--modular-script-dir code/script (as path)
--tpm-gct VAL (as path, required)
Required input is TPM file
--counts-gct . (as path)
Raw counts file is optional and if available, it will be
filtered to match with the TPM file sample and genes
--cwd output (as path)
--container ''
--entrypoint ('micromamba run -a "" -n' + ' ' + re.sub(r'(_apptainer:latest|_docker:latest|\.sif)$', '', container.split('/')[-1])) if container else ""
--job-size 1 (as int)
For cluster jobs, number commands to run per job
--walltime 5h
Wall clock time expected
--mem 16G
Memory expected
--numThreads 8 (as int)
Number of threads
Sections
qc_1:
Workflow Options:
--low-expr-TPM 0.1 (as float)
--low-expr-TPM-percent 0.2 (as float)
qc_2:
Workflow Options:
--RLEFilterPercent 0.05 (as float)
--DSFilterPercent 0.05 (as float)
--topk-genes 100 (as int)
--cluster-percent 0.6 (as float)
--pvalue-cutoff 0.05 (as float)
--cluster-level 5 (as int)
qc_3:
Workflow implementation#
[global]
parameter: modular_script_dir = path('code/script') # override with --modular-script-dir
# Required input is TPM file
parameter: tpm_gct = path
# Raw counts file is optional and if available, it will be filtered to match with the TPM file sample and genes
parameter: counts_gct = path()
parameter: cwd = path("output")
parameter: container = ""
import re
parameter: entrypoint= ('micromamba run -a "" -n' + ' ' + re.sub(r'(_apptainer:latest|_docker:latest|\.sif)$', '', container.split('/')[-1])) if container else ""
cwd = path(f'{cwd:a}')
# For cluster jobs, number commands to run per job
parameter: job_size = 1
# Wall clock time expected
parameter: walltime = "5h"
# Memory expected
parameter: mem = "16G"
# Number of threads
parameter: numThreads = 8
[qc_1 (basic check and low expression filtering)]
parameter: low_expr_TPM = 0.1
parameter: low_expr_TPM_percent = 0.2
input: tpm_gct
output: f'{cwd}/{_input:bnnn}.low_expression_filtered.tpm.gct.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads
bash: expand= "${ }", stderr = f'{_output:nnn}.stderr', stdout = f'{_output:nnn}.log', container = container, entrypoint = entrypoint
Rscript ${modular_script_dir}/molecular_phenotypes/QC/bulk_expression_QC.R \
--step qc_1 \
--cwd "${cwd}" \
--tpm-gct "${_input}" \
--low-expr-TPM ${low_expr_TPM} \
--low-expr-TPM-percent ${low_expr_TPM_percent} \
--numThreads ${numThreads}
Caution. The offset used for the log transformation on TPM is unresolved. GTEx suggests an offset of 1; the recommendation kept here follows these authors, where both 0.0001 and 1 are used at different steps.
[qc_2 (remove outliers)]
parameter: RLEFilterPercent = 0.05
parameter: DSFilterPercent = 0.05
parameter: topk_genes = 100
parameter: cluster_percent = 0.6
parameter: pvalue_cutoff = 0.05
parameter: cluster_level = 5
output: f'{cwd}/{_input:bnnn}.outlier_removed.tpm.gct.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads
bash: expand= "${ }", stderr = f'{_output:nnn}.stderr', stdout = f'{_output:nnn}.log', container = container, entrypoint = entrypoint
Rscript ${modular_script_dir}/molecular_phenotypes/QC/bulk_expression_QC.R \
--step qc_2 \
--cwd "${cwd}" \
--tpm-gct "${_input}" \
--RLEFilterPercent ${RLEFilterPercent} \
--DSFilterPercent ${DSFilterPercent} \
--topk-genes ${topk_genes} \
--cluster-percent ${cluster_percent} \
--pvalue-cutoff ${pvalue_cutoff} \
--cluster-level ${cluster_level} \
--numThreads ${numThreads}
Remove genes and samples from raw counts#
[qc_3 (remove gene and samples from raw counts)]
stop_if(not counts_gct.is_file())
output: f'{cwd}/{_input:bnnn}.geneCount.gct.gz'
task: trunk_workers = 1, trunk_size = job_size, walltime = walltime, mem = mem, cores = numThreads
bash: expand= "${ }", stderr = f'{_output:nn}.stderr', stdout = f'{_output:nn}.stdout', container = container, entrypoint = entrypoint
Rscript ${modular_script_dir}/molecular_phenotypes/QC/bulk_expression_QC.R \
--step qc_3 \
--cwd "${cwd}" \
--tpm-gct "${_input}" \
--counts-gct "${counts_gct}" \
--numThreads ${numThreads}