335 lines
9.7 KiB
Markdown
335 lines
9.7 KiB
Markdown
---
|
|
title: "Clustering Methods for Single-Cell Data"
|
|
task: ""
|
|
lineage_type: import
|
|
upstream_source: https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-single-cell/references/clustering_guide.md
|
|
upstream_sha: e2520a96
|
|
imported_at: 2026-06-26
|
|
prompt_class: prompt
|
|
upstream_changes: accepted
|
|
author: upstream
|
|
validated: false
|
|
---
|
|
|
|
# Clustering Methods for Single-Cell Data
|
|
|
|
Guide to different clustering methods: Leiden, Louvain, hierarchical, and bootstrap consensus clustering.
|
|
|
|
---
|
|
|
|
## Leiden Clustering (Recommended)
|
|
|
|
Best all-around method for single-cell data.
|
|
|
|
```python
|
|
import scanpy as sc
|
|
|
|
# Build neighbor graph
|
|
sc.pp.neighbors(adata, n_neighbors=15, n_pcs=30)
|
|
|
|
# Run Leiden
|
|
sc.tl.leiden(adata, resolution=0.5, random_state=0)
|
|
|
|
print(f"Clusters: {adata.obs['leiden'].nunique()}")
|
|
```
|
|
|
|
**Resolution parameter**:
|
|
- Higher = More clusters
|
|
- Typical range: 0.3 - 1.5
|
|
- Start with 0.5, adjust based on biology
|
|
|
|
---
|
|
|
|
## Louvain Clustering
|
|
|
|
Alternative to Leiden (older algorithm).
|
|
|
|
```python
|
|
sc.tl.louvain(adata, resolution=0.5, random_state=0)
|
|
```
|
|
|
|
**Leiden vs Louvain**:
|
|
- Leiden: Better optimization, guaranteed connected communities
|
|
- Louvain: Faster, may produce disconnected communities
|
|
- For publication: Use Leiden
|
|
|
|
---
|
|
|
|
## Hierarchical Clustering
|
|
|
|
For expression matrices (not single-cell level).
|
|
|
|
```python
|
|
from scipy.cluster.hierarchy import linkage, fcluster, dendrogram
|
|
from scipy.spatial.distance import pdist
|
|
import numpy as np
|
|
|
|
def hierarchical_clustering(expression_df, n_clusters=3, method='ward', metric='euclidean'):
|
|
"""Hierarchical clustering on expression matrix.
|
|
|
|
Args:
|
|
expression_df: DataFrame (samples as rows, genes as columns)
|
|
n_clusters: Number of clusters
|
|
method: 'ward', 'complete', 'average', 'single'
|
|
metric: Distance metric
|
|
|
|
Returns:
|
|
dict with labels, linkage_matrix
|
|
"""
|
|
# Compute linkage
|
|
if method == 'ward':
|
|
Z = linkage(expression_df.values, method='ward')
|
|
else:
|
|
dist = pdist(expression_df.values, metric=metric)
|
|
Z = linkage(dist, method=method)
|
|
|
|
# Cut tree
|
|
labels = fcluster(Z, t=n_clusters, criterion='maxclust')
|
|
|
|
# Cluster sizes
|
|
unique, counts = np.unique(labels, return_counts=True)
|
|
for c, n in zip(unique, counts):
|
|
print(f" Cluster {c}: {n} samples")
|
|
|
|
return {
|
|
'labels': labels,
|
|
'linkage_matrix': Z,
|
|
'n_clusters': n_clusters
|
|
}
|
|
|
|
# Example
|
|
result = hierarchical_clustering(expr_df, n_clusters=3, method='ward')
|
|
```
|
|
|
|
**Linkage methods**:
|
|
- `ward`: Minimizes within-cluster variance (best for most data)
|
|
- `complete`: Maximum distance between clusters (compact clusters)
|
|
- `average`: Average distance (balanced)
|
|
- `single`: Minimum distance (can create chains)
|
|
|
|
---
|
|
|
|
## Bootstrap Consensus Clustering
|
|
|
|
Robust clustering with logistic regression prediction.
|
|
|
|
```python
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
def bootstrap_consensus_clustering(expression_df, n_clusters=3, n_iterations=50,
|
|
train_fraction=0.7, random_state=42):
|
|
"""Bootstrap consensus clustering.
|
|
|
|
Args:
|
|
expression_df: DataFrame (samples as rows, genes as columns)
|
|
n_clusters: Number of clusters
|
|
n_iterations: Bootstrap iterations
|
|
train_fraction: Fraction for training
|
|
random_state: Random seed
|
|
|
|
Returns:
|
|
dict with labels, consensus_matrix, consistent_count
|
|
"""
|
|
np.random.seed(random_state)
|
|
n_samples = len(expression_df)
|
|
n_train = int(n_samples * train_fraction)
|
|
|
|
# Consensus matrices
|
|
train_consensus = np.zeros((n_samples, n_samples))
|
|
test_consensus = np.zeros((n_samples, n_samples))
|
|
train_count = np.zeros((n_samples, n_samples))
|
|
test_count = np.zeros((n_samples, n_samples))
|
|
|
|
for i in range(n_iterations):
|
|
# Random split
|
|
indices = np.random.permutation(n_samples)
|
|
train_idx = indices[:n_train]
|
|
test_idx = indices[n_train:]
|
|
|
|
# Cluster training data
|
|
train_data = expression_df.iloc[train_idx]
|
|
Z = linkage(train_data.values, method='ward')
|
|
train_labels = fcluster(Z, t=n_clusters, criterion='maxclust')
|
|
|
|
# Update train consensus
|
|
for a_i, a in enumerate(train_idx):
|
|
for b_i, b in enumerate(train_idx):
|
|
train_count[a, b] += 1
|
|
if train_labels[a_i] == train_labels[b_i]:
|
|
train_consensus[a, b] += 1
|
|
|
|
# Predict test labels
|
|
scaler = StandardScaler()
|
|
X_train = scaler.fit_transform(train_data.values)
|
|
X_test = scaler.transform(expression_df.iloc[test_idx].values)
|
|
|
|
lr = LogisticRegression(max_iter=1000, random_state=random_state)
|
|
lr.fit(X_train, train_labels)
|
|
test_labels = lr.predict(X_test)
|
|
|
|
# Update test consensus
|
|
for a_i, a in enumerate(test_idx):
|
|
for b_i, b in enumerate(test_idx):
|
|
test_count[a, b] += 1
|
|
if test_labels[a_i] == test_labels[b_i]:
|
|
test_consensus[a, b] += 1
|
|
|
|
# Normalize
|
|
with np.errstate(divide='ignore', invalid='ignore'):
|
|
train_consensus_norm = np.where(train_count > 0, train_consensus / train_count, 0)
|
|
test_consensus_norm = np.where(test_count > 0, test_consensus / test_count, 0)
|
|
|
|
# Final clustering
|
|
combined_consensus = (train_consensus_norm + test_consensus_norm) / 2
|
|
np.fill_diagonal(combined_consensus, 1.0)
|
|
|
|
from scipy.spatial.distance import squareform
|
|
dist = 1 - combined_consensus
|
|
np.fill_diagonal(dist, 0)
|
|
dist = np.maximum(dist, 0)
|
|
dist = (dist + dist.T) / 2
|
|
condensed = squareform(dist)
|
|
Z_final = linkage(condensed, method='average')
|
|
final_labels = fcluster(Z_final, t=n_clusters, criterion='maxclust')
|
|
|
|
# Count consistent samples
|
|
consistent_count = 0
|
|
for s in range(n_samples):
|
|
cluster = final_labels[s]
|
|
same_cluster = np.where(final_labels == cluster)[0]
|
|
same_cluster = same_cluster[same_cluster != s]
|
|
|
|
if len(same_cluster) > 0:
|
|
train_scores = [train_consensus_norm[s, j] for j in same_cluster if train_count[s, j] > 0]
|
|
test_scores = [test_consensus_norm[s, j] for j in same_cluster if test_count[s, j] > 0]
|
|
|
|
if train_scores and test_scores:
|
|
if np.mean(train_scores) > 0.7 and np.mean(test_scores) > 0.7:
|
|
consistent_count += 1
|
|
|
|
print(f"Consistently classified: {consistent_count}/{n_samples}")
|
|
|
|
return {
|
|
'labels': final_labels,
|
|
'train_consensus': train_consensus_norm,
|
|
'test_consensus': test_consensus_norm,
|
|
'combined_consensus': combined_consensus,
|
|
'consistent_count': consistent_count
|
|
}
|
|
|
|
# Example
|
|
result = bootstrap_consensus_clustering(expr_df, n_clusters=3, n_iterations=50)
|
|
print(f"Answer: {result['consistent_count']} samples consistently classified")
|
|
```
|
|
|
|
---
|
|
|
|
## PCA for Clustering
|
|
|
|
Perform PCA on expression matrix.
|
|
|
|
```python
|
|
from sklearn.decomposition import PCA
|
|
import pandas as pd
|
|
import numpy as np
|
|
|
|
def manual_pca(expression_df, log_transform='log10', pseudocount=1):
|
|
"""Run PCA with specific transforms.
|
|
|
|
Args:
|
|
expression_df: DataFrame (samples as rows, genes as columns)
|
|
log_transform: 'log10', 'log2', 'log1p', or None
|
|
pseudocount: Pseudocount for log
|
|
|
|
Returns:
|
|
dict with variance_ratio, pc_coords, loadings
|
|
"""
|
|
X = expression_df.values.astype(float)
|
|
|
|
# Transform
|
|
if log_transform == 'log10':
|
|
X = np.log10(X + pseudocount)
|
|
elif log_transform == 'log2':
|
|
X = np.log2(X + pseudocount)
|
|
elif log_transform == 'log1p':
|
|
X = np.log1p(X)
|
|
|
|
# Run PCA
|
|
n_components = min(X.shape[0], X.shape[1])
|
|
pca = PCA(n_components=n_components)
|
|
pc_coords = pca.fit_transform(X)
|
|
|
|
# Results
|
|
result = {
|
|
'variance_ratio': pca.explained_variance_ratio_,
|
|
'variance_explained': pca.explained_variance_,
|
|
'pc_coords': pd.DataFrame(
|
|
pc_coords,
|
|
index=expression_df.index,
|
|
columns=[f'PC{i+1}' for i in range(n_components)]
|
|
),
|
|
'loadings': pd.DataFrame(
|
|
pca.components_.T,
|
|
index=expression_df.columns,
|
|
columns=[f'PC{i+1}' for i in range(n_components)]
|
|
),
|
|
'cumulative_variance': np.cumsum(pca.explained_variance_ratio_)
|
|
}
|
|
|
|
print(f"PC1: {result['variance_ratio'][0]*100:.2f}% variance")
|
|
print(f"PC1-10: {result['cumulative_variance'][9]*100:.2f}% variance")
|
|
|
|
return result
|
|
|
|
# Example
|
|
pca_result = manual_pca(expr_df, log_transform='log10', pseudocount=1)
|
|
```
|
|
|
|
---
|
|
|
|
## Choosing the Right Method
|
|
|
|
| Use Case | Method | When to Use |
|
|
|----------|--------|-------------|
|
|
| Single-cell clustering | **Leiden** | Default for scRNA-seq |
|
|
| Older pipeline compatibility | **Louvain** | If comparing to old analyses |
|
|
| Expression matrix clustering | **Hierarchical** | Bulk RNA-seq, <1000 samples |
|
|
| Robust clustering | **Bootstrap consensus** | Need confidence estimates |
|
|
| Dimensionality reduction | **PCA** | Variance analysis, visualization |
|
|
|
|
---
|
|
|
|
## Validation
|
|
|
|
### Silhouette Score
|
|
```python
|
|
from sklearn.metrics import silhouette_score
|
|
|
|
# After clustering
|
|
silhouette_avg = silhouette_score(X, labels)
|
|
print(f"Silhouette score: {silhouette_avg:.3f}")
|
|
# Range: -1 to 1. >0.5 is good.
|
|
```
|
|
|
|
### Cluster Stability
|
|
```python
|
|
# Run clustering multiple times with different random seeds
|
|
from collections import Counter
|
|
|
|
all_labels = []
|
|
for seed in range(10):
|
|
sc.tl.leiden(adata, resolution=0.5, random_state=seed, key_added=f'leiden_{seed}')
|
|
all_labels.append(adata.obs[f'leiden_{seed}'])
|
|
|
|
# Check consistency
|
|
# (Implementation depends on label alignment)
|
|
```
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- **scanpy_workflow.md** - Prepare data for clustering
|
|
- **marker_identification.md** - Annotate clusters after clustering
|