Preprocessing and clustering 3k PBMCs .md .md

This guide demonstrates how to use lamindb to track data lineage and manage artifacts during a standard single-cell RNA-seq analysis workflow. Adapted from the Scanpy PBMC3k tutorial, it has additional code showing how to track the notebook’s execution and how to save the raw and the the final annotated dataset in your database.

We will process a dataset of 3k PBMCs from a Healthy Donor (freely available from 10x Genomics).

Note: To run examples, if you don’t have a LaminDB instance, create one:

lamin init --storage ./test-pbmc3k
Hide code cell output
 initialized lamindb: testuser1/test-pbmc3k

Import modules:

import matplotlib.pyplot as plt
import pandas as pd
import scanpy as sc
import lamindb as ln
Hide code cell output
 connected lamindb: testuser1/test-pbmc3k

Track the notebook’s execution:

ln.track()
Hide code cell output
 created Transform('g8bJOrvqcZWN0000', key='pbmc3k.ipynb'), started new Run('Plootw67O0BRcIiQ') at 2026-08-11 09:33:01 UTC
 notebook imports: lamindb-core==2.9.0 matplotlib==3.11.1 pandas==2.3.3 scanpy==1.12.3
 tip: to identify the notebook across renames, pass the uid: ln.track("g8bJOrvqcZWN")

Download and unpack the data:

from pathlib import Path
import tarfile
from urllib.request import Request, urlopen

data_dir = Path("data")
data_dir.mkdir(exist_ok=True)
archive_path = data_dir / "pbmc3k_filtered_gene_bc_matrices.tar.gz"
if not archive_path.exists():
    urls = [
        "https://cf.10xgenomics.com/samples/cell-exp/1.1.0/pbmc3k/pbmc3k_filtered_gene_bc_matrices.tar.gz",
        "https://cf.10xgenomics.com/samples/cell/pbmc3k/pbmc3k_filtered_gene_bc_matrices.tar.gz",
    ]
    for i, url in enumerate(urls):
        try:
            req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
            with urlopen(req) as response, archive_path.open("wb") as f:
                f.write(response.read())
            break
        except Exception:
            if i == len(urls) - 1:
                raise
with tarfile.open(archive_path, "r:gz") as tar:
    tar.extractall(data_dir)
Hide code cell output
/tmp/ipykernel_3097/2369541917.py:23: DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives and reject files or modify their metadata. Use the filter argument to control this behavior.
  tar.extractall(data_dir)

Read in the count matrix into an AnnData object:

adata = sc.read_10x_mtx(
    "data/filtered_gene_bc_matrices/hg19/",  # the directory with the `.mtx` file
    var_names="gene_symbols",  # use gene symbols for the variable names (variables-axis index)
    cache=True,  # write a cache file for faster subsequent reading
)
adata
Hide code cell output
AnnData object with n_obs × n_vars = 2700 × 32738
    var: 'gene_ids'
    layers: None (.X)

Save the AnnData object with the raw data:

ln.Artifact.from_anndata(adata, key="raw_pbmc3k.h5ad").save()
Hide code cell output
Artifact(uid='Pq9yX4dwEJuMN4eT0000', key='raw_pbmc3k.h5ad', description=None, suffix='.h5ad', kind='dataset', otype='AnnData', size=21594848, hash='olsswItNtrSMTtUsy-zMiA', n_files=None, n_observations=2700, extra_data=None, branch_id=1, created_on_id=1, space_id=1, storage_id=1, run_id=1, schema_id=None, created_by_id=1, created_at=2026-08-11 09:33:02 UTC, is_locked=False, version_tag=None, is_latest=True)

Preprocessing

Load the raw dataset:

adata = ln.Artifact.get(key="raw_pbmc3k.h5ad").load()

Show those genes that yield the highest fraction of counts in each single cell, across all cells:

sc.pl.highest_expr_genes(adata, n_top=20)
Hide code cell output
_images/b666704e7be80c650bc4c97ae5d3e41ac9f414b01271ada63061e83718897361.png

Basic filtering:

sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)
adata
Hide code cell output
AnnData object with n_obs × n_vars = 2700 × 13714
    obs: 'n_genes'
    var: 'gene_ids', 'n_cells'
    layers: None (.X)

Let’s assemble some information about mitochondrial genes, which are important for quality control:

# annotate the group of mitochondrial genes as "mt"
adata.var["mt"] = adata.var_names.str.startswith("MT-")
sc.pp.calculate_qc_metrics(adata, qc_vars=["mt"], percent_top=None, log1p=False, inplace=True)

A violin plot of some of the computed quality measures:

  • the number of genes expressed in the count matrix

  • the total counts per cell

  • the percentage of counts in mitochondrial genes

sc.pl.violin(
    adata,
    ["n_genes_by_counts", "total_counts", "pct_counts_mt"],
    jitter=0.4,
    multi_panel=True,
)
Hide code cell output
_images/fb0b16657a18f352a2f87b1d9617d8bc094034f4de660787801ba82704e0ca47.png

Remove cells that have too many mitochondrial genes expressed or too many total counts. Do the filtering by slicing the AnnData object:

adata = adata[
    (adata.obs.n_genes_by_counts < 2500) & (adata.obs.n_genes_by_counts > 200) & (adata.obs.pct_counts_mt < 5),
    :,
].copy()
adata.layers["counts"] = adata.X.copy()
adata
Hide code cell output
AnnData object with n_obs × n_vars = 2638 × 13714
    obs: 'n_genes', 'n_genes_by_counts', 'total_counts', 'total_counts_mt', 'pct_counts_mt'
    var: 'gene_ids', 'n_cells', 'mt', 'n_cells_by_counts', 'mean_counts', 'pct_dropout_by_counts', 'total_counts'
    layers: None (.X), 'counts'

Total-count normalize (library-size correct) the data matrix $\mathbf{X}$ to 10,000 reads per cell, so that counts become comparable among cells:

sc.pp.normalize_total(adata, target_sum=1e4)

Logarithmize the data:

sc.pp.log1p(adata)

Identify highly-variable genes:

sc.pp.highly_variable_genes(
    adata,
    layer="counts",
    n_top_genes=2000,
    min_mean=0.0125,
    max_mean=3,
    min_disp=0.5,
    flavor="seurat_v3",
)

Scale each gene to unit variance. Clip values exceeding standard deviation 10.

adata.layers["scaled"] = adata.X.toarray(order='C')
sc.pp.regress_out(adata, ["total_counts", "pct_counts_mt"], layer="scaled")
sc.pp.scale(adata, max_value=10, layer="scaled")
Hide code cell output
/opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages/scanpy/preprocessing/_simple.py:660: NumbaPerformanceWarning: '@' is faster on contiguous arrays, called on (Array(float64, 1, 'A', False, aligned=True), Array(float64, 2, 'C', False, aligned=True))
  data[i] -= regressor[i] @ coeff
/opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages/scanpy/preprocessing/_simple.py:660: NumbaPerformanceWarning: '@' is faster on contiguous arrays, called on (Array(float64, 1, 'A', False, aligned=True), Array(float64, 2, 'C', False, aligned=True))
  data[i] -= regressor[i] @ coeff

Principal component analysis

Reduce the dimensionality of the data by running principal component analysis (PCA), which reveals the main axes of variation and denoises the data.

sc.pp.pca(adata, layer="scaled", svd_solver="arpack")

Let us inspect the contribution of single PCs to the total variance in the data. This gives us information about how many PCs we should consider in order to compute the neighborhood relations of cells, e.g. used in the clustering function sc.tl.louvain() or tSNE sc.tl.tsne().

sc.pl.pca_variance_ratio(adata, n_pcs=20)
Hide code cell output
_images/1fdf5787130cacdb1f28fbe45528b3bd6b77f2258fe5c200c71076bba4dcadf3.png

Computing the neighborhood graph

Compute the neighborhood graph of cells using the PCA representation of the data matrix.

sc.pp.neighbors(adata, n_neighbors=10, n_pcs=40)

Clustering the neighborhood graph

sc.tl.leiden(
    adata,
    resolution=0.7,
    random_state=0,
    flavor="igraph",
    n_iterations=2,
    directed=False,
)
adata.obs["leiden"] = adata.obs["leiden"].copy()
adata.uns["leiden"] = adata.uns["leiden"].copy()

Embedding the neighborhood graph in a UMAP

sc.tl.paga(adata)
sc.pl.paga(adata, plot=False)  # remove `plot=False` if you want to see the coarse-grained graph
sc.tl.umap(adata, init_pos='paga')
sc.tl.umap(adata)

adata.obsm["X_umap"] = adata.obsm["X_umap"].copy()
sc.pl.umap(adata, color=["leiden", "CD14", "NKG7"])
Hide code cell output
_images/a0c10f7a88d11c2c57e5e2f24718cc8b962fea5731a6559fc7d8c3163cba1112.png

Finding marker genes

Let us compute a ranking for the highly differential genes in each cluster.

sc.tl.rank_genes_groups(adata, "leiden", mask_var="highly_variable", method="wilcoxon")
sc.pl.rank_genes_groups(adata, n_genes=25, sharey=False)
Hide code cell output
_images/8a031f9c0802fb402c20bd61f854ac9dff108ee54835d2efabc51b66f49e38d2.png

Most of these genes are found in the highly expressed genes with notable exceptions like CD8A (see the below dotplot).

Leiden Group

Markers

Cell Type

0

IL7R

CD4 T cells

1

CD14, LYZ

CD14+ Monocytes

2

MS4A1

B cells

3

CD8A

CD8 T cells

4

GNLY, NKG7

NK cells

5

FCGR3A, MS4A7

FCGR3A+ Monocytes

6

FCER1A, CST3

Dendritic Cells

7

PPBP

Megakaryocytes

Let us also define a list of marker genes for later reference.

marker_genes = [
    *["IL7R", "CD79A", "MS4A1", "CD8A", "CD8B", "LYZ", "CD14"],
    *["LGALS3", "S100A8", "GNLY", "NKG7", "KLRB1"],
    *["FCGR3A", "MS4A7", "FCER1A", "CST3", "PPBP"],
]

Actually mark the cell types.

new_cluster_names = [
    "CD4 T",
    "B",
    "CD14+ Monocytes",
    "NK",
    "CD8 T",
    "FCGR3A+ Monocytes",
    "Dendritic",
    "Megakaryocytes",
]
adata.rename_categories("leiden", new_cluster_names)
adata.obs['cell_type']=adata.obs['leiden']
adata
Hide code cell output
AnnData object with n_obs × n_vars = 2638 × 13714
    obs: 'n_genes', 'n_genes_by_counts', 'total_counts', 'total_counts_mt', 'pct_counts_mt', 'leiden', 'cell_type'
    var: 'gene_ids', 'n_cells', 'mt', 'n_cells_by_counts', 'mean_counts', 'pct_dropout_by_counts', 'total_counts', 'highly_variable', 'highly_variable_rank', 'means', 'variances', 'variances_norm', 'mean', 'std'
    uns: 'log1p', 'hvg', 'pca', 'neighbors', 'leiden', 'paga', 'leiden_sizes', 'umap', 'leiden_colors', 'rank_genes_groups'
    obsm: 'X_pca', 'X_umap'
    varm: 'PCs'
    obsp: 'distances', 'connectivities'
    layers: None (.X), 'counts', 'scaled'

Visualize the cell types in a UMAP

sc.pl.umap(adata, color="cell_type", legend_loc="on data", title="", frameon=False)
Hide code cell output
_images/00b77664d97b65b5a510e4ddfc13fb2bedffeb5dda6951c59b5c55d584ed03f8.png

Now that we annotated the cell types, let us visualize the marker genes.

sc.pl.dotplot(adata, marker_genes, groupby="cell_type")
Hide code cell output
_images/e63abcf0ba3b61101574ecb4044acf444924ef475b6957a18f856ff459e3498a.png

Save the resulting AnnData object in LaminDB:

ln.Artifact.from_anndata(adata, key="result_pbmc3k.h5ad").save()
Hide code cell output
Artifact(uid='sFLWiytgrMobQMFj0000', key='result_pbmc3k.h5ad', description=None, suffix='.h5ad', kind='dataset', otype='AnnData', size=190921732, hash='zF7d2Z6wGilsrzby-WCF_R', n_files=None, n_observations=2638, extra_data=None, branch_id=1, created_on_id=1, space_id=1, storage_id=1, run_id=1, schema_id=None, created_by_id=1, created_at=2026-08-11 09:33:48 UTC, is_locked=False, version_tag=None, is_latest=True)

ln.finish() marks the successful completion of the current script or notebook run in LaminDB, finalizing its execution state and saving the source code to the database for reproducibility.

ln.finish()
Hide code cell output
 finished Run('Plootw67O0BRcIiQ') after 48s at 2026-08-11 09:33:50 UTC