Clustering and cluster evidence#

Clustering is a model of graph structure, not a cell-type verdict. This page keeps one graph fixed, compares several Leiden resolutions with a Paris hierarchy, and reads every result through its exact immutable artifact ref.

1. Open one graph#

from dataclasses import asdict
from itertools import combinations

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score

import scarf

scarf.configure_output(level="WARNING", progress=False)

dataset = scarf.cytebase.connect("scarf_docs").download_dataset(
    "tenx_5K_pbmc_rnaseq",
    destination="scarf_datasets",
    zarr=True,
)
ds = scarf.DataStore(f"{dataset}/data.zarr", nthreads=4)
clustering_run = ds.pipeline.open(label="docs_default")
graph = clustering_run["connectivity_map"]
umap = clustering_run["umap"]
umap_values = np.asarray(ds.load_artifact(umap)["values"][:])

The rebuilt store carries one completed pipeline run under the immutable label docs_default. Its exact graph and UMAP refs are the baseline below. Only the additional clustering choices are created on this page, so feature, PCA, graph, and layout effects stay out of the comparison.

2. Sweep Leiden resolution#

leiden_refs = {
    0.3: ds.run_leiden_clustering(graph, resolution=0.3),
    0.5: clustering_run["leiden_0.5"],
    0.8: ds.run_leiden_clustering(graph, resolution=0.8),
}
leiden_values = {
    resolution: np.asarray(ds.load_artifact(ref)["values"][:])
    for resolution, ref in leiden_refs.items()
}

pd.DataFrame(
    {
        resolution: pd.Series(values).value_counts()
        for resolution, values in leiden_values.items()
    }
).fillna(0).astype(int)
0.3 0.5 0.8
1 716 716 336
2 239 25 377
3 1310 239 28
4 927 1241 239
5 318 434 560
6 142 319 707
7 256 552 289
8 25 151 29
9 15 256 236
10 0 15 309
11 0 0 152
12 0 0 236
13 0 0 20
14 0 0 415
15 0 0 15
figure, axes = plt.subplots(1, 3, figsize=(12, 4))
for axis, resolution in zip(axes, leiden_values, strict=True):
    axis.scatter(
        umap_values[:, 0],
        umap_values[:, 1],
        c=leiden_values[resolution],
        s=3,
        cmap="tab20",
    )
    axis.set_title(f"Leiden {resolution}")
figure.tight_layout()
figure
../_images/b8fda7a0b002eea691f830c76725c06696c9c289b8b33b3171c15e7a8f9b47fd.png ../_images/b8fda7a0b002eea691f830c76725c06696c9c289b8b33b3171c15e7a8f9b47fd.png

Higher resolution usually produces more and smaller groups. Reject a split when it is driven by a technical covariate, has weak marker evidence, or disappears under a modest parameter change.

ARI and NMI quantify agreement without selecting a winner:

agreement = []
for first, second in combinations(leiden_values, 2):
    agreement.append(
        {
            "comparison": f"{first} vs {second}",
            "ARI": adjusted_rand_score(
                leiden_values[first],
                leiden_values[second],
            ),
            "NMI": normalized_mutual_info_score(
                leiden_values[first],
                leiden_values[second],
            ),
        }
    )
pd.DataFrame(agreement)
comparison ARI NMI
0 0.3 vs 0.5 0.870890 0.919991
1 0.3 vs 0.8 0.569073 0.807202
2 0.5 vs 0.8 0.652480 0.848899

3. Inspect membership strength#

Use the chosen cluster ref directly. The result is another axis-aligned artifact, not a metadata column. Resolution 0.5 is fixed here for the diagnostic walkthrough.

chosen = leiden_refs[0.5]
chosen_values = leiden_values[0.5]
membership = ds.calc_membership_strength(chosen, graph)
membership_values = np.asarray(ds.load_artifact(membership)["values"][:])

figure, axis = plt.subplots(figsize=(5, 4))
points = axis.scatter(
    umap_values[:, 0],
    umap_values[:, 1],
    c=membership_values,
    s=3,
)
figure.colorbar(points, ax=axis, label="membership strength")
figure.tight_layout()
figure
../_images/cb0c983ab797b625f03543882d97ca418932961b5bc2f816631408766bd1cb36.png ../_images/cb0c983ab797b625f03543882d97ca418932961b5bc2f816631408766bd1cb36.png
ds.plots.cluster_connectivity(
    graph=graph,
    groups=chosen,
    layout=umap,
)
../_images/b4a57cd8907a063b698b605b8f87be74003ce467d6ba7bfec67edbafdcb9b76c.png

Low values throughout one cluster suggest a weak boundary. A narrow band of low values between otherwise coherent groups may represent continuous biology.

4. Compare Paris cuts#

run_paris_clustering returns a cluster_cut ref. Load the domain result explicitly when hierarchy diagnostics are needed.

paris_auto = ds.run_paris_clustering(graph)
paris_result = ds.load_paris_clustering(paris_auto)
pd.DataFrame([asdict(item) for item in paris_result.diagnostics])[
    ["label", "size", "persistence", "decision_margin", "forced"]
]
label size persistence decision_margin forced
0 1 381 87.005282 0.106567 False
1 2 332 75.167770 0.066890 False
2 3 228 144.527046 0.560474 False
3 4 1339 430.260234 0.165564 False
4 5 284 62.717847 NaN False
5 6 332 114.578882 0.171068 False
6 7 143 105.512744 0.649577 False
7 8 267 53.963308 NaN False
8 9 570 109.741983 NaN False
9 10 28 21.267540 NaN False
10 11 15 NaN NaN True
11 12 29 19.083022 0.645307 False
ds.plots.cluster_tree(graph=graph, clusters=paris_auto)
../_images/b6c1d0a8242cbc44c79869c0443278fdfabd8c8df38bee412b126c628123a2ca.png

Persistence measures how long a selected branch survives in the hierarchy. The decision margin measures the preference for retaining it. A forced group satisfies a structural constraint and is not, by itself, strong biological evidence.

paris_fixed = ds.run_paris_clustering(
    graph,
    n_clusters=paris_result.n_clusters,
)
paris_auto_values = np.asarray(ds.load_artifact(paris_auto)["labels"][:])
paris_fixed_values = np.asarray(ds.load_artifact(paris_fixed)["labels"][:])
pd.Series(
    {
        "auto vs fixed ARI": adjusted_rand_score(
            paris_auto_values,
            paris_fixed_values,
        ),
        "Leiden vs Paris ARI": adjusted_rand_score(
            chosen_values,
            paris_auto_values,
        ),
    }
)
auto vs fixed ARI      0.943232
Leiden vs Paris ARI    0.791518
dtype: float64

5. Review marker evidence#

Marker search requires exact cluster and feature-selection refs and returns one immutable marker table artifact.

markers = ds.run_marker_search(
    chosen,
    features=clustering_run["feature_universe"],
)
sizes = pd.Series(chosen_values).value_counts()
largest = sizes.index[0]
smallest = sizes.index[-1]

largest_markers = ds.get_markers(marker=markers, group_id=largest)
smallest_markers = ds.get_markers(marker=markers, group_id=smallest)
largest_markers[
    ["feature_name", "score", "auc", "p_value", "p_value_adjusted"]
].head(10)
feature_name score auc p_value p_value_adjusted
0 ADTRP 0.61890 0.60920 3.580369e-108 6.286828e-106
1 FHIT 0.51857 0.77473 3.305650e-282 3.695496e-279
2 TSHZ2 0.48540 0.63679 6.612532e-118 1.327971e-115
3 TCEA3 0.48253 0.59006 2.671241e-75 2.509470e-73
4 CHRM3-AS2 0.46413 0.66321 7.806746e-146 2.218836e-143
5 CMTM8 0.43859 0.61914 1.037609e-85 1.298483e-83
6 EPHX2 0.43070 0.66277 6.659238e-138 1.731299e-135
7 MAN1C1 0.42497 0.62275 2.670897e-85 3.293256e-83
8 NOG 0.42190 0.61849 1.471531e-118 2.973024e-116
9 LRRN3 0.39995 0.73614 9.865691e-246 7.519899e-243
smallest_markers[
    ["feature_name", "score", "auc", "p_value", "p_value_adjusted"]
].head(10)
feature_name score auc p_value p_value_adjusted
0 TPM2 0.83726 0.96477 7.923809e-68 2.438062e-65
1 SERPINF1 0.82985 0.99685 1.200639e-123 6.711172e-121
2 MAP1A 0.81256 0.96035 2.330331e-109 1.070611e-106
3 LILRA4 0.81141 0.99976 2.403755e-215 2.303346e-212
4 IL3RA 0.80536 1.00000 9.536887e-170 7.269275e-167
5 SMPD3 0.79616 0.99775 2.867716e-210 2.671596e-207
6 DNASE1L3 0.79286 0.99868 6.656585e-193 5.874962e-190
7 GAS6 0.78312 0.99888 3.028428e-271 5.078371e-268
8 DERL3 0.77951 0.96440 1.351287e-130 8.092761e-128
9 FCER1A 0.76586 0.82674 6.683680e-63 1.949194e-60

The p-values are cell-level one-versus-rest marker tests with within-group adjustment. They are not replicate-aware differential expression. A defensible partition combines marker evidence, graph support, technical covariates, replicate coverage, and the study question.

6. Pipeline cluster selection#

When a pipeline run includes multiple Leiden candidates, it scores them with one deterministic shared sample of at most 10,000 cells in the graph’s PCA or Harmony coordinates. Paris can still run as clustering_run["paris"], but it is not an automatic winner. The cluster_selection artifact persists the scores, sampling policy, invalid-candidate reasons, tie order, and selected key:

decision_ref = clustering_run["cluster_selection"]
selected_cluster_ref = clustering_run["clusters"]

This automatic choice is a reproducible baseline, not proof that the selected resolution is best for every biological question. Retain alternative refs when the decision needs domain-specific evidence.