Tutorial
This tutorial walks through the recount3 Python API end-to-end: finding
projects and samples, downloading the files behind them, assembling
SummarizedExperiment and
RangedSummarizedExperiment objects, merging
sample metadata, normalizing and scaling counts, reading BigWig coverage, and
managing the on-disk cache.
For the recount3 command-line tool, see CLI Reference. For full per-symbol
documentation, see API Reference.
Installation
The core package supports Python 3.10 through 3.15 and depends only on NumPy, pandas, and SciPy. The optional extras enable features used throughout this tutorial:
python3 -m pip install recount3 # core only
python3 -m pip install "recount3[biocpy]" # + SummarizedExperiment
python3 -m pip install "recount3[bigwig]" # + pyBigWig
python3 -m pip install "recount3[parquet]" # + .parquet output
python3 -m pip install "recount3[anndata]" # + .h5ad output
python3 -m pip install "recount3[pybiocfilecache]" # R cache sharing
python3 -m pip install "recount3[all]" # every optional feature
What each extra enables:
biocpyis required forcreate_rse(),to_summarized_experiment(),to_ranged_summarized_experiment(), and every helper inrecount3.sethat returns or operates on a BiocPy object.bigwigis required only when you callload()on a BigWig resource or use theBigWigFilereader directly.parquetinstallspyarrowand is required to write.parquet, both frompandas.DataFrame.to_parquet()on a stacked matrix and fromrecount3 bundle stack-counts --out=counts.parquet. pandas accepts eitherpyarroworfastparquet; an existingfastparquetinstallation is used as-is, and theio.parquet.engineoption is honoured.anndatainstallsanndataanddelayedarray(and impliesbiocpy).SummarizedExperiment.to_anndata()imports both, andsummarizedexperimentdeclares neither as a required dependency, so the extra is needed to write.h5adfromrecount3 bundle se/recount3 bundle rse. It cannot be installed on Python 3.15 yet, becauseanndatadepends onh5py, which publishes no Python 3.15 wheels.pybiocfilecacheenables a shared R/Python registry. The faster default filesystem cache needs no extra; see Cache and configuration.allinstalls every optional feature above. Thedevanddocsextras hold the test and documentation toolchains and are installed separately, soalldoes not pull them in.
If an optional dependency is missing, the affected function raises
ImportError on first use; the remainder of the package stays importable
and functional. The two output extras are additionally checked up front by the
CLI, before any download runs, so an unusable output format is reported
immediately rather than after the data has been fetched and assembled.
Quick start
Note
Run each section’s blocks in order, since later blocks reuse names bound by
earlier ones. Examples that download data require network access unless
their files are already cached. Annotation lookup, filtering, and analysis
of loaded objects run offline. Downloaded files are cached under
~/.cache/recount3/files (see Cache and configuration), so
re-running an example reuses the local copy rather than downloading again.
The most direct path from a project identifier to an analysis-ready BiocPy
object is create_rse(). It requires the biocpy extra and
performs discovery, downloads, metadata merging, and range assembly in a single
call:
import recount3 as r3
rse = r3.create_rse(
project="SRP009615",
organism="human",
annotation_label="gencode_v26",
)
print("Features x samples:", rse.shape)
print("Gene assay:", type(rse.get_assay("raw_counts")).__name__)
print("First samples:", list(rse.get_column_names()[:3]))
Output:
Features x samples: (63856, 12)
Gene assay: ndarray
First samples: ['SRR389077', 'SRR387777', 'SRR387778']
63,856 gene features by 12 samples. The counts are a plain
numpy.ndarray; the BiocPy container supplies the feature and sample
labels around it. Exact counts and identifiers depend on the study and on what
the mirror currently serves.
Examples here import the package under the short alias r3, so each call
shows where it comes from. from recount3 import create_rse works the same
way if you prefer it.
This single call is sufficient for the most common workflow; it is expanded in
Layer 1: Building experiments with create_rse below. The remainder of this tutorial describes the steps that
create_rse performs internally and the lower-level components to use when
finer control is required.
Finding projects and samples before choosing a study
The quick start above assumes you already know which study you want. If you
do not, start from the source-level metadata: these calls read and parse the
mirror’s own project and sample tables, so what they return is a record of
what exists rather than a guess at a filename. Limit data_sources to the
source you need. The human SRA table alone covers several thousand
projects:
import recount3 as r3
projects = r3.available_projects(organism="human", data_sources="sra")
print(projects[["project", "n_samples"]].head())
samples = r3.available_samples(organism="human", data_sources="sra")
study_samples = samples.loc[samples["project"].eq("SRP009615")]
print(study_samples[["project", "external_id"]].head())
Both return a pandas.DataFrame. The human SRA project table has 8,677
rows; n_samples there matches the column count you will get from
create_rse:
project n_samples file_source project_home
831 SRP009615 12 sra data_sources/sra
These tables support selecting projects before downloading counts.
r3.samples_for_project returns one project’s sample identifiers as a list;
r3.project_homes returns a table of project locations; and
r3.create_sample_project_lists(organism="human") returns a
(samples, projects) pair of sorted identifier lists for a whole organism,
which is what the CLI’s recount3 ids writes out.
r3.annotation_label("human", "G026") converts an extension back to its
human-readable label.
Keep the distinction in mind while reading the rest of this tutorial: the discovery calls on this page read metadata, whereas the resource and bundle layers below mostly construct candidate URLs from the parameters you give them. A constructed URL is not evidence that the file exists.
The three layers of the API
recount3 exposes the same workflow at three levels of abstraction:
Layer |
Primary entry point |
Recommended when |
|---|---|---|
High-level: BiocPy builders |
|
You want one project as a |
Mid-level: bundles |
|
You combine multiple projects, filter resources, or stack matrices yourself. |
Low-level: resources |
|
You want fine-grained control over a single file’s URL, download, or parser. |
Each layer is a thin wrapper around the next. create_rse calls
R3ResourceBundle.discover internally; R3ResourceBundle aggregates
R3Resource objects. Because the layers share a common set of types, they
interoperate freely: a bundle obtained from discover can be filtered at
Layer 2 and then handed to the same builders that create_rse invokes.
Layer 1: Building experiments with create_rse
create_rse() is the recommended entry point for the most
common workflow: one project, one organism, one annotation, one assembled
RangedSummarizedExperiment. Requires the
biocpy extra.
Use this layer when you have a study accession and want its expression
data ready to analyze. You read a paper that used SRP009615 and want to
re-run the differential expression yourself; you need gene-level counts for
one GTEx tissue to test a hypothesis; you are checking whether a gene of
interest is expressed in a public dataset before designing an experiment. In
each case one accession goes in and one object comes out, with counts,
sample metadata, and genomic coordinates already aligned to each other.
Use a different layer when one project is not the unit of work: reach for Layer 2 if you need several studies in one matrix or want to inspect the files before committing to a download, and Layer 3 if you want one specific file and nothing else.
Gene-level RSE (default)
import recount3 as r3
rse = r3.create_rse(
project="SRP009615",
organism="human",
annotation_label="gencode_v26", # or "gencode_v29", "fantom6_cat", "refseq", "ercc", "sirv"
)
You may pass the raw extension code instead of a label:
rse = r3.create_rse(
project="SRP009615",
organism="human",
annotation_extension="G026",
)
When both are supplied, annotation_extension takes precedence. Discover the
available labels with annotation_options():
import recount3 as r3
r3.annotation_options("human")
r3.annotation_options("mouse")
Output:
{'gencode_v26': 'G026', 'gencode_v29': 'G029', 'fantom6_cat': 'F006',
'refseq': 'R109', 'ercc': 'ERCC', 'sirv': 'SIRV'}
{'gencode_v23': 'M023'}
Exon-level and junction-level
exon_rse = r3.create_rse(
project="SRP009615",
organism="human",
genomic_unit="exon",
annotation_label="gencode_v26",
)
junction_rse = r3.create_rse(
project="SRP009615",
organism="human",
genomic_unit="junction",
)
For junctions, recount3 prefers the RR sidecar for genomic
coordinates; pass prefer_rr_junction_coordinates=False to disable
this.
Falling back to a plain SummarizedExperiment
Every gene or exon row range comes from an annotation GTF, and every
junction range from an RR sidecar. When that file cannot be turned into
ranges, create_rse raises RangesError (a
ValueError) naming which of three things went wrong:
the annotation could not be retrievedThe download failed. The HTTP layer has already retried it (
RECOUNT3_MAX_RETRIES, default 3), so this points at a mirror or network that is actually down rather than a momentary blip. Try again later, or pointRECOUNT3_URLat another mirror.the annotation could not be parsedThe file arrived but is not readable as a GTF, most often a truncated cache entry from an interrupted download. Drop it with
recount3_cache_rm()and let it download again.the annotation does not cover every counted featureThe annotation parses cleanly but describes a different feature set than the counts, for example a GENCODE 26 GTF against GENCODE 29 counts. Pass the matching
annotation_extensionorannotation_label;annotation_options()lists what is available.
You do not have to predict which of these will happen before you call. Ask for the RSE; you either get one, or you get a message naming the cause. Nothing is silently degraded in between.
allow_fallback_to_se=True changes only what happens in that failure
case: rather than raising, you get a plain
SummarizedExperiment and a logged warning
explaining why:
experiment = r3.create_rse(
project="SRP009615",
organism="human",
allow_fallback_to_se=True,
)
Be deliberate about that flag:
A fallback object has no genomic ranges. Counts, sample metadata, and the operations driven by column data still work, namely
recount3.se.compute_scale_factors(),recount3.se.expand_sra_attributes(), andrecount3.se.is_paired_end(). The helpers that need an RSE,compute_read_counts,transform_counts, andcompute_tpm, raiseTypeErroron a plain SE, as do range queries. The flag returns an RSE as usual whenever ranges are available.It is not a retry. The download is not attempted again, and the flag does nothing about whatever made retrieval fail.
It does not repair an annotation mismatch. Accepting an SE is how a wrong
annotation_extensiongoes unnoticed.
It earns its place in count-only work, and in batch jobs that should not abort on one bad project. When you do want ranges, fix the cause the message names instead of passing the flag.
Operations performed by create_rse
For one (organism, data_source, project) triple, it:
discovers gene/exon/junction counts, the matching annotation GTF, and the five project metadata tables;
downloads everything into the on-disk cache;
stacks the count matrix into a feature × sample DataFrame;
merges, namespaces, and aligns the metadata tables to the count columns (including a
BigWigURLcolumn constructed per sample);parses the GTF (or RR file, for junctions) to attach genomic ranges;
constructs the BiocPy object.
To deviate from any of those steps (multiple projects, custom metadata filtering, stacking only some matrices, or a different join policy), use Layer 2.
Layer 2: Resource bundles
R3ResourceBundle is a container of
R3Resource objects with helpers for filtering,
loading, stacking, and converting to BiocPy objects.
Use this layer when create_rse’s one-project, everything-at-once
shape does not fit. Typical cases:
Several studies, one matrix. You want a gene-level matrix spanning
SRP009615andSRP001558to look for an effect that holds across both, so the counts have to be stacked on a shared feature axis before any analysis starts.Look before you download. Junction and BigWig files are large. A bundle describes candidate files for a project, their URLs and annotation codes, as plain objects, so you can decide what is worth fetching, or write out a manifest for someone else to fetch.
Only part of what discovery returns. You need gene counts and the QC table, without the other resources in a default bundle.
create_rsealready limits counts to the requested genomic unit and annotation.Your own assembly. You want the stacked counts as a
pandas.DataFrameto merge with clinical data of your own, and will build the BiocPy object yourself (or skip it entirely).
A bundle is just a list of resources plus filters, so nothing is downloaded
by default during discovery without BigWigs. BigWig discovery reads a sample
index. Candidate URLs do not verify that files exist and do not report their
sizes. That is the practical difference from Layer 1:
create_rse decides what to fetch for you, a bundle lets you decide.
Discovering resources for one or more projects
import recount3 as r3
bundle = r3.R3ResourceBundle.discover(
organism="human",
data_source="sra",
project="SRP009615",
)
print(f"Found {len(bundle.resources)} resources.")
Output:
Found 10 resources.
By default, discover returns gene + exon counts, the default annotation
GTF for each of those units (gene and exon), the five metadata tables, and the
default junction artifact (MM). For SRP009615 this is the ten resources
counted above: 2 counts + 2 annotation GTFs + 5 metadata tables + 1 junction
file. Override with:
custom_bundle = r3.R3ResourceBundle.discover(
organism="human",
data_source="sra",
project="SRP009615",
genomic_units=("gene",),
annotations=("G026", "G029"), # or "default" / "all"
junction_exts=("MM", "RR"),
include_metadata=True,
include_bigwig=False,
)
Multi-project bundles
Pass an iterable for any of organism, data_source, or
project. discover uses all combinations of the supplied values and
produces a single combined bundle:
multi = r3.R3ResourceBundle.discover(
organism="human",
data_source="sra",
project=["SRP009615", "SRP001558"],
genomic_units=("gene",),
)
print(f"Combined: {len(multi.resources)} resources, 2 projects.")
Output:
Combined: 15 resources, 2 projects.
The count is 15, not 14: each project contributes 7 project-specific resources
(1 gene count + 1 junction file + 5 metadata tables), and the gene annotation
GTF is shared across both projects, so it is deduplicated to a single resource
(7 × 2 + 1 = 15). Note that the junction artifact is included by default
regardless of genomic_units; pass junction_exts=() to omit it.
When a bundle spans more than one (organism, data_source, project)
triple, its organism/data_source/project attributes are left
as None to avoid misrepresenting its identity; per-resource fields
remain authoritative.
Supported values:
organism:"human","mouse"data_source:"sra","gtex","tcga"
Filtering bundles
Bundles are returned by-value from filter();
the original is not mutated. Each keyword maps to a field on the
underlying R3ResourceDescription, and
accepts any FieldSpec:
a single string: exact match
an iterable of strings: membership test
a callable
(value) -> bool: predicate
gene_counts = bundle.filter(
resource_type="count_files_gene_or_exon",
genomic_unit="gene",
)
gene_or_exon = bundle.filter(genomic_unit=["gene", "exon"])
gencode_only = bundle.filter(
annotation_extension=lambda ext: bool(ext) and ext.startswith("G"),
)
no_metadata = bundle.filter(resource_type="metadata_files", invert=True)
Convenience aliases provide shortcuts for the most common filters:
only_counts(),
only_metadata(),
bigwigs(),
exclude_metadata().
Note
Filtering on a field that a resource does not have (for example,
filtering on genomic_unit when metadata files have no genomic unit)
excludes those resources from the result. Combine filters explicitly
when this matters: bundle.filter(resource_type=..., genomic_unit=...).
Stacking count matrices
stack_count_matrices() concatenates count
DataFrames. It does not take a genomic_unit argument, so filter the
bundle first to choose which family you want:
gene_counts_df = (
bundle
.filter(resource_type="count_files_gene_or_exon", genomic_unit="gene")
.filter(annotation_extension="G026")
.stack_count_matrices(compat="feature")
)
print(type(gene_counts_df).__name__, gene_counts_df.shape)
Output:
DataFrame (63856, 12)
A plain pandas.DataFrame, feature IDs on the index and sample IDs on
the columns, identical to what create_rse puts in its raw_counts
assay. Junctions stack the same way, and stay sparse-backed:
junction_counts_df = (
bundle
.filter(resource_type="count_files_junctions", junction_extension="MM")
.stack_count_matrices()
)
Compatibility checking is controlled by compat:
compat="family"(default): gene/exon may mix with gene/exon; junctions stay with junctions.compat="feature": stricter; the feature space must match exactly: the same genomic unit (gene versus exon) for gene/exon counts, or the same junction subtype for junctions. (The annotation build is not constrained.)
Mixing incompatible resources raises
CompatibilityError.
compat="feature" does not verify an annotation build or align metadata.
Select one annotation explicitly before stacking. Use
axis=1, join_policy="inner" to combine samples on shared feature IDs, and
check that the resulting sample names are unique. For experiment construction,
use the builders below; they also validate annotations and sample metadata.
For multi-project junctions, include MM, ID, and RR files and use the builders
to align junctions by genomic coordinates rather than project-local row numbers.
Building SummarizedExperiment / RangedSummarizedExperiment from a bundle
The bundle methods below are what create_rse calls internally:
se = bundle.to_summarized_experiment(genomic_unit="gene")
rse = bundle.to_ranged_summarized_experiment(
genomic_unit="gene",
annotation_extension="G026",
allow_fallback_to_se=False,
)
The same functions are available as standalone wrappers in
recount3.se (build_summarized_experiment(),
build_ranged_summarized_experiment()) for symmetry with
create_rse.
Downloading a bundle’s files in parallel
download() materializes every resource in a
bundle to a local destination. Because retrieval is I/O-bound, resources are
fetched concurrently by a pool of worker threads sized by max_workers
(default 8). This is the same mechanism used by the recount3 download
command-line tool:
bundle.download(dest="./downloads", max_workers=8)
dest may be a directory (each resource written as a separate file) or a
path ending in .zip (resources written into a single archive). The
cache keyword (named cache_mode on
download()) accepts the same values: "enable",
"update", "disable".
Layer 3: Individual resources
R3Resource is the lowest level: one file, one URL, one
cache entry, one parser.
Use this layer when the unit of work is a single file. You want the GENCODE 26 gene annotation itself, not an experiment built from it; you are mirroring a handful of known URLs into a shared directory for a cluster job; you want the raw junction MatrixMarket file to feed a tool of your own; or you are debugging and want to see exactly which URL a description resolves to before anything is fetched.
A resource is built from a description. Descriptions are typed
dataclasses with field validation; the recommended constructor is the
R3ResourceDescription factory, which routes to the
appropriate subclass based on resource_type:
import recount3 as r3
desc = r3.R3ResourceDescription(
resource_type="count_files_gene_or_exon",
organism="human",
data_source="sra",
genomic_unit="gene",
project="SRP009615",
annotation_extension="G026", # required for gene/exon counts
)
res = r3.R3Resource(desc)
print(res.url) # fully-qualified URL on the recount3 mirror
# http://duffel.rail.bio/recount3/human/data_sources/sra/gene_sums/15/SRP009615/sra.gene_sums.SRP009615.G026.gz
res.download(path=None, cache_mode="enable") # cache only, no local copy
df = res.load() # pandas.DataFrame
local_path = res.ensure_cached() # path for another file reader
print(df.shape)
The full description catalog:
Resource type |
Description class |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Downloading
download() has three forms, controlled by
path:
res.download(path=None) # cache only
res.download(path="./downloads") # copy into a directory
res.download(path="./recount3.zip") # append to a ZIP archive
path accepts a string or a pathlib.Path
(StrPath), so a directory built with / works as
well, as does a Path handed back by
ensure_cached() or
recount3_cache():
from pathlib import Path
out = Path("results-output")
res.download(path=out / "downloads")
The same applies to dest on
download().
cache_mode controls cache interaction:
"enable"(default): use cached copy if present; download if not."update": force a fresh download, then overwrite the cache."disable": bypass the cache entirely (only valid whenpathis a directory or.zip).
Loading
load() parses the cached file. The return type
depends on the resource:
Resource type |
|
|---|---|
Gene/exon counts |
|
Junction MM (with ID sidecar) |
|
Junction ID / RR |
|
Metadata tables / source listings |
|
BigWig |
The parsed object is cached on the resource; subsequent load() calls
return the same instance until you call
clear_loaded() (or pass force=True).
Searching without a bundle
If you want a flat list of resources rather than a bundle, the
recount3.search helpers return list[R3Resource] directly. Each
takes StringOrIterable for every parameter and
returns one resource per Cartesian-product combination:
import recount3 as r3
counts = r3.search_count_files_gene_or_exon(
organism="human",
data_source="sra",
genomic_unit="gene",
project="SRP009615",
annotation_extension="G026",
)
meta = r3.search_metadata_files(
organism="human",
data_source="sra",
project="SRP009615",
table_name=(
"recount_project", "recount_qc", "recount_seq_qc",
"recount_pred", "sra",
),
)
bigwigs = r3.search_bigwig_files(
organism="human",
data_source="sra",
project="SRP009615",
sample=["SRR387777", "SRR387778"],
)
The single-call equivalent is search_project_all()
(used internally by R3ResourceBundle.discover). The remaining helpers
follow the same shape:
Function |
Returns |
|---|---|
|
Gene or exon count files |
|
Junction |
|
Per-project metadata tables |
|
Per-sample BigWig coverage files |
|
Annotation GTFs for an organism |
|
The |
|
Source-level (not project) metadata |
|
Everything above for one project |
To go the other way, from a manifest line back to a resource, use
from_mapping(), which rehydrates one JSONL record
written by recount3 search:
import json
with open("manifest.jsonl", encoding="utf-8") as handle:
resources = [r3.R3Resource.from_mapping(json.loads(line))
for line in handle if line.strip()]
The url and arcname keys in the record are recomputed from the
description and the active configuration, so a manifest written against one
mirror can be replayed against another.
Working with sample metadata
When create_rse or
to_ranged_summarized_experiment()
assembles an RSE, it merges all available per-project metadata tables
into column_data, namespacing non-key columns by their table of
origin (e.g. recount_qc__star.all_mapped_reads).
Construction uses metadata_join="inner" by default, matching R’s
intersection of the nonempty metadata tables within each project. Samples
absent from that intersection are excluded from both the assay and
column_data. Use metadata_join="outer" on create_rse or either
bundle builder to retain every count sample with missing metadata values.
This option is independent of join_policy, which joins feature rows
across projects. With no metadata resources, all count samples are retained.
Requested files that fail to load, conflicting sample identifiers, and mixed
annotations raise errors. Select annotation_extension explicitly when a
bundle contains several annotations. For junctions from multiple projects,
each MM matrix needs its matching ID and RR sidecars: junction rows are
matched by chromosome, inclusive coordinates, and strand. An outer feature
join introduces zeros only for features absent from a project; missing values
inside an input count file are errors.
Gene and exon assays preserve their numeric types. Junction assays remain
SciPy CSC sparse matrices, and count-transform helpers return sparse-backed
DataFrames for sparse inputs. Standard BiocPy assay access, slicing, copying,
and range operations work directly on the constructed objects. Converting a
large junction assay with toarray() explicitly allocates its dense form.
Repeated exon IDs retain their individual transcript annotations in
row_data and row_ranges. Python gives repeated rows unique names while
preserving the original IDs in row_data["feature_id"]. Compatible projects
with identical repeated feature ordering can be combined; ambiguous repeated
feature alignments raise an error. The GTF score is preserved as bp_length
(covered exonic length), which can differ from genomic span. TPM uses this
annotated length when present.
Experiment metadata records the project, organism, annotation, source URLs,
construction options, creation time, package version, and the mapping of
metadata columns to their source tables. Access individual fields through
rse.metadata["project"]. A bundle retains at most one aligned annotation
cache for repeated RSE construction; changing its annotation file invalidates
the cache. With autoload=False, counts and sample metadata must already be
loaded, while genomic range files must be cached locally.
Access it as a pandas DataFrame:
col_df = rse.get_column_data().to_pandas()
col_df.columns[:10]
Expanding SRA sample attributes
In an assembled RSE, SRA samples carry an sra__sample_attributes column
(the sra metadata table namespaced with __ as described above) that
encodes key-value pairs in the form "age;;67.78|disease;;Control|...".
recount3.se.expand_sra_attributes() parses these into separate columns.
It accepts either a DataFrame or an SE/RSE object, and recognizes both the
namespaced sra__sample_attributes name and the R-style
sra.sample_attributes spelling. Each parsed attribute becomes a new column
named sra_attribute.<key> (for example, sra_attribute.disease):
import recount3 as r3
rse2 = r3.se.expand_sra_attributes(rse)
col_df = rse2.get_column_data().to_pandas()
sra_cols = [c for c in col_df.columns if c.startswith("sra_attribute.")]
print(sra_cols)
print(col_df[["sra_attribute.cells", "sra_attribute.cell_line"]].head(4))
Output:
['sra_attribute.cell_line', 'sra_attribute.shRNA_expression',
'sra_attribute.source_name', 'sra_attribute.treatment',
'sra_attribute.cells']
sra_attribute.cells sra_attribute.cell_line
SRR389077 NaN K562
SRR387777 K562 NaN
SRR387778 K562 NaN
SRR389078 NaN K562
Worth dwelling on: the submitters of this study recorded the same fact under
two different attribute names, so neither column alone describes every sample.
expand_sra_attributes reports what the submitters wrote and does not
reconcile it for you. Inspect the expanded columns before defining analysis
groups; here you would combine them, for example with
col_df["sra_attribute.cell_line"].combine_first(col_df["sra_attribute.cells"]).
Normalization and scaling
Gene and exon matrices contain base-pair coverage sums (the raw_counts
assay). Junction matrices contain junction-supporting counts (the counts
assay); do not apply the gene coverage-to-read or TPM formulas to junctions.
recount3.se provides helpers consistent with the R implementation.
These reach the se submodule rather than the package root, so they are
called as r3.se.compute_tpm(rse), whereas the builders and discovery
helpers used so far are available directly as r3.create_rse(...).
compute_read_counts, transform_counts, and compute_tpm require an
RSE. compute_scale_factors and is_paired_end also accept sample
metadata DataFrames and plain SE objects.
Approximate read counts
import recount3 as r3
reads = r3.se.compute_read_counts(rse) # pandas DataFrame, integer-rounded
Values are rounded to whole reads by default; pass round_to_integers=False
to retain the fractional estimates.
Per-sample scale factors
Two methods are supported, matching the R recount3 reference:
import recount3 as r3
sf_auc = r3.se.compute_scale_factors(rse, by="auc")
sf_mapreads = r3.se.compute_scale_factors(rse, by="mapped_reads")
print(pd.DataFrame({"auc": sf_auc, "mapped_reads": sf_mapreads}).head(3))
Both return a pandas.Series indexed by external_id:
auc mapped_reads
external_id
SRR389077 0.045855 0.129044
SRR387777 0.039961 0.112357
SRR387778 0.034571 0.097252
Apply scale factors to the assay:
scaled = r3.se.transform_counts(rse, by="auc") # default
scaled = r3.se.transform_counts(rse, by="mapped_reads", target_read_count=4e7)
TPM (gene/exon only, needs feature widths)
import recount3 as r3
tpm = r3.se.compute_tpm(rse)
print(type(tpm).__name__, tpm.shape)
print(tpm.sum(axis=0).round().unique())
Output:
DataFrame (63856, 12)
[1000000.]
Every sample sums to one million across the full feature set, which is the quickest check that the normalization ran over the whole matrix rather than a subset. The five highest-expressed genes in the first three samples:
gene_name SRR389077 SRR387777 SRR387778
ENSG00000210082.2 MT-RNR2 36013.14 16179.52 14534.20
ENSG00000281383.1 CH507-513H4.5 67606.31 7793.08 10009.38
ENSG00000213934.6 HBG1 7827.91 12294.58 10770.67
ENSG00000198712.1 MT-CO2 6718.71 9145.23 6520.00
ENSG00000228253.1 MT-ATP8 4585.29 6566.27 8629.65
TPM uses annotated bp_length (covered exonic bases) when present, falling
back to range widths. A gene’s genomic span can include introns and is not an
interchangeable length. Normalize the full gene matrix before selecting genes
for display; each nonzero sample should sum to approximately one million.
Check for missing or non-finite results before downstream analysis. The helpers
return new DataFrames and do not replace the RSE assay; rounded estimates have
integer-like values but may retain a floating dtype.
recount3.se.is_paired_end(), compute_scale_factors, and
expand_sra_attributes accept a metadata DataFrame or an SE/RSE object.
See the API reference for full signatures.
From package objects to downstream analysis
The gene assay is a NumPy array, metadata and normalized counts are pandas objects, and the junction assay is a SciPy sparse matrix. Inspect the real objects and preserve their labels when moving between representations:
import numpy as np
import pandas as pd
from scipy import sparse
raw = rse.get_assay("raw_counts")
print(type(raw), raw.shape)
print(type(tpm), tpm.shape)
assert np.isfinite(tpm.to_numpy()).all()
nonzero = tpm.sum(axis=0) > 0
np.testing.assert_allclose(tpm.sum(axis=0)[nonzero], 1_000_000)
# Filter only after normalizing the complete gene matrix.
expressed = (tpm >= 1).sum(axis=1) >= 3
log_tpm = np.log2(tpm.loc[expressed] + 1)
variable_ids = log_tpm.var(axis=1).nlargest(2000).index
X = log_tpm.loc[variable_ids].T.to_numpy() # samples × genes
sample_metadata = rse.get_column_data().to_pandas().reindex(tpm.columns)
assert sample_metadata.index.tolist() == tpm.columns.tolist()
correlation = pd.DataFrame(
np.corrcoef(X), index=tpm.columns, columns=tpm.columns,
)
print("Analysis array (samples x genes):", X.shape, type(X).__name__)
print(correlation.iloc[:4, :4].round(3))
Output:
Analysis array (samples x genes): (12, 2000) ndarray
SRR389077 SRR387777 SRR387778 SRR389078
SRR389077 1.000 0.731 0.736 0.902
SRR387777 0.731 1.000 0.955 0.723
SRR387778 0.736 0.955 1.000 0.724
SRR389078 0.902 0.723 0.724 1.000
X is a samples-by-genes numpy.ndarray, the orientation
scikit-learn and most Python machine-learning tooling expect, and it can be
handed straight to an estimator.
The same array summarizes by principal component analysis (PCA). NumPy’s SVD is enough; no extra dependency is needed:
X_centered = X - X.mean(axis=0, keepdims=True)
U, singular_values, _ = np.linalg.svd(X_centered, full_matrices=False)
scores = U[:, :2] * singular_values[:2]
variance_fraction = singular_values**2 / np.sum(singular_values**2)
pc_df = pd.DataFrame(scores, index=tpm.columns, columns=["PC1", "PC2"])
print(pc_df.head(4).round(3))
print("PC1/PC2 explained variance (%):",
np.round(100 * variance_fraction[:2], 2).tolist())
Output:
PC1 PC2
SRR389077 -56.396 5.020
SRR387777 -6.816 -23.199
SRR387778 -4.931 -20.632
SRR389078 -67.903 7.778
PC1/PC2 explained variance (%): [42.19, 14.57]
This is an exploratory expression-profile comparison. The thresholds are explicit choices for SRP009615, not universal defaults; component signs can flip between numerical libraries without changing the result. For prediction, fit gene selection and centering within training folds. This example does not estimate treatment effects or remove batch effects.
For a junction RSE built earlier, summarize sparse counts without allocating the entire dense matrix:
junctions = junction_rse.get_assay("counts")
assert sparse.issparse(junctions)
totals = np.asarray(junctions.sum(axis=0)).ravel()
detected = np.asarray((junctions > 0).sum(axis=0)).ravel()
junction_summary = pd.DataFrame(
{"count_sum": totals, "detected_junctions": detected},
index=junction_rse.get_column_names(),
)
print(type(junctions).__module__ + "." + type(junctions).__name__)
print("Features x samples:", junctions.shape, "stored entries:", junctions.nnz)
print(junction_summary.head(3))
Output:
scipy.sparse._csc.csc_matrix
Features x samples: (281448, 12) stored entries: 1341130
count_sum detected_junctions
SRR389079 1732182 142791
SRR389080 1315344 117890
SRR389081 844075 106754
281,448 junctions by 12 samples, of which 1,341,130 cells are nonzero. The three SciPy buffers hold about 16 MB against roughly 27 MB for the equivalent dense array, and that gap widens sharply for a multi-project assembly.
The default junction assay is named counts, not raw_counts. Summing or
filtering it with SciPy keeps the full matrix sparse; toarray() allocates
every cell. For Parquet output, pandas sparse columns must be densified
explicitly (CLI --densify), so estimate the memory requirement first.
Exporting to AnnData
AnnData is the container most Python single-cell and machine-learning tooling
reads. It is transposed relative to BiocPy: samples are rows (obs),
features are columns (var), and each recount3 assay becomes a layer.
recount3.se.to_anndata() performs the conversion and needs the
anndata extra:
adata = r3.se.to_anndata(rse)
print("samples x genes:", adata.shape)
print("uns provenance:", adata.uns["project"], adata.uns["annotation"],
len(adata.uns["resource_urls"]), "resource URLs")
print("layers:", list(adata.layers), "| obs columns:", adata.obs.shape[1])
Output:
samples x genes: (12, 63856)
uns provenance: SRP009615 gencode_v26 7 resource URLs
layers: ['raw_counts'] | obs columns: 176
Sample metadata travels as obs, gene annotations as var, and the
experiment’s provenance as uns.
Call to_anndata() rather than the BiocPy
rse.to_anndata() method. BiocPy holds experiment metadata as a
NamedList and hands it to AnnData(uns=...) unchanged, which AnnData
rejects with Only mutable mapping types (e.g. dict) are allowed for
`.uns`.. Since every experiment built here carries provenance metadata, that
applies to all of them. to_anndata() converts the
provenance to plain dicts and lists first, so project, annotation,
resource_urls, and metadata_columns all survive the conversion.
Writing the result to HDF5 has a second requirement. recount3 STAR QC fields
are named after splice motifs and contain /, which HDF5 reads as a path
separator, both in the sample column names and in the uns provenance map
keyed by them. Pass sanitize_for_hdf5=True to rename them:
adata = r3.se.to_anndata(rse, sanitize_for_hdf5=True)
adata.write_h5ad("rse.h5ad")
That is the same fixup recount3 bundle rse --sanitize-columns applies.
Renaming is opt-in because it changes what an analysis indexes by; each
renamed provenance entry keeps its original name in its value, so nothing is
lost. If you would rather not rename anything, write .pkl instead, which
preserves the RSE itself, including its genomic ranges, which AnnData has no
place for.
BigWig coverage
Per-sample BigWig coverage files are not included by default; pass
include_bigwig=True (or use the search_bigwig_files helper)
to add them. Requires the bigwig extra.
bundle = r3.R3ResourceBundle.discover(
organism="human",
data_source="sra",
project="SRP009615",
include_bigwig=True,
)
for res, bw in bundle.iter_bigwig():
with bw:
print(res.description.sample, bw.chroms("chr1"))
mean_chr1 = bw.stats("chr1", 0, 1_000_000, type="mean")[0]
BigWigFile is a thin wrapper around
pyBigWig. Its main methods are chroms(), header(), values(),
stats(), intervals(), and close():
bw_res = bundle.bigwigs().resources[0]
bw = bw_res.load() # a BigWigFile wrapper
with bw: # closes the handle on exit
values = bw.values("chr1", 0, 1000, numpy=True)
Note which object each spelling gives you. load() returns the
BigWigFile wrapper, so bw above is the wrapper
and reopens its handle automatically on the next read after a close().
Entering the wrapper as a context manager instead yields the live pyBigWig
handle, not the wrapper:
bw_res = r3.search_bigwig_files(
organism="human", data_source="sra", project="SRP009615",
sample="SRR387777",
)[0]
with bw_res.load() as bw: # bw is the pyBigWig handle here
coverage = bw.values("chr1", 10_000, 11_000, numpy=True)
mean_signal = bw.stats("chr1", 10_000, 11_000, type="mean", exact=True)[0]
print(type(coverage).__name__, coverage.shape, "| mean:", mean_signal)
Output:
ndarray (1000,) | mean: 0.036
Per-base coverage comes back as a numpy.ndarray, one value per base
over the requested interval.
Both spellings close the file on exit and expose the same values(),
stats(), and intervals() calls, so either spelling works; just do not
expect wrapper-only behaviour from the second.
BigWig intervals use zero-based, half-open coordinates; uncovered positions can
be NaN. load() first caches the full BigWig file. Reading a small interval
limits the array returned, not the initial download. To limit downloads to one
sample, use r3.search_bigwig_files(..., sample="SRR387777") as shown above
rather than discovering a whole project’s coverage files.
Cache and configuration
The default filesystem backend stores downloads under
~/.cache/recount3/files. The optional pybiocfilecache backend defaults
to R’s recount3 cache directory, as described below.
The recount3.config helpers let you inspect and prune the cache:
import recount3 as r3
print(r3.recount3_cache()) # cache directory Path
files = r3.recount3_cache_files(pattern="*.gtf.gz")
# Dry-run a deletion first:
to_remove = r3.recount3_cache_rm(
predicate=lambda p: ".junctions." in p.name,
dry_run=True,
)
r3.recount3_cache_rm(predicate=lambda p: ".junctions." in p.name)
Threaded operations
R3ResourceBundle.download(max_workers=8) and recount3 download --jobs 8
use worker threads. A single R3Resource.download() performs one operation.
create_rse() and the bundle’s experiment constructors do not automatically
call the parallel bundle downloader. Prefetch a bundle explicitly before
constructing an experiment when overlapping transfers is useful:
bundle.download(dest="downloads", cache="enable", max_workers=4)
rse = bundle.to_ranged_summarized_experiment(genomic_unit="gene")
The worker count bounds concurrency, not guaranteed throughput. Network capacity, server policies, dataset count, disk I/O, and ZIP writes can limit scaling. Experiment construction and parsing have separate memory costs.
Cache destinations and refreshes
Cache transfers are coordinated by the canonical destination path, resolving
relative paths and symlink aliases. Different cache entries may transfer
concurrently. Requests for the same missing entry check existence under its
lock: after one succeeds, waiting enable requests reuse that file. If a
transfer fails, its lock is released and a waiter can attempt the download.
Locks are weakly retained, so an unbounded list of historical URLs is not kept.
Each update request performs its own transfer, including simultaneous
requests. Updates to the same destination run sequentially; lock acquisition
order is unspecified. An enable request waits for a transfer already
holding that destination’s lock, then uses the available file. It does not
force a further refresh. The last successful update determines the cache
contents. Returned paths are not immutable snapshots of that version.
Transfers write a temporary sibling and atomically replace the payload only on success. A failed transfer leaves any previous payload intact and removes its temporary file. Already-open readers and hard-linked materializations can continue to refer to the previous version after replacement. A later refresh does not silently update those materializations or parsed objects in memory.
ZIP writes retain a lock per canonical archive path. Downloads for different
archive members can overlap, but adding or replacing members is serialized.
With cache="disable", ZIP downloads use temporary files before inserting
complete members; directory downloads stream to their destinations without
cache deduplication. Cache-disabled calls do not load pybiocfilecache.
All these locks coordinate threads within one Python process. They do not coordinate separate CLI jobs, Python processes, R sessions, or cluster nodes. Use separate cache and output paths per concurrent process, or provide external coordination. Run cache removal, registry maintenance, and external file edits only when the cache is idle. Atomic payload replacement alone does not provide cross-process deduplication or transactional registry and payload updates.
Errors and troubleshooting
All recount3 exceptions derive from Recount3Error,
so a single except clause catches every package-specific failure:
Exception |
Raised when |
|---|---|
|
Bad config (env var, cache dir, option combinations) |
|
Network/I-O failure during download |
|
Cached file parsed empty, malformed, or shape-mismatched |
|
Incompatible resources combined in a stack/build |
|
Genomic ranges could not be derived for an RSE |
|
Nothing in the bundle can supply genomic ranges |
|
Ranges source omits some counted features |
The three ranges errors also subclass ValueError, which is what
create_rse has always raised on this failure, so existing
except ValueError handlers keep working. MissingRangesError and
RangesCoverageError are what you catch to tell “there was no
annotation to read” from “the annotation was the wrong one”.
Common pitfalls
ImportError: summarizedexperiment is requiredInstall the BiocPy extra:
pip install "recount3[biocpy]".Writing Parquet requires a Parquet engineNo
pyarroworfastparquetis installed. Runpip install "recount3[parquet]", or write.tsv,.tsv.gz, or.csvinstead.Cannot write .h5ad: Optional dependency 'anndata' is requiredAnnData export needs
anndataanddelayedarray. Runpip install "recount3[anndata]", or write a.pklinstead.Cannot write .h5ad: N name(s) contain a forward slashHDF5 reads
/as a path separator, and recount3 STAR QC fields are named after splice motifs (..._gt/ag). This affects the sample columns and theunsprovenance map keyed by them. Pass--sanitize-columnsto rename both to..._gt_ag(in Python,r3.se.to_anndata(rse, sanitize_for_hdf5=True)), or write a.pklinstead, which keeps the names verbatim.Cannot write Parquet: N columns use a pandas sparse dtypeJunction count matrices load sparse-backed and no Parquet engine accepts
pandas.SparseDtype. Write a text format, or pass--densifyto materialize every implicit zero first. Densifying a junction matrix can need far more memory than the sparse form.KeyError: Missing required field: annotation_extensionGene and exon descriptions need an annotation code. Pass it explicitly (
annotation_extension="G026") or usecreate_rse, which resolves a default for you.TypeError: stack_count_matrices() got an unexpected keyword 'genomic_unit'Filter the bundle before calling stack:
bundle.filter(genomic_unit="gene").stack_count_matrices().RangesError: Could not derive genomic ranges …The rest of the message names the cause: the annotation (or, for junctions, the RR coordinate file) could not be retrieved, could not be parsed, or does not cover every counted feature. This is a mismatch, fixed by passing the matching
annotation_extension. A fourth variant, “no annotation providing genomic ranges was in the bundle”, means there is no GTF or RR file to work from at all; include one at discovery time.allow_fallback_to_se=Truereturns a range-lessSummarizedExperimentinstead of raising; it does not retry or repair anything.CompatibilityError: Incompatible count families …You tried to stack gene/exon counts together with junctions. Filter to one family first, or stack each family separately.
Where to go next
API Reference: full per-symbol reference for all public modules.
CLI Reference: the
recount3command-line tool, which mirrors this API as a discover -> manifest -> materialize workflow.The recount3 raw-files documentation describes the underlying file layout (URLs, sharding, annotation codes). Note that this upstream page (not this tutorial) contains several inaccuracies.