Changing the number of neighbours from 21 to 31 does not require rerunning normalization or PCA, or rebuilding the neighbour-search index. Query the saved index, build a new graph, and keep the original graph for comparison.
This example changes one setting at a time: first the neighbour count, then the number of
principal components. A lineage diagram shows which results the analyses share. The examples
require the extra optional dependencies.
Open a prepared analysis¶
The prepared 100,000-cell Tabula Sapiens subset has a saved run labelled docs_default, with
30 principal components and 21 neighbours. The saved run uses PCA without batch correction.
We use its cells, variable genes, and saved results as the starting point.
These settings belong to this example; they can differ from the current pipeline defaults.
# Open count stores and run CyteArc analyses.
import cytearc
# Keep routine logs and progress bars out of the results.
cytearc.configure_output(level="WARNING", progress=False)
# Download the prepared example store.
dataset = cytearc.cytebase.connect("cytearc_docs").download_dataset(
"tabula_sapiens_100k",
destination="cytearc_datasets",
zarr=True,
)
# Open the count store for this analysis.
ds = cytearc.DataStore(f"{dataset}/data.zarr", nthreads=4, min_features_per_cell=-1)
# Open the saved analysis used to check reuse.
baseline_run = ds.pipeline.open(label="docs_default")
# Keep the cells used by the saved analysis.
cell_selection = baseline_run["analysis_cell_selection"]
# Keep the variable genes used by the saved analysis.
hvg_ref = baseline_run["highly_variable_features"]
# Inspect the opened store's cells and features.
dsDataStore has 100000 (100000) cells with 1 assays: RNA
Cell metadata:
'I', 'ids', 'names', '10X_run', 'RNA_nCounts',
'RNA_nFeatures', 'RNA_percentMito', 'RNA_percentRibo', '_scvi_batch', '_scvi_labels',
'ambient_removal', 'anatomical_position', 'assay', 'assay_ontology_term_id', 'broad_cell_class',
'cdna_plate', 'cdna_well', 'cell_type', 'cell_type_ontology_term_id', 'compartment',
'development_stage', 'development_stage_ontology_term_id', 'disease', 'disease_ontology_term_id', 'donor_assay',
'donor_id', 'donor_method', 'donor_tissue', 'donor_tissue_assay', 'ethnicity_original',
'free_annotation', 'is_primary_data', 'library_plate', 'manually_annotated', 'method',
'n_genes_by_counts', 'notes', 'observation_joinid', 'pct_counts_ercc', 'pct_counts_mt',
'published_2022', 'replicate', 'sample_id', 'sample_number', 'scvi_leiden_donorassay_full',
'self_reported_ethnicity', 'self_reported_ethnicity_ontology_term_id', 'sex', 'sex_ontology_term_id', 'suspension_type',
'tissue', 'tissue_in_publication', 'tissue_ontology_term_id', 'tissue_type', 'total_counts',
'total_counts_ercc', 'total_counts_mt'
RNA assay has 60606 features and following metadata:
'I', 'ids', 'names', '_index', 'dropOuts',
'ercc', 'feature_biotype', 'feature_length', 'feature_reference', 'feature_type',
'genome', 'mean', 'mean_counts', 'mt', 'nCells',
'n_cells_by_counts', 'pct_dropout_by_counts', 'std', 'total_counts'Started: 2026-10-11T18:54:54.462572Z | Wall time: 37.168 s
Change the neighbour count from 21 to 31¶
Increasing k requests more neighbours from the same search index. CyteArc writes a new
neighbour list and connectivity graph, keeping the normalization, PCA, and index unchanged.
# Reopen the saved normalized expression.
normalized = baseline_run["normalized"]
# Reopen the saved PCA result.
pca = baseline_run["pca"]
# Keep the saved nearest-neighbor index.
ann = baseline_run["ann_index"]
# Keep the saved 21-neighbor result.
neighbors_k21 = baseline_run["neighbors"]
# Keep the baseline connectivity graph.
graph_k21 = baseline_run["connectivity_map"]
# Query the existing index with 31 neighbors per cell.
neighbors_k31 = ds.graph.neighbors(ann, k=31)
# Build connectivity for the new neighbor count.
graph_k31 = ds.graph.connectivity(neighbors_k31)
# Inspect which saved index the new neighbor query used.
query_status = ds.artifacts.inspect(neighbors_k31)
# Check which results were reused and which changed.
reuse_checks = {
"normalization reused": ds.features.normalize(cell_selection, hvg_ref) == normalized,
"PCA reused": ds.reduction.pca(normalized, dims=30) == pca,
"ANN index reused": query_status.input_ref("ann_index") == ann,
"neighbors changed": neighbors_k31 != neighbors_k21,
"graph changed": graph_k31 != graph_k21,
}
# Stop if any result differs from the reuse behavior described here.
assert all(reuse_checks.values()), reuse_checks
# Display the verified comparison.
reuse_checks{'normalization reused': True,
'PCA reused': True,
'ANN index reused': True,
'neighbors changed': True,
'graph changed': True}Started: 2026-10-11T18:55:31.634789Z | Wall time: 30.334 s
See Build a neighborhood graph stage by stage for how changing k affects an analysis. Here, notice that only
neighbours and the graph needed new results. Normalization, PCA, and the search index did not
need to run again.
The index check reads the new neighbour result’s recorded input. Matching it to ann confirms
that the query used the existing index.
Compare 30 and 40 principal components¶
A new PCA dimensionality creates a separate analysis branch. The search index, neighbours, and connectivity depend on the reduction, so the new branch needs its own result at each of those steps. The earlier 30-component analysis remains available alongside the new 40-component one.
# Fit a separate reduction with 40 principal components.
pca_dims40 = ds.reduction.pca(normalized, dims=40)
# Build a search index from the 40-component reduction.
ann_dims40 = ds.graph.ann_index(pca_dims40)
# Find 21 neighbors using the new reduction.
neighbors_dims40 = ds.graph.neighbors(ann_dims40, k=21)
# Build the connectivity graph for this reduction.
graph_dims40 = ds.graph.connectivity(neighbors_dims40)
# Check which results were reused and which changed.
branch_checks = {
"PCA changed": pca_dims40 != pca,
"ANN index changed": ann_dims40 != ann,
"neighbors changed": neighbors_dims40 != neighbors_k21,
"graph changed": graph_dims40 != graph_k21,
}
# Stop if the new branch unexpectedly shares one of these results.
assert all(branch_checks.values()), branch_checks
# Display the verified comparison.
branch_checks{'PCA changed': True,
'ANN index changed': True,
'neighbors changed': True,
'graph changed': True}Started: 2026-10-11T18:56:01.972655Z | Wall time: 14.837 s
The comparisons above refer to these original 30-component results. Creating the new branch has not replaced them:
# Revisit the original references after building the 40-component branch.
{
"baseline PCA": pca,
"baseline neighbors": neighbors_k21,
"baseline graph": graph_k21,
}{'baseline PCA': ArtifactRef(assay='RNA', kind='reduction', artifact_id='532aa2d022fe...'),
'baseline neighbors': ArtifactRef(assay='RNA', kind='neighbors', artifact_id='91937d072cd9...'),
'baseline graph': ArtifactRef(assay='RNA', kind='connectivity_map', artifact_id='f2ac2849e34c...')}Started: 2026-10-11T18:56:16.813081Z | Wall time: 0.006 s
Compare analysis branches¶
Put all three graphs in one read-only report: the two neighbour-count branches and the
dims=40 branch. Each shared input appears once, so the diagram shows where a parameter
change first requires a new result.
# Trace the shared inputs and differences between all three graphs.
lineage = ds.artifacts.lineage(
{
"k21 graph": graph_k21,
"k31 graph": graph_k31,
"dims40 graph": graph_dims40,
}
)
# Display the shared inputs and diverging analysis branches.
lineageOutput
Started: 2026-10-11T18:56:16.822409Z | Wall time: 0.604 s
Expand the output to inspect the full report, including its Mermaid source and exact artifact references. This diagram summarizes the branches verified above:
The k branches diverge after the ANN index. The dims=40 branch forks earlier, at PCA,
then carries its own ANN, neighbours, and graph. The full report also includes the shared
cell and feature selections and feature-scaling result.
Export the same report when it needs to travel with an analysis.
to_markdown() is what notebook display uses. Inspect a short preview before writing or sending
the complete string elsewhere:
# Export the displayed lineage report as Markdown.
lineage_markdown = lineage.to_markdown()
# Preview the opening lines of the exported report.
lineage_markdown.splitlines()[:12]['```mermaid',
'flowchart LR',
' artifact0["RNA / metadata_snapshot | snapshot_run_metadata | 7340c6fca965"]',
' artifact1["datastore / cell_selection | snapshot_pipeline_input_selection | 829e00dc1708"]',
' artifact2["RNA / feature_summary | summarize_rna_features | cab031960e6e"]',
' artifact3["RNA / feature_selection | select_hvgs | 561ec515123d"]',
' artifact4["RNA / normalized | run_normalization | 525af1d08271"]',
' artifact5["RNA / feature_scaling | calculate_feature_scaling | 739b82327259"]',
' artifact6["RNA / reduction | run_pca | 532aa2d022fe"]',
' artifact7["RNA / ann_index | build_ann_index | e7f9b6ebbb42"]',
' artifact8["RNA / neighbors | query_neighbors | 91937d072cd9"]',
' artifact9["RNA / connectivity_map | build_connectivity_map | f2ac2849e34c | outputs: k21 graph"]']Started: 2026-10-11T18:56:17.429618Z | Wall time: 0.007 s
lineage.to_mermaid() returns only the diagram source when a tooling pipeline needs that form alone.
Find and inspect saved results¶
The completed run retains every immutable reference needed to keep side comparisons separate.
# Inspect the saved chain from normalization to connectivity.
{
"normalization": normalized,
"PCA": pca,
"neighbor index": ann,
"neighbors": neighbors_k21,
"graph": graph_k21,
}{'normalization': ArtifactRef(assay='RNA', kind='normalized', artifact_id='525af1d08271...'),
'PCA': ArtifactRef(assay='RNA', kind='reduction', artifact_id='532aa2d022fe...'),
'neighbor index': ArtifactRef(assay='RNA', kind='ann_index', artifact_id='e7f9b6ebbb42...'),
'neighbors': ArtifactRef(assay='RNA', kind='neighbors', artifact_id='91937d072cd9...'),
'graph': ArtifactRef(assay='RNA', kind='connectivity_map', artifact_id='f2ac2849e34c...')}Started: 2026-10-11T18:56:17.440209Z | Wall time: 0.006 s
For how results record their inputs, see Why CyteArc records provenance.
The artifact catalog can also find results by their exact inputs. It returns every match instead of
guessing which result you intended. Unpacking into [reopened_graph] checks that the search
found exactly one graph; zero or multiple matches raise an error:
# Find the exact saved graph from its neighbor input.
[reopened_graph] = ds.artifacts.list(
from_assay="RNA",
kind="connectivity_map",
operation="build_connectivity_map",
inputs={"neighbors": neighbors_k21},
complete_only=True,
)
# Check that the catalog returned the baseline graph.
assert reopened_graph == graph_k21
# Inspect the saved graph's operation, parameters, and inputs.
status = ds.artifacts.inspect(reopened_graph)
# Inspect the graph operation and the exact inputs it records.
{
"operation": status.operation,
"parameters": status.parameters,
"inputs": status.inputs,
"complete": status.complete,
}{'operation': 'build_connectivity_map',
'parameters': {'local_connectivity': 1.0, 'bandwidth': 1.5},
'inputs': {'neighbors': {'type': 'artifact',
'scope': 'assay',
'kind': 'neighbors',
'artifact_id': '91937d072cd999181452a281debaa17cb51adcae39ccda32c7defb6d3271523e',
'assay': 'RNA'}},
'complete': True}Started: 2026-10-11T18:56:17.448758Z | Wall time: 0.013 s
Recompute a matching result¶
invalidate_cache=True skips reuse even when the parameters match.
Previously completed artifacts remain on disk.
The new reference has a different id and path.
The operation and parameters stay the same.
CyteArc also stops reusing a result on its own when a release changes what the operation computes. Such a release gives the operation a new revision, and an earlier result of that operation is computed again, with one log line that names the result it replaced and what changed. The earlier result remains on disk, and lineage reports mark it as stale. See Operation revisions.
For normalization, the call below would write a fresh normalized artifact while retaining the
exact immutable cell_selection and feature_selection inputs. It is not executed here because a
throwaway duplicate adds no evidence to the lineage figure.
# Force normalization to create a new result with the same inputs.
forced = ds.features.normalize(cell_selection, hvg_ref, invalidate_cache=True)
# Check that forcing recomputation produced a distinct result.
forced != normalized