scRNA-seq Basics
Nasir Mahmood Abbasi, PhD
Bioinformatics Educator
Learning Objectives & Prerequisites
- Prerequisites: Complete Linux, biological data formats, and basic statistics; install current Scanpy or Seurat in an isolated environment.
- Objective: Load a 10x count matrix, inspect QC distributions, normalize, identify variable features, run PCA/UMAP, and cluster cells without treating fixed thresholds as universal.
- Expected Output: A reproducible AnnData or Seurat object with documented per-sample QC decisions, neighborhood graph, UMAP, and cluster labels.
Suggested route: use the Bioinformatics Learning Path to review any prerequisite stage before continuing.
From Raw Counts to Biological Insights
Once the sequencing facility provides raw FASTQ files, the initial preprocessing step (using tools like Cell Ranger or STARsolo) maps the reads to a reference genome and generates a sparse count matrix. This matrix - where rows are genes and columns are cells - is the starting point for computational biologists.
In this tutorial, we will walk through a standard end-to-end analysis on a 10x Genomics dataset. We provide parallel workflows in both Python (Scanpy) and R (Seurat) so you can choose the ecosystem that best fits your needs.
1. Loading the Data
We begin by loading the sparse matrix into our core data structures: AnnData in Python, or a Seurat object in R.
import scanpy as sc
import pandas as pd
import numpy as np
sc.settings.verbosity = 3
sc.logging.print_header()
# Load 10x data
adata = sc.read_10x_mtx(
'data/filtered_feature_bc_matrix/',
var_names='gene_symbols',
cache=True
)
adata.var_names_make_unique()
library(Seurat)
library(dplyr)
library(patchwork)
# Load 10x data
data_dir <- "data/filtered_feature_bc_matrix/"
pbmc.data <- Read10X(data.dir = data_dir)
# Initialize the Seurat object
pbmc <- CreateSeuratObject(counts = pbmc.data, project = "pbmc3k", min.cells = 3, min.features = 200)
2. Quality Control (QC)
Empty droplets or dying cells can confound downstream analysis. High mitochondrial expression or unusually low gene counts can flag low-quality cells, but the distribution must be inspected for each dataset and batch. The thresholds below are training starting points, not universal biological rules; document and justify any filtering decision.
# Identify mitochondrial genes
adata.var['mt'] = adata.var_names.str.startswith('MT-')
# Calculate QC metrics
sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True)
# Filter cells
adata = adata[adata.obs.n_genes_by_counts < 2500, :]
adata = adata[adata.obs.pct_counts_mt < 5, :]
print(f"Cells remaining after QC: {adata.n_obs}")
# Calculate mitochondrial percentage
pbmc[["percent.mt"]] <- PercentageFeatureSet(pbmc, pattern = "^MT-")
# Filter cells
pbmc <- subset(pbmc, subset = nFeature_RNA > 200 & nFeature_RNA < 2500 & percent.mt < 5)
print(paste("Cells remaining after QC:", ncol(pbmc)))
3. Normalization, Scaling, and PCA
Because different droplets capture different total amounts of RNA, we must normalize the data to a common scale (usually 10,000 counts per cell), log-transform it, and extract the most highly variable genes for dimensionality reduction.
# Normalize and log transform
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
# Find highly variable genes
sc.pp.highly_variable_genes(adata, min_mean=0.0125, max_mean=3, min_disp=0.5)
# Save the raw data before scaling
adata.raw = adata
# Scale data and run PCA
sc.pp.scale(adata, max_value=10)
sc.tl.pca(adata, svd_solver='arpack')
sc.pl.pca_variance_ratio(adata, log=True)
# Normalize
pbmc <- NormalizeData(pbmc, normalization.method = "LogNormalize", scale.factor = 10000)
# Find highly variable genes
pbmc <- FindVariableFeatures(pbmc, selection.method = "vst", nfeatures = 2000)
# Scale data
all.genes <- rownames(pbmc)
pbmc <- ScaleData(pbmc, features = all.genes)
# Run PCA
pbmc <- RunPCA(pbmc, features = VariableFeatures(object = pbmc))
ElbowPlot(pbmc)
4. Neighborhood Graph and Clustering
We compute the k-nearest neighbors graph in PCA space, embed it into 2D space using UMAP, and cluster the cells to identify transcriptionally distinct subpopulations.
# Compute neighbors and UMAP
sc.pp.neighbors(adata, n_neighbors=10, n_pcs=40)
sc.tl.umap(adata)
# Cluster using the Leiden algorithm
sc.tl.leiden(adata, resolution=0.5)
# Visualize
sc.pl.umap(adata, color=['leiden'])
# Compute neighbors
pbmc <- FindNeighbors(pbmc, dims = 1:10)
# Cluster using Louvain/Leiden
pbmc <- FindClusters(pbmc, resolution = 0.5)
# Run UMAP
pbmc <- RunUMAP(pbmc, dims = 1:10)
# Visualize
DimPlot(pbmc, reduction = "umap", label = TRUE)
Conclusion
Both Scanpy and Seurat support robust standard scRNA-seq workflows, but they differ in data structures, defaults, ecosystem choices, and analysis decisions. Choose one deliberately and document versions and parameters. With these clusters defined, we are ready to proceed to downstream analysis such as Trajectory Inference and Cell-Cell Communication.
Knowledge Check & Assessment
1. Concept Verification
Why must QC thresholds be inspected per dataset or batch rather than copied unchanged from a tutorial?
2. Practical Execution
Run the workflow on a small 10x dataset, create QC plots before filtering, document chosen thresholds, and save a clustered UMAP. Pass Criteria: Record the command or analysis choice, keep the output, and explain why it answers the stated task.
3. Troubleshooting
If a cluster is driven by mitochondrial content, batch, or doublets, what QC, sample-stratification, and doublet-detection checks should precede interpretation?
Reviewed: July 2026
All commands and outputs were verified with the software versions listed in this tutorial. If you encounter reproducibility issues, please report them through the Contact page.
Author: Nasir Mahmood Abbasi, PhD · Category: Single-Cell RNA-seq