Skill Tracer
Skills › Scientific Agent Skills › scanpy › analysis_workflow.md

analysis_workflow.md

251 lines · skills/scanpy/references/analysis_workflow.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Standard Analysis Workflow and Common Tasks
2
3Reviewed for Scanpy 1.12.4 / AnnData 0.13.4 on 2026-10-01. These snippets are
4illustrative and require the named genes, groups and prior results to exist.
5Use the tested CLI workflow for a runnable starting point. Counts, normalized
6expression and `.raw` have distinct roles; see [upstream-review.md](upstream-review.md).
7
8
9The seven workflow steps in full — quality control, normalization and preprocessing,
10dimensionality reduction, clustering, marker gene identification, cell type annotation,
11and saving results — followed by common tasks: publication-quality plots, trajectory
12inference, pseudobulk differential expression between conditions, gene set scoring, and
13batch correction.

Standard Analysis Workflow and Common Tasks

Reviewed for Scanpy 1.12.4 / AnnData 0.13.4 on 2026-10-01. These snippets are illustrative and require the named genes, groups and prior results to exist. Use the tested CLI workflow for a runnable starting point. Counts, normalized expression and .raw have distinct roles; see upstream-review.md.

The seven workflow steps in full — quality control, normalization and preprocessing, dimensionality reduction, clustering, marker gene identification, cell type annotation, and saving results — followed by common tasks: publication-quality plots, trajectory inference, pseudobulk differential expression between conditions, gene set scoring, and batch correction.

Raw lines 1 to 14
15## Standard Analysis Workflow

Standard Analysis Workflow

Raw lines 15 to 16
17### 1. Quality Control
19Identify and filter low-quality cells and genes:
21```python
22# Identify mitochondrial genes
23adata.var['mt'] = adata.var_names.str.startswith('MT-')
25# Calculate QC metrics
26sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, inplace=True)
28# Visualize QC metrics
29sc.pl.violin(adata, ['n_genes_by_counts', 'total_counts', 'pct_counts_mt'],
30 jitter=0.4, multi_panel=True)
32# Filter cells and genes
33sc.pp.filter_cells(adata, min_genes=200)
34sc.pp.filter_genes(adata, min_cells=3)
35adata = adata[adata.obs.pct_counts_mt < 5, :].copy() # Remove high MT% cells
36```
38**Doublet detection (optional, on raw counts before normalization):**
40```python
41sc.pp.scrublet(adata) # Core API since scanpy 1.10 (was scanpy.external.pp)
42adata = adata[~adata.obs['predicted_doublet'], :].copy()
43```
45**Use the QC script for automated analysis** (run from the skill directory or pass the full path):
47```bash
48python skills/scanpy/scripts/qc_analysis.py input_file.h5ad --output filtered.h5ad
49```

1. Quality Control

Identify and filter low-quality cells and genes:

# 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, inplace=True)

# Visualize QC metrics
sc.pl.violin(adata, ['n_genes_by_counts', 'total_counts', 'pct_counts_mt'],
             jitter=0.4, multi_panel=True)

# Filter cells and genes
sc.pp.filter_cells(adata, min_genes=200)
sc.pp.filter_genes(adata, min_cells=3)
adata = adata[adata.obs.pct_counts_mt < 5, :].copy()  # Remove high MT% cells

Doublet detection (optional, on raw counts before normalization):

sc.pp.scrublet(adata)  # Core API since scanpy 1.10 (was scanpy.external.pp)
adata = adata[~adata.obs['predicted_doublet'], :].copy()

Use the QC script for automated analysis (run from the skill directory or pass the full path):

python skills/scanpy/scripts/qc_analysis.py input_file.h5ad --output filtered.h5ad
Raw lines 17 to 50
Runs code python skills/scanpy/scripts/qc_analysis.py :48
51### 2. Normalization and Preprocessing
53```python
54# Preserve identified raw counts BEFORE normalization
55adata.layers["counts"] = adata.X.copy()
56# Normalize to 10,000 counts per cell
57sc.pp.normalize_total(adata, target_sum=1e4)
59# Log-transform
60sc.pp.log1p(adata)
62# Save the full log-normalized matrix for markers/plots, not raw counts
63adata.raw = adata.copy()
65# Identify highly variable genes
66sc.pp.highly_variable_genes(adata, n_top_genes=2000)
67sc.pl.highly_variable_genes(adata)
69# Gene subsetting also subsets counts: retain full-gene data for pseudobulk
70adata.write_h5ad("full_gene_lognorm.h5ad")
71# Subset to highly variable genes
72adata = adata[:, adata.var.highly_variable].copy()
74# Optional only with biological justification; can remove true signal
75# sc.pp.regress_out(adata, ['total_counts', 'pct_counts_mt'])
77# Scale data
78sc.pp.scale(adata, max_value=10)
79```

2. Normalization and Preprocessing

# Preserve identified raw counts BEFORE normalization
adata.layers["counts"] = adata.X.copy()
# Normalize to 10,000 counts per cell
sc.pp.normalize_total(adata, target_sum=1e4)

# Log-transform
sc.pp.log1p(adata)

# Save the full log-normalized matrix for markers/plots, not raw counts
adata.raw = adata.copy()

# Identify highly variable genes
sc.pp.highly_variable_genes(adata, n_top_genes=2000)
sc.pl.highly_variable_genes(adata)

# Gene subsetting also subsets counts: retain full-gene data for pseudobulk
adata.write_h5ad("full_gene_lognorm.h5ad")
# Subset to highly variable genes
adata = adata[:, adata.var.highly_variable].copy()

# Optional only with biological justification; can remove true signal
# sc.pp.regress_out(adata, ['total_counts', 'pct_counts_mt'])

# Scale data
sc.pp.scale(adata, max_value=10)
Raw lines 51 to 80
81### 3. Dimensionality Reduction
83```python
84# PCA
85n_pcs = min(40, adata.n_obs - 1, adata.n_vars - 1)
86sc.pp.pca(adata, n_comps=n_pcs, svd_solver='arpack')
87sc.pl.pca_variance_ratio(adata, log=True) # Check elbow plot
89# Compute neighborhood graph
90sc.pp.neighbors(adata, n_neighbors=10, n_pcs=n_pcs, use_rep='X_pca')
92# UMAP for visualization
93sc.tl.umap(adata)
94sc.pl.umap(adata, color='total_counts')
96# Alternative: t-SNE
97sc.tl.tsne(adata)
98```

3. Dimensionality Reduction

# PCA
n_pcs = min(40, adata.n_obs - 1, adata.n_vars - 1)
sc.pp.pca(adata, n_comps=n_pcs, svd_solver='arpack')
sc.pl.pca_variance_ratio(adata, log=True)  # Check elbow plot

# Compute neighborhood graph
sc.pp.neighbors(adata, n_neighbors=10, n_pcs=n_pcs, use_rep='X_pca')

# UMAP for visualization
sc.tl.umap(adata)
sc.pl.umap(adata, color='total_counts')

# Alternative: t-SNE
sc.tl.tsne(adata)
Raw lines 81 to 99
100### 4. Clustering
102```python
103# Leiden clustering (recommended)
104sc.tl.leiden(adata, resolution=0.5, flavor='igraph', directed=False, n_iterations=2)
105sc.pl.umap(adata, color='leiden', legend_loc='on data')
107# Try multiple resolutions to find optimal granularity
108for res in [0.3, 0.5, 0.8, 1.0]:
109 sc.tl.leiden(adata, resolution=res, key_added=f'leiden_{res}')
110```

4. Clustering

# Leiden clustering (recommended)
sc.tl.leiden(adata, resolution=0.5, flavor='igraph', directed=False, n_iterations=2)
sc.pl.umap(adata, color='leiden', legend_loc='on data')

# Try multiple resolutions to find optimal granularity
for res in [0.3, 0.5, 0.8, 1.0]:
    sc.tl.leiden(adata, resolution=res, key_added=f'leiden_{res}')
Raw lines 100 to 111
112### 5. Marker Gene Identification
114Use `rank_genes_groups` for **exploratory cluster markers** only. Per-cell statistical tests inflate significance (often making p-values too small) because cells are not independent observations. For rigorous differential expression between conditions or samples, pseudobulk first (see below) and use **pydeseq2** or similar tools.
116```python
117# Find marker genes for each cluster (exploratory)
118sc.tl.rank_genes_groups(adata, 'leiden', method='wilcoxon')
120# Visualize results
121sc.pl.rank_genes_groups(adata, n_genes=25, sharey=False)
122sc.pl.rank_genes_groups_heatmap(adata, n_genes=10)
123sc.pl.rank_genes_groups_dotplot(adata, n_genes=5)
125# Get results as DataFrame
126markers = sc.get.rank_genes_groups_df(adata, group='0')
127```

5. Marker Gene Identification

Use rank_genes_groups for exploratory cluster markers only. Per-cell statistical tests inflate significance (often making p-values too small) because cells are not independent observations. For rigorous differential expression between conditions or samples, pseudobulk first (see below) and use pydeseq2 or similar tools.

# Find marker genes for each cluster (exploratory)
sc.tl.rank_genes_groups(adata, 'leiden', method='wilcoxon')

# Visualize results
sc.pl.rank_genes_groups(adata, n_genes=25, sharey=False)
sc.pl.rank_genes_groups_heatmap(adata, n_genes=10)
sc.pl.rank_genes_groups_dotplot(adata, n_genes=5)

# Get results as DataFrame
markers = sc.get.rank_genes_groups_df(adata, group='0')
Raw lines 112 to 128
129### 6. Cell Type Annotation
131```python
132# Define marker genes for known cell types
133marker_genes = ['CD3D', 'CD14', 'MS4A1', 'NKG7', 'FCGR3A']
135# Visualize markers
136sc.pl.umap(adata, color=marker_genes, use_raw=True)
137sc.pl.dotplot(adata, var_names=marker_genes, groupby='leiden')
139# Manual annotation
140cluster_to_celltype = {
141 '0': 'CD4 T cells',
142 '1': 'CD14+ Monocytes',
143 '2': 'B cells',
144 '3': 'CD8 T cells',
146adata.obs['cell_type'] = adata.obs['leiden'].map(cluster_to_celltype)
148# Visualize annotated types
149sc.pl.umap(adata, color='cell_type', legend_loc='on data')
150```

6. Cell Type Annotation

# Define marker genes for known cell types
marker_genes = ['CD3D', 'CD14', 'MS4A1', 'NKG7', 'FCGR3A']

# Visualize markers
sc.pl.umap(adata, color=marker_genes, use_raw=True)
sc.pl.dotplot(adata, var_names=marker_genes, groupby='leiden')

# Manual annotation
cluster_to_celltype = {
    '0': 'CD4 T cells',
    '1': 'CD14+ Monocytes',
    '2': 'B cells',
    '3': 'CD8 T cells',
}
adata.obs['cell_type'] = adata.obs['leiden'].map(cluster_to_celltype)

# Visualize annotated types
sc.pl.umap(adata, color='cell_type', legend_loc='on data')
Raw lines 129 to 151
152### 7. Save Results
154```python
155# Save processed data
156adata.write('results/processed_data.h5ad')
158# Export metadata
159adata.obs.to_csv('results/cell_metadata.csv')
160adata.var.to_csv('results/gene_metadata.csv')
161```

7. Save Results

# Save processed data
adata.write('results/processed_data.h5ad')

# Export metadata
adata.obs.to_csv('results/cell_metadata.csv')
adata.var.to_csv('results/gene_metadata.csv')
Raw lines 152 to 162
163## Common Tasks
165### Creating Publication-Quality Plots
167Prefer `sc.settings.autosave` and `sc.settings.figdir` for saving figures. The per-plot `save=` parameter is deprecated in scanpy 1.12.
169```python
170# Set high-quality defaults
171sc.set_figure_params(dpi=300, frameon=False, figsize=(5, 5))
172sc.settings.file_format_figs = 'pdf'
173sc.settings.figdir = './figures/'
174sc.settings.autosave = True
176# UMAP with custom styling (saved as figures/umap.pdf via autosave)
177sc.pl.umap(adata, color='cell_type',
178 palette='Set2',
179 legend_loc='on data',
180 legend_fontsize=12,
181 legend_fontoutline=2,
182 frameon=False)
184# Heatmap of marker genes
185sc.pl.heatmap(adata, var_names=genes, groupby='cell_type',
186 swap_axes=True, show_gene_labels=True)
188# Dot plot
189sc.pl.dotplot(adata, var_names=genes, groupby='cell_type')
190```
192Refer to `references/plotting_guide.md` for comprehensive visualization examples.

Creating Publication-Quality Plots

Prefer sc.settings.autosave and sc.settings.figdir for saving figures. The per-plot save= parameter is deprecated in scanpy 1.12.

# Set high-quality defaults
sc.set_figure_params(dpi=300, frameon=False, figsize=(5, 5))
sc.settings.file_format_figs = 'pdf'
sc.settings.figdir = './figures/'
sc.settings.autosave = True

# UMAP with custom styling (saved as figures/umap.pdf via autosave)
sc.pl.umap(adata, color='cell_type',
           palette='Set2',
           legend_loc='on data',
           legend_fontsize=12,
           legend_fontoutline=2,
           frameon=False)

# Heatmap of marker genes
sc.pl.heatmap(adata, var_names=genes, groupby='cell_type',
              swap_axes=True, show_gene_labels=True)

# Dot plot
sc.pl.dotplot(adata, var_names=genes, groupby='cell_type')

Refer to references/plotting_guide.md for comprehensive visualization examples.

Raw lines 165 to 193
194### Trajectory Inference
196```python
197# PAGA (Partition-based graph abstraction)
198sc.tl.paga(adata, groups='leiden')
199sc.pl.paga(adata, color='leiden')
201# Diffusion pseudotime
202adata.uns['iroot'] = np.flatnonzero(adata.obs['leiden'] == '0')[0]
203sc.tl.diffmap(adata, n_comps=10) # choose fewer for small datasets
204sc.tl.dpt(adata, n_dcs=10)
205sc.pl.umap(adata, color='dpt_pseudotime')
206```

Trajectory Inference

# PAGA (Partition-based graph abstraction)
sc.tl.paga(adata, groups='leiden')
sc.pl.paga(adata, color='leiden')

# Diffusion pseudotime
adata.uns['iroot'] = np.flatnonzero(adata.obs['leiden'] == '0')[0]
sc.tl.diffmap(adata, n_comps=10)  # choose fewer for small datasets
sc.tl.dpt(adata, n_dcs=10)
sc.pl.umap(adata, color='dpt_pseudotime')
Raw lines 194 to 207
208### Pseudobulk and Differential Expression Between Conditions
210Pseudobulk by sample and cell type, then run proper DE (e.g., pydeseq2) rather than per-cell `rank_genes_groups`:
212```python
213# Load the FULL-GENE object, then transfer reviewed cell-type/sample metadata
214# Group values must be unambiguous: the CLI handles underscore collisions.
215# Aggregate counts by sample and cell type
216pb = sc.get.aggregate(
217 adata,
218 by=['sample', 'cell_type'],
219 func='sum',
220 layer='counts', # Required original counts, no normalized fallback
222# pb.layers["sum"] is samples x genes; pb.X is None.
223# Join condition/donor metadata, select one cell type and verify replication/design.
224```
226For quick exploratory comparisons within a cluster, `rank_genes_groups` is acceptable but interpret p-values cautiously:
228```python
229adata_subset = adata[adata.obs['cell_type'] == 'T cells'].copy()
230sc.tl.rank_genes_groups(adata_subset, groupby='condition',
231 groups=['treated'], reference='control')
232sc.pl.rank_genes_groups(adata_subset, groups=['treated'])
233```

Pseudobulk and Differential Expression Between Conditions

Pseudobulk by sample and cell type, then run proper DE (e.g., pydeseq2) rather than per-cell rank_genes_groups:

# Load the FULL-GENE object, then transfer reviewed cell-type/sample metadata
# Group values must be unambiguous: the CLI handles underscore collisions.
# Aggregate counts by sample and cell type
pb = sc.get.aggregate(
    adata,
    by=['sample', 'cell_type'],
    func='sum',
    layer='counts',  # Required original counts, no normalized fallback
)
# pb.layers["sum"] is samples x genes; pb.X is None.
# Join condition/donor metadata, select one cell type and verify replication/design.

For quick exploratory comparisons within a cluster, rank_genes_groups is acceptable but interpret p-values cautiously:

adata_subset = adata[adata.obs['cell_type'] == 'T cells'].copy()
sc.tl.rank_genes_groups(adata_subset, groupby='condition',
                         groups=['treated'], reference='control')
sc.pl.rank_genes_groups(adata_subset, groups=['treated'])
Raw lines 208 to 234
235### Gene Set Scoring
237```python
238# Score cells for gene set expression
239gene_set = ['CD3D', 'CD3E', 'CD3G']
240sc.tl.score_genes(adata, gene_set, score_name='T_cell_score')
241sc.pl.umap(adata, color='T_cell_score')
242```

Gene Set Scoring

# Score cells for gene set expression
gene_set = ['CD3D', 'CD3E', 'CD3G']
sc.tl.score_genes(adata, gene_set, score_name='T_cell_score')
sc.pl.umap(adata, color='T_cell_score')
Raw lines 235 to 243
244### Batch Correction
246```python
247# ComBat batch correction
248sc.pp.combat(adata, key='batch')
250# Alternative: use Harmony or scVI (separate packages)
251```

Batch Correction

# ComBat batch correction
sc.pp.combat(adata, key='batch')

# Alternative: use Harmony or scVI (separate packages)
Raw lines 244 to 251