Single-cell RNA data describe each cell with thousands of gene measurements. We cannot view all those dimensions at once, and noisy features can obscure useful similarities. Principal component analysis (PCA) forms weighted combinations of the selected gene values, ordered by the variation they capture. Keeping the first few gives each cell a smaller set of coordinates that CyteArc uses to find neighbours and build a graph. Large variation can reflect technical effects as well as biological differences.
UMAP, densMAP, and t-SNE turn that neighbourhood graph into a two-dimensional view. Their settings change how the graph looks without changing its edges. These layouts are visual summaries, not alternative cluster assignments. Choosing how many PCA dimensions to retain does change the graph: too few can merge distinct populations, while too many can bring technical variation and noise back into the comparison.
Here we compare PCA dimension counts, UMAP packing, and all three layout methods on a prepared PBMC analysis. We will judge the views by whether known populations and local relationships remain visible, rather than by how tidy the pictures look.
See Clustering and cluster evidence for choosing and interpreting cluster assignments.
Open the prepared analysis¶
# Load tools for comparing partitions and displaying their layouts.
from itertools import combinations
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import cytearc
# Keep routine progress messages out of the teaching output.
cytearc.configure_output(level="WARNING", progress=False)
# Download the prepared example, including its saved analysis.
dataset = cytearc.cytebase.connect("cytearc_docs").download_dataset(
"tenx_5K_pbmc_rnaseq", destination="cytearc_datasets", zarr=True
)Started: 2026-10-11T18:54:46.033260Z | Wall time: 3.600 s
Open the downloaded store and its saved analysis.
# Open the datastore for the following analysis.
ds = cytearc.DataStore(f"{dataset}/data.zarr", nthreads=4)
# Open the prepared baseline for the comparisons below.
baseline = ds.pipeline.open(label="docs_default")
# Keep the normalization fixed while comparing reductions.
normalized = baseline["normalized"]
# Reuse the saved UMAP coordinates.
umap = baseline["umap"]
# Inspect the opened assays and their dimensions.
dsDataStore has 3948 (5025) cells with 1 assays: RNA
Cell metadata:
'I', 'ids', 'names', 'RNA_G2M_score', 'RNA_S_score',
'RNA_UMAP1', 'RNA_UMAP2', 'RNA_cell_cycle_phase', 'RNA_clusters', 'RNA_doublet_score',
'RNA_leiden_cluster', 'RNA_nCounts', 'RNA_nFeatures', 'RNA_paris_cluster', 'RNA_percentMito',
'RNA_percentRibo'
RNA assay has 33538 features and following metadata:
'I', 'ids', 'names', 'dropOuts', 'feature_type',
'genome', 'nCells'Started: 2026-10-11T18:54:49.636008Z | Wall time: 2.949 s
The saved analysis used 15 PCs; CyteArc’s current default is 21. We keep the example’s cells, genes, and normalization fixed so we can explore what changing the number of PCs does.
# Inspect the baseline clusters before changing PCA dimensions.
ds.plots.embedding(run=baseline, color_by="clusters")
Started: 2026-10-11T18:54:52.586568Z | Wall time: 0.515 s
Compare PCA dimension counts¶
Build each candidate from the same normalized data and cluster each graph by passing it explicitly. Retain the 15-component graph and initialization for the layout comparisons below. The 15-component graph and clusters come from the saved analysis. The 10- and 30-component graphs are built below.
# Compare three PCA dimension counts.
dimension_counts = (10, 15, 30)
# Reuse the baseline graph built from 15 PCs.
graph_15 = baseline["connectivity_map"]
# Reuse the baseline clustering for the 15-PC comparison.
cluster_refs = {15: baseline["leiden_0.5"]}
# Rebuild only the alternatives to the saved 15-PC analysis.
for dimensions in (10, 30):
# Fit PCA with the current dimension count.
pca = ds.reduction.pca(
normalized, dims=dimensions, show_elbow_plot=dimensions == 30
)
# Build an index over these PCA coordinates.
ann = ds.graph.ann_index(pca)
# Query the same number of neighbors for every candidate.
neighbors = ds.graph.neighbors(ann, k=11)
# Build connectivity from this candidate's neighbors.
graph = ds.graph.connectivity(neighbors)
# Cluster this candidate graph at the fixed resolution.
cluster_refs[dimensions] = ds.clusters.leiden(graph, resolution=0.5)
# Reuse the same initialization for the layout comparisons.
initialization_15 = baseline["embedding_initialization"]
# Load each candidate's labels in the common cell order.
cluster_values = {
dimensions: np.asarray(
ds.artifacts.load(cluster_refs[dimensions])["values"][:]
)
for dimensions in dimension_counts
}
Started: 2026-10-11T18:54:53.103877Z | Wall time: 19.702 s
PCA axes represent decreasing amounts of variation in the selected genes. An early bend in the elbow plot means later axes each add less variance. It suggests a range to investigate, rather than a single correct cutoff. CyteArc fits one extra component for this plot; when reusing a saved PCA result, it may warn that the plot is unavailable. Too few axes can merge distinct populations; too many can restore technical variation and noise. Compare graph connectivity, cluster stability, and marker coherence when the choice is uncertain.
Compare cluster sizes as well as the number of clusters. Cluster numbers can change between analyses, so matching row numbers do not necessarily identify the same cells.
# Count cells per cluster for each PCA dimension count.
cluster_sizes = pd.DataFrame(
{
dimensions: pd.Series(cluster_values[dimensions]).value_counts()
for dimensions in dimension_counts
}
).fillna(0).astype(int)
# Label both comparison axes before displaying the cluster sizes.
cluster_sizes.rename_axis(index="cluster", columns="PCs")| PCs | 10 | 15 | 30 |
|---|---|---|---|
| cluster | |||
| 1 | 602 | 716 | 704 |
| 2 | 127 | 239 | 237 |
| 3 | 248 | 1237 | 1299 |
| 4 | 738 | 506 | 601 |
| 5 | 537 | 318 | 319 |
| 6 | 298 | 487 | 317 |
| 7 | 363 | 149 | 144 |
| 8 | 149 | 256 | 258 |
| 9 | 245 | 25 | 17 |
| 10 | 614 | 15 | 25 |
| 11 | 15 | 0 | 15 |
| 12 | 12 | 0 | 12 |
Started: 2026-10-11T18:55:12.807854Z | Wall time: 0.012 s
# Compare partition agreement between PCA dimension counts.
pd.Series(
{
f"{first} vs {second} PCs": ds.clusters.compute_concordance(
cluster_refs[first], cluster_refs[second]
)
for first, second in combinations(dimension_counts, 2)
},
name="adjusted Rand index",
)10 vs 15 PCs 0.679137
10 vs 30 PCs 0.670148
15 vs 30 PCs 0.838912
Name: adjusted Rand index, dtype: float64Started: 2026-10-11T18:55:12.820962Z | Wall time: 0.062 s
The adjusted Rand index measures partition agreement without requiring matching cluster numbers. It does not identify the biologically correct dimension count. Inspect markers and QC metrics where the partitions disagree. See Clustering and cluster evidence for more on cluster evidence.
Compare UMAP packing¶
The layout below uses the explicit 15-component graph. Colouring by each Leiden partition shows how the 10-, 15-, and 30-component cuts land on the same coordinates.
# Create one plotting axis for each comparison panel.
figure, axes = plt.subplots(1, 3, figsize=(12, 4))
# Draw each comparison on its own labeled axis.
for axis, dimensions in zip(axes, dimension_counts, strict=True):
# Place each candidate clustering on the common baseline UMAP.
ds.plots.embedding(
layout=umap,
color_by=cluster_refs[dimensions],
target=axis,
show_titles=False,
show=False,
)
# Label the panel with the quantity being compared.
axis.set_title(f"Leiden on {dimensions} PCs")
# Adjust spacing so panel labels remain readable.
figure.tight_layout()
# Display the completed figure.
figure
Started: 2026-10-11T18:55:12.884322Z | Wall time: 1.346 s
min_dist controls how tightly cells can pack in the layout relative to spread, which sets
the overall distance scale. A smaller min_dist often makes groups appear tighter, but it does
not add evidence for a cluster or change the input graph. Compare 0.1 with the prepared
example’s value of 1 while keeping the neighbours and cluster labels fixed.
# Change UMAP packing while holding the graph and initialization fixed.
umap_tight = ds.embeddings.umap(graph_15, initialization_15, min_dist=0.1)Started: 2026-10-11T18:55:14.231860Z | Wall time: 4.399 s
# Create one plotting axis for each comparison panel.
figure, axes = plt.subplots(1, 2, figsize=(10, 4))
# Draw each comparison on its own labeled axis.
for axis, layout, title in zip(
axes, (umap, umap_tight), ("min_dist=1", "min_dist=0.1"), strict=True
):
# Compare UMAP packing with the same cluster colors.
ds.plots.embedding(
layout=layout,
color_by=cluster_refs[15],
target=axis,
show_titles=False,
show=False,
)
# Label the panel with the quantity being compared.
axis.set_title(title)
# Adjust spacing so panel labels remain readable.
figure.tight_layout()
# Display the completed figure.
figure
Started: 2026-10-11T18:55:18.632545Z | Wall time: 0.729 s
Optional: compare densMAP and t-SNE¶
# Fit densMAP to the same graph and initialization.
densmap = ds.embeddings.umap(graph_15, initialization_15, use_density_map=True)Started: 2026-10-11T18:55:19.363405Z | Wall time: 5.129 s
densMAP adds a density-preservation objective. Relative packing can differ from UMAP; plot area is still not a direct estimate of cell frequency.
CyteArc’s t-SNE consumes the same neighbourhood graph.
Computing a new embedding needs the optional sgtsnepi package from the tsne extra (see Optional t-SNE); reusing a saved embedding does not.
# Fit t-SNE to that graph without requesting iteration logs.
tsne = ds.embeddings.tsne(graph_15, initialization_15, verbose=False)Started: 2026-10-11T18:55:24.494579Z | Wall time: 1.154 s
Read the layouts cautiously¶
# Create one plotting axis for each comparison panel.
figure, axes = plt.subplots(1, 3, figsize=(12, 4))
# Pair each saved layout with its display title.
layout_comparisons = (("UMAP", umap), ("densMAP", densmap), ("t-SNE", tsne))
# Draw each comparison on its own labeled axis.
for axis, (title, layout) in zip(axes, layout_comparisons, strict=True):
# Compare UMAP, densMAP, and t-SNE with the same cluster colors.
ds.plots.embedding(
layout=layout,
color_by=cluster_refs[15],
target=axis,
show_titles=False,
show=False,
)
# Label the panel with the quantity being compared.
axis.set_title(title)
# Adjust spacing so panel labels remain readable.
figure.tight_layout()
# Display the completed figure.
figure
Started: 2026-10-11T18:55:25.650473Z | Wall time: 1.279 s
The three panels share cells, graph, and cluster labels. A useful comparison asks whether local neighbours and known populations remain visible. Differences in global orientation, distance, empty space, or apparent island size do not demonstrate different biology. A layout that hides connected transitions or separates obvious technical covariates needs further investigation.
Next: clustering¶
Continue with Clustering and cluster evidence to compare ways of grouping cells. That guide distinguishes graph connectivity between groups from the Paris hierarchy and covers both adaptive and fixed cuts.