4  DBSCAN exercises with PBMC3k

4.1 The dataset

These exercises use PBMC3k, a collection of 2,638 peripheral blood mononuclear cells from a healthy donor. The dataset was produced with the 10x Genomics platform and later processed as part of a widely used Scanpy teaching workflow.

The downloadable file contains the following information.

  • Ten principal component scores named pca1 through pca10
  • Two UMAP coordinates named umap1 and umap2
  • A reference cell-type annotation for every cell
  • Three quality-control measurements. They are the number of detected genes, the total UMI count, and the fraction of counts from mitochondrial genes. Despite its historical name, percent_mito is stored as a fraction, so 0.02 means 2 percent

DBSCAN will use the ten principal component scores. UMAP will only be used to draw the results.

ImportantWhat the reference labels mean

The cell_type labels are useful reference annotations, not independently measured ground truth. They were derived from Louvain communities in the processed PBMC3k dataset, then given cell-type names after marker-gene inspection. DBSCAN does not receive these labels. We compare with them only after fitting the model.

NoteWhy we start with PCA scores

Calculating a meaningful PCA from the original expression counts would require a complete single-cell preprocessing workflow. That includes quality control, normalization, selection of variable genes, scaling, and decisions about technical effects. Repeating those steps in both R and Python would shift the focus away from DBSCAN and could give the two languages different inputs.

The supplied scores come from the PCA stored with the processed PBMC3k dataset. We use the first ten components as a fixed, compact representation. Ten is not a universal choice for single-cell data, and changing that number would change the distances. Here it is fixed before the reference annotations are examined.

4.2 Loading the data

NoteDownload the exercise data

Place the CSV inside a folder named data beside your script or notebook.

library(dbscan)

pbmc <- read.csv("data/pbmc3k.csv")
dim(pbmc)
table(pbmc$cell_type)
[1] 2638   17

          B cells   CD14+ Monocytes       CD4 T cells       CD8 T cells
              342               480              1144               316
  Dendritic cells FCGR3A+ Monocytes    Megakaryocytes          NK cells
               37               150                15               154
import pandas as pd
from sklearn.cluster import DBSCAN

pbmc = pd.read_csv("data/pbmc3k.csv")
print(pbmc.shape)
print(pbmc["cell_type"].value_counts())
(2638, 17)
cell_type
CD4 T cells          1144
CD14+ Monocytes       480
B cells               342
CD8 T cells           316
NK cells               154
FCGR3A+ Monocytes     150
Dendritic cells         37
Megakaryocytes          15
Name: count, dtype: int64

4.3 Exercise 1. Explore the UMAP display

Task. Plot the UMAP coordinates and colour each cell by its reference annotation. Which annotations occupy distinct parts of the display? Which ones overlap or meet along gradual transitions?

Remember what this plot can and cannot tell you. It is a two-dimensional summary of the data. It provides context for the later results, but DBSCAN will not use these coordinates.

TipTip

A scatter plot of umap1 against umap2 is enough. Use a different colour for each value of cell_type. Look closely at the T-cell and NK-cell region, and then at the two monocyte annotations.

cell_type <- factor(pbmc$cell_type)
plot(pbmc$umap1, pbmc$umap2,
     col = cell_type, pch = 19, cex = 0.5,
     xlab = "UMAP1", ylab = "UMAP2")
legend("topright", legend = levels(cell_type),
       col = seq_along(levels(cell_type)), pch = 19, cex = 0.6)
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(7, 6))
for cell_type, cells in pbmc.groupby("cell_type"):
    ax.scatter(cells["umap1"], cells["umap2"],
               s=8, label=cell_type, alpha=0.8)
ax.set_xlabel("UMAP1")
ax.set_ylabel("UMAP2")
ax.legend(markerscale=2, fontsize=8, bbox_to_anchor=(1.0, 1.0))
A UMAP scatter plot with eight reference annotations. B cells and Megakaryocytes occupy compact islands. CD14 positive and FCGR3A positive Monocytes occupy neighbouring regions. CD4 T cells, CD8 T cells, and NK cells share a broader region with gradual transitions.
Figure 4.1: The UMAP display of all 2,638 cells, coloured by their reference annotations.

B cells and Megakaryocytes occupy compact islands in this display. The two monocyte annotations lie beside one another. CD4 T cells, CD8 T cells, and NK cells share a broader region and meet without large empty gaps.

4.4 Exercise 2. Choose an initial eps

Task. Build a matrix from pca1 through pca10. Use MinPts = 10 and make a sorted k-distance plot. Because the point itself counts toward MinPts, use the distance to the ninth nearest other cell. What range of eps values looks reasonable for an initial fit?

Do not use the reference annotations to choose the radius. The aim is to inspect the geometry available to DBSCAN before looking at agreement with another partition.

MinPts = 10 is also a starting choice. It asks for more local support than the two-dimensional examples earlier in the course, but it is not determined automatically by the number of components. Exercise 6 will vary it.

TipTip

The PCA scores should not be standardized again. Their different variances are part of the PCA representation. Giving every component unit variance would give the later, weaker components as much influence as the first component and would define a different distance.

The curve in this dataset bends gradually. Look for a plausible interval rather than one exact answer.

pc_cols <- paste0("pca", 1:10)
X <- as.matrix(pbmc[, pc_cols])

kdist <- dbscan::kNNdist(X, k = 9)
kdist_sorted <- sort(kdist)
round(quantile(kdist, c(0.70, 0.80, 0.90)), 4)

plot(kdist_sorted, type = "l",
     xlab = "Cells, sorted by distance",
     ylab = "Distance to the 9th nearest other cell")
abline(h = 3.8, lty = 2)
   70%    80%    90%
3.7750 4.2341 4.9700
import numpy as np
from sklearn.neighbors import NearestNeighbors

pc_cols = [f"pca{i}" for i in range(1, 11)]
X = pbmc[pc_cols].to_numpy()

nn = NearestNeighbors(n_neighbors=10).fit(X)
dist, _ = nn.kneighbors(X)
kdist_sorted = np.sort(dist[:, 9])
print(np.round(np.quantile(kdist_sorted, [0.70, 0.80, 0.90]), 4))

fig, ax = plt.subplots(figsize=(7, 5))
ax.plot(np.arange(1, len(kdist_sorted) + 1), kdist_sorted)
ax.axhline(3.8, linestyle="--")
ax.set_xlabel("Cells, sorted by distance")
ax.set_ylabel("Distance to the 9th nearest other cell")
[3.775  4.2341 4.97  ]
A sorted k-distance curve calculated from ten principal components. It rises gradually through most of the cells and then more sharply in the upper tail. A dashed horizontal line at eps 3.8 marks one reasonable starting value near the lower part of the bend.
Figure 4.2: The sorted distance from every cell to its ninth nearest other cell in the ten-dimensional PCA representation.

There is no sharp corner in Figure 4.2. Around 70 percent of the cells have their ninth other neighbour within a distance of 3.775. That distance rises to 4.234 at 80 percent and 4.970 at 90 percent. The curve supports a range of starting values rather than one automatic answer.

We will begin with eps = 3.8, close to the 70th percentile. This deliberately conservative choice leaves much of the sparse tail outside core neighbourhoods. It is not an estimate of a uniquely correct radius. Exercise 6 tests nearby values.

4.5 Exercise 3. Fit DBSCAN to the PCA scores

Task. Fit DBSCAN to X with eps = 3.8 and MinPts = 10. Report the number of clusters and noise cells. Then classify every cell as core, border, or noise. Display the cluster assignments on the UMAP coordinates.

TipTip

The fit uses X, which contains ten PCA columns. The scatter plot uses umap1 and umap2. Keep separate objects for those two jobs so that the plotting coordinates cannot accidentally become the clustering input.

res <- dbscan::dbscan(X, eps = 3.8, minPts = 10)
n_clusters <- length(setdiff(unique(res$cluster), 0))

core <- dbscan::is.corepoint(X, eps = 3.8, minPts = 10)
kind <- ifelse(res$cluster == 0, "noise",
               ifelse(core, "core", "border"))

cat("clusters:", n_clusters,
    " noise:", sum(res$cluster == 0), "\n")
table(kind)
clusters: 4  noise: 391

kind
border   core  noise
   375   1872    391
db = DBSCAN(eps=3.8, min_samples=10).fit(X)
n_clusters = len(set(db.labels_)) - (1 if -1 in db.labels_ else 0)

core = np.zeros(len(X), dtype=bool)
core[db.core_sample_indices_] = True
noise = db.labels_ == -1
border = (~core) & (~noise)
kind = np.where(noise, "noise", np.where(core, "core", "border"))

print("clusters:", n_clusters, " noise:", noise.sum())
print(pd.Series(kind).value_counts().reindex(["core", "border", "noise"]))
clusters: 4  noise: 391
core      1872
border     375
noise      391
Name: count, dtype: int64
Two UMAP scatter plots display a DBSCAN result calculated from ten principal components. The left plot colours the four clusters and marks noise in grey. The right plot distinguishes 1,872 core cells, 375 border cells, and 391 noise cells.
Figure 4.3: The four DBSCAN clusters and the core, border, and noise classifications, displayed on UMAP.

The fit finds four clusters and assigns 2,247 of the 2,638 cells to one of them. The remaining 391 cells, about 15 percent of the dataset, are noise under these settings. Most assigned cells are core points. Another 375 are border points, which means they are within eps of a core point but do not meet MinPts themselves.

The UMAP coordinates in Figure 4.3 are only used to display these PCA-space labels.

4.6 Exercise 4. Compare with the reference labels

Task. Cross-tabulate the DBSCAN labels against cell_type and calculate the adjusted Rand index. Also report the percentage of cells assigned to a cluster and the ARI after excluding noise. Which broad populations did DBSCAN recover? Which annotations were combined or left mostly as noise?

TipTip

The full-data ARI treats every DBSCAN noise point as having the same predicted label. The assigned-only ARI removes those cells before comparison. Report both because they answer different questions. The assigned-only value describes the partition among retained cells, but it can look better simply because difficult cells were excluded.

ari <- function(a, b) {
  tab <- table(a, b)
  n <- sum(tab)
  sum_comb <- function(v) sum(choose(v, 2))
  index <- sum_comb(tab)
  expected <- sum_comb(rowSums(tab)) * sum_comb(colSums(tab)) / choose(n, 2)
  maximum <- (sum_comb(rowSums(tab)) + sum_comb(colSums(tab))) / 2
  (index - expected) / (maximum - expected)
}

comparison <- table(DBSCAN = res$cluster,
                    reference = pbmc$cell_type)
comparison

assigned <- res$cluster != 0
cat("assigned:", round(100 * mean(assigned), 2), "%\n")
cat("ARI, all cells:", round(ari(res$cluster, pbmc$cell_type), 4), "\n")
cat("ARI, assigned cells:",
    round(ari(res$cluster[assigned], pbmc$cell_type[assigned]), 4), "\n")
assigned: 85.18 %
ARI, all cells: 0.6812
ARI, assigned cells: 0.729
from sklearn.metrics import adjusted_rand_score

comparison = pd.crosstab(db.labels_, pbmc["cell_type"])
print(comparison)

assigned = db.labels_ != -1
print("assigned:", round(100 * assigned.mean(), 2), "%")
print("ARI, all cells:",
      round(adjusted_rand_score(pbmc["cell_type"], db.labels_), 4))
print("ARI, assigned cells:",
      round(adjusted_rand_score(pbmc.loc[assigned, "cell_type"],
                                db.labels_[assigned]), 4))
assigned: 85.18 %
ARI, all cells: 0.6812
ARI, assigned cells: 0.729
Table 4.1: Nonzero entries in the DBSCAN by reference cross-tabulation
DBSCAN result Nonzero reference counts
Noise 36 B, 36 CD14+ Monocytes, 30 CD4 T, 72 CD8 T, 34 Dendritic, 25 FCGR3A+ Monocytes, 15 Megakaryocytes, 143 NK
T-cell cluster 1,114 CD4 T, 244 CD8 T
Monocyte cluster 444 CD14+ Monocytes, 125 FCGR3A+ Monocytes, 3 Dendritic
B-cell cluster 306 B
Small NK cluster 11 NK
Two UMAP plots show the same cells. The left displays four DBSCAN clusters calculated from ten principal components, with noise in grey. The right displays eight reference annotations. DBSCAN recovers broad T-cell, monocyte, and B-cell groups, while many NK, Dendritic, and Megakaryocyte cells are noise.
Figure 4.4: DBSCAN clusters calculated from ten principal components beside the reference annotations, both displayed on UMAP.

Three of the four DBSCAN clusters correspond to broad, recognizable groups. One contains 1,114 CD4 T cells and 244 CD8 T cells. A second consists mainly of 444 CD14+ Monocytes and 125 FCGR3A+ Monocytes, along with 3 Dendritic cells. A third contains 306 B cells. The fourth cluster contains only 11 NK cells. The remaining 143 NK cells are noise.

This explains the difference between the two ARI values. Among assigned cells, the broad groups produce an ARI of 0.729 against the reference partition. The method combines related annotations within the T-cell and monocyte groups, and it leaves many cells from smaller or less dense annotations unassigned. The value is a descriptive comparison, not evidence that either partition is the biological truth.

The baseline settings were chosen before this comparison and were not revised afterward.

4.7 Exercise 5. Who was labelled as noise?

Task. Calculate the number and percentage of noise cells within each reference annotation. Then compare n_genes, n_counts, and percent_mito between assigned and noise cells. Use boxplots and a Mann-Whitney test. Does DBSCAN noise simply identify low-quality cells?

TipTip

Keep the interpretation narrow. A DBSCAN noise label means that a cell was not density-connected to a core region under these settings. Quality-control measurements can help describe those cells, but they cannot tell us why every cell received its label.

is_noise <- res$cluster == 0
noise_tab <- table(pbmc$cell_type, is_noise)
noise_by_type <- data.frame(
  cell_type = rownames(noise_tab),
  cells = rowSums(noise_tab),
  noise = noise_tab[, "TRUE"],
  noise_percent = round(100 * noise_tab[, "TRUE"] / rowSums(noise_tab), 1)
)
noise_by_type

aggregate(cbind(n_genes, n_counts, percent_mito) ~ is_noise,
          data = data.frame(pbmc, is_noise = is_noise), median)

par(mfrow = c(1, 3))
for (column in c("n_genes", "n_counts", "percent_mito")) {
  boxplot(pbmc[[column]] ~ is_noise,
          names = c("assigned", "noise"), main = column)
}

for (column in c("n_genes", "n_counts", "percent_mito")) {
  test <- wilcox.test(pbmc[[column]][is_noise],
                      pbmc[[column]][!is_noise], exact = FALSE)
  cat(sprintf("%s p = %.4g\n", column, test$p.value))
}
  is_noise n_genes n_counts percent_mito
1    FALSE     807     2228   0.02003643
2     TRUE     921     2136   0.02048705

n_genes p = 5.274e-26
n_counts p = 0.246
percent_mito p = 0.3245
from scipy import stats

pbmc["is_noise"] = noise
noise_by_type = (
    pbmc.groupby("cell_type")["is_noise"]
        .agg(cells="size", noise="sum", noise_percent="mean")
)
noise_by_type["noise_percent"] *= 100
print(noise_by_type.round(1))

print(pbmc.groupby("is_noise")
          [["n_genes", "n_counts", "percent_mito"]].median())

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, column in zip(axes, ["n_genes", "n_counts", "percent_mito"]):
    assigned_values = pbmc.loc[~pbmc.is_noise, column]
    noise_values = pbmc.loc[pbmc.is_noise, column]
    ax.boxplot([assigned_values, noise_values],
               tick_labels=["assigned", "noise"], showfliers=False)
    ax.set_title(column)

for column in ["n_genes", "n_counts", "percent_mito"]:
    a = pbmc.loc[pbmc.is_noise, column]
    b = pbmc.loc[~pbmc.is_noise, column]
    _, p = stats.mannwhitneyu(a, b, alternative="two-sided")
    print(f"{column} p = {p:.4g}")
          n_genes  n_counts  percent_mito
is_noise
False         807      2228      0.020036
True          921      2136      0.020487

n_genes p = 5.274e-26
n_counts p = 0.246
percent_mito p = 0.3245
Table 4.2: Noise cells within each reference annotation
Reference annotation Cells Noise Noise percent
B cells 342 36 10.5
CD14+ Monocytes 480 36 7.5
CD4 T cells 1,144 30 2.6
CD8 T cells 316 72 22.8
Dendritic cells 37 34 91.9
FCGR3A+ Monocytes 150 25 16.7
Megakaryocytes 15 15 100.0
NK cells 154 143 92.9
Three boxplots compare assigned and noise cells. Noise cells have a higher median number of detected genes. Total UMI counts and mitochondrial fractions have similar medians in the two groups.
Figure 4.5: Quality-control measurements for cells assigned to a DBSCAN cluster and cells labelled as noise.

Noise is concentrated in a few reference populations. Every Megakaryocyte and almost every NK or Dendritic cell is noise, while only 2.6 percent of CD4 T cells are. Under this fit, rare or diffuse groups are often unassigned.

The quality-control results do not support the simple claim that noise cells are poor-quality cells. Noise cells have more detected genes at the median. Their total UMI counts and mitochondrial fractions are not detectably different in these comparisons. The dataset had already passed the original workflow’s quality filters, so this comparison only describes variation among retained cells. It cannot show how DBSCAN would behave on unfiltered droplets.

These measurements also do not justify calling the noise cells doublets. That would require dedicated evidence such as a doublet score or marker pattern. In this fit, DBSCAN noise is not simply a low-quality-cell category.

4.8 Exercise 6. Test nearby parameter settings

Task. Sweep eps over {3.4, 3.6, 3.8, 4.0, 4.2} and MinPts over {5, 10, 15, 20, 25}. Record the number of clusters, the number of noise cells, and the ARI against the reference annotations. Look for regions where these summaries are similar. Do not select a setting only because it has the largest ARI.

TipTip

Check all three summaries together. Two settings can produce the same number of clusters while assigning different cells or leaving very different numbers of cells as noise. This grid measures parameter sensitivity, not complete membership stability.

grid <- expand.grid(
  eps = c(3.4, 3.6, 3.8, 4.0, 4.2),
  minPts = c(5, 10, 15, 20, 25)
)
grid$clusters <- NA_integer_
grid$noise <- NA_integer_
grid$ari <- NA_real_

for (i in seq_len(nrow(grid))) {
  fit <- dbscan::dbscan(X, eps = grid$eps[i], minPts = grid$minPts[i])
  grid$clusters[i] <- length(setdiff(unique(fit$cluster), 0))
  grid$noise[i] <- sum(fit$cluster == 0)
  grid$ari[i] <- ari(fit$cluster, pbmc$cell_type)
}

cat("Clusters\n")
xtabs(clusters ~ minPts + eps, grid)
cat("Noise cells\n")
xtabs(noise ~ minPts + eps, grid)
cat("ARI\n")
round(xtabs(ari ~ minPts + eps, grid), 3)
Clusters
      eps
minPts 3.4 3.6 3.8 4.0 4.2
    5    7   7   5   5   3
    10   4   3   4   3   3
    15   5   4   3   3   2
    20   4   4   4   3   2
    25   3   4   3   4   3

Noise cells
      eps
minPts 3.4 3.6 3.8 4.0 4.2
    5  486 379 300 230 182
    10 621 481 391 313 259
    15 745 577 454 373 318
    20 832 684 508 406 342
    25 924 739 560 455 369

ARI
      eps
minPts   3.4   3.6   3.8   4.0   4.2
    5  0.463 0.467 0.437 0.413 0.399
    10 0.641 0.666 0.681 0.470 0.435
    15 0.621 0.662 0.680 0.687 0.467
    20 0.601 0.656 0.691 0.691 0.473
    25 0.574 0.639 0.677 0.707 0.694
rows = []
for eps in [3.4, 3.6, 3.8, 4.0, 4.2]:
    for min_samples in [5, 10, 15, 20, 25]:
        fit = DBSCAN(eps=eps, min_samples=min_samples).fit(X)
        labels = fit.labels_
        rows.append({
            "eps": eps,
            "MinPts": min_samples,
            "clusters": len(set(labels)) - (1 if -1 in labels else 0),
            "noise": int((labels == -1).sum()),
            "ARI": adjusted_rand_score(pbmc["cell_type"], labels),
        })

grid = pd.DataFrame(rows)
for value in ["clusters", "noise", "ARI"]:
    print(value)
    print(grid.pivot(index="MinPts", columns="eps", values=value).round(3))
clusters
eps     3.4  3.6  3.8  4.0  4.2
MinPts
5         7    7    5    5    3
10        4    3    4    3    3
15        5    4    3    3    2
20        4    4    4    3    2
25        3    4    3    4    3

noise
eps     3.4  3.6  3.8  4.0  4.2
MinPts
5       486  379  300  230  182
10      621  481  391  313  259
15      745  577  454  373  318
20      832  684  508  406  342
25      924  739  560  455  369

ARI
eps       3.4    3.6    3.8    4.0    4.2
MinPts
5       0.463  0.467  0.437  0.413  0.399
10      0.641  0.666  0.681  0.470  0.435
15      0.621  0.662  0.680  0.687  0.467
20      0.601  0.656  0.691  0.691  0.473
25      0.574  0.639  0.677  0.707  0.694
Three heatmaps show results across five eps values and five MinPts values. Noise falls whenever eps increases and rises whenever MinPts increases. Cluster counts vary between two and seven. ARI has a broad band of moderately high values rather than one isolated optimum.
Figure 4.6: Cluster count, noise count, and ARI across the PCA-space DBSCAN parameter grid.

Noise follows a clear pattern. It decreases as eps increases and rises as MinPts increases. Cluster count is less regular and ranges from two to seven. Several settings with eps between 3.6 and 4.0 and MinPts of at least 10 give ARI values from about 0.64 to 0.71. The maximum ARI of 0.707 leaves 455 cells as noise, so it is not an obvious winner. The baseline lies within this broader range and leaves 391 cells as noise.

Because this grid reports ARI against Louvain-derived annotations, it is a descriptive comparison rather than an unsupervised selection rule or an unbiased performance estimate. Similar summary values also do not prove that the same individual cells stay together. Every result remains conditional on the fixed ten-component representation.