Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/nf-core/custom/clustermetrics/main.nf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ process CUSTOM_CLUSTERMETRICS {
touch ${prefix}.silhouette.png
touch ${prefix}.davies_bouldin.png
touch ${prefix}.calinski_harabasz.png
touch ${prefix}.k_distance.png

cat <<-END_VERSIONS > versions.yml
"${task.process}":
Expand Down
11 changes: 7 additions & 4 deletions modules/nf-core/custom/clustermetrics/meta.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
name: "CUSTOM_CLUSTERMETRICS"
description: "Computes clustering quality metrics (silhouette, Calinski-Harabasz,
Davies-Bouldin) and performs k-sweep analysis"
Davies-Bouldin), performs k-sweep and k-distance analysis"
keywords:
- clustering
- metrics
- silhouette
- calinski-harabasz
- davies-bouldin
- k-distance
- evaluation
tools:
- "scikit-learn":
Expand All @@ -25,9 +26,11 @@ input:
- features:
type: file
description: |
Tab-separated feature matrix with a `sample_id` column and one
column per numeric feature (e.g. PCA scores).
pattern: "*.tsv"
Sample-by-feature matrix: TSV with a `sample_id` column, or a
PLINK2 `.eigenvec` file (`#FID IID PC1 ...` or `#IID PC1 ...`).
Family/source ID columns are dropped; remaining columns are
numeric features (e.g. PCA scores).
pattern: "*.{tsv,txt,eigenvec}"
ontologies:
- edam: http://edamontology.org/format_3475
- clusters:
Expand Down
47 changes: 45 additions & 2 deletions modules/nf-core/custom/clustermetrics/templates/cluster_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import sklearn
import yaml
Expand All @@ -22,13 +23,28 @@
davies_bouldin_score,
silhouette_score,
)
from sklearn.neighbors import NearestNeighbors


def load_features(path):
"""Read a TSV of `sample_id` + numeric feature columns, indexed by sample_id."""
df = pd.read_csv(path, sep="\\t")
"""Read sample-by-feature matrix, indexed by sample_id.

Accepts a TSV with a sample_id column, or a PLINK2 .eigenvec file
(`#FID IID PC1 ...` or `#IID PC1 ...`). FID/SID are dropped.
"""
df = pd.read_csv(path, sep="\\t", dtype=str)
df.columns = [str(col).lstrip("#") for col in df.columns]

if "IID" in df.columns:
df = df.rename(columns={"IID": "sample_id"})

drop = [col for col in ("FID", "SID") if col in df.columns]
if drop:
df = df.drop(columns=drop)

if "sample_id" not in df.columns:
raise ValueError(f"features file must have a 'sample_id' column. Found: {list(df.columns)}")

df["sample_id"] = df["sample_id"].astype(str)
return df.set_index("sample_id").apply(pd.to_numeric, errors="coerce").fillna(0.0)

Expand Down Expand Up @@ -73,6 +89,28 @@ def plot_curve(sweep_df, metric, title, ylabel, out_png):
plt.close()


def plot_k_distance(x, k, out_png, eps=None):
"""k-distance plot used to choose DBSCAN eps."""
k = int(min(max(k, 1), len(x) - 1))
nn = NearestNeighbors(n_neighbors=k + 1)
nn.fit(x)
distances, _ = nn.kneighbors(x)
k_distances = np.sort(distances[:, -1])

plt.figure(figsize=(8, 4))
plt.plot(k_distances)
if eps is not None:
plt.axhline(y=eps, linestyle="--", label=f"eps = {eps}")
plt.legend()
plt.ylabel(f"Distance to {k}-th nearest neighbour")
plt.xlabel("Points sorted by distance")
plt.title("k-distance plot")
plt.grid(True)
plt.tight_layout()
plt.savefig(out_png, dpi=200)
plt.close()


def main():
features = "$features"
clusters_path = "$clusters"
Expand All @@ -83,6 +121,8 @@ def main():
parser = argparse.ArgumentParser()
parser.add_argument("--k-min", type=int, default=2)
parser.add_argument("--k-max", type=int, default=12)
parser.add_argument("--k-neighbors", type=int, default=None)
parser.add_argument("--eps", type=float, default=None)
opts = parser.parse_args(shlex.split(raw_args) if raw_args and raw_args != "null" else [])

joined = load_features(features).join(load_clusters(clusters_path), how="inner")
Expand Down Expand Up @@ -127,6 +167,9 @@ def main():
f"{prefix}.calinski_harabasz.png",
)

k_neighbors = opts.k_neighbors if opts.k_neighbors is not None else x.shape[1]
plot_k_distance(x, k_neighbors, f"{prefix}.k_distance.png", eps=opts.eps)

versions = {
"${task.process}": {
"python": platform.python_version(),
Expand Down
28 changes: 28 additions & 0 deletions modules/nf-core/custom/clustermetrics/tests/main.nf.test
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ nextflow_process {
}
}

test("clustermetrics - features from eigenvec") {

when {
process {
"""
input[0] = [
[ id:'test' ],
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test.eigenvec", checkIfExists: true),
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
]
"""
}
}

then {
assertAll(
{ assert process.success },
{ assert snapshot(
process.out.metrics,
process.out.k_sweep,
process.out.selected,
process.out.versions,
path(process.out.versions[0]).yaml
).match() }
)
}
}

test("clustermetrics - features and clusters - stub") {

options "-stub"
Expand Down
49 changes: 47 additions & 2 deletions modules/nf-core/custom/clustermetrics/tests/main.nf.test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"test.calinski_harabasz.png:md5,d41d8cd98f00b204e9800998ecf8427e",
"test.davies_bouldin.png:md5,d41d8cd98f00b204e9800998ecf8427e",
"test.elbow.png:md5,d41d8cd98f00b204e9800998ecf8427e",
"test.k_distance.png:md5,d41d8cd98f00b204e9800998ecf8427e",
"test.silhouette.png:md5,d41d8cd98f00b204e9800998ecf8427e"
]
]
Expand All @@ -95,10 +96,54 @@
}
}
],
"timestamp": "2026-05-19T14:11:36.600888057",
"timestamp": "2026-09-08T11:43:04.630166242",
"meta": {
"nf-test": "0.9.5",
"nextflow": "25.09.0"
"nextflow": "26.04.6"
}
},
"clustermetrics - features from eigenvec": {
"content": [
[
[
{
"id": "test"
},
"test.metrics.tsv:md5,15c36eab43e480e0311c4bcc3d511477"
]
],
[
[
{
"id": "test"
},
"test.k_sweep.csv:md5,98635ea739c5e136ced833916ae5d931"
]
],
[
[
{
"id": "test"
},
"test.selected.json:md5,d1d1b3788b7a111f38bf3b27d4bb1ab4"
]
],
[
"versions.yml:md5,602aa5dfe6c0b807d758c4f5cf3fc5e4"
],
{
"CUSTOM_CLUSTERMETRICS": {
"python": "3.12.13",
"pandas": "3.0.3",
"scikit-learn": "1.8.0",
"matplotlib": "3.10.9"
}
}
],
"timestamp": "2026-09-08T15:41:30.682155747",
"meta": {
"nf-test": "0.9.5",
"nextflow": "26.04.6"
}
}
}
8 changes: 5 additions & 3 deletions modules/nf-core/custom/clustervisualization/meta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ input:
- features:
type: file
description: |
Tab-separated feature matrix with a `sample_id` column and one
column per numeric feature (e.g. PCA scores).
pattern: "*.tsv"
Sample-by-feature matrix: TSV with a `sample_id` column, or a
PLINK2 `.eigenvec` file (`#FID IID PC1 ...` or `#IID PC1 ...`).
Family/source ID columns are dropped; remaining columns are
numeric features (e.g. PCA scores).
pattern: "*.{tsv,txt,eigenvec}"
ontologies:
- edam: http://edamontology.org/format_3475
- clusters:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,24 @@


def load_features(path):
"""Read a TSV of `sample_id` + numeric feature columns, indexed by sample_id."""
df = pd.read_csv(path, sep="\\t")
"""Read sample-by-feature matrix, indexed by sample_id.

Accepts a TSV with a sample_id column, or a PLINK2 .eigenvec file
(`#FID IID PC1 ...` or `#IID PC1 ...`). FID/SID are dropped.
"""
df = pd.read_csv(path, sep="\\t", dtype=str)
df.columns = [str(col).lstrip("#") for col in df.columns]

if "IID" in df.columns:
df = df.rename(columns={"IID": "sample_id"})

drop = [col for col in ("FID", "SID") if col in df.columns]
if drop:
df = df.drop(columns=drop)

if "sample_id" not in df.columns:
raise ValueError(f"features file must have a 'sample_id' column. Found: {list(df.columns)}")

df["sample_id"] = df["sample_id"].astype(str)
return df.set_index("sample_id").apply(pd.to_numeric, errors="coerce").fillna(0.0)

Expand All @@ -54,9 +68,9 @@ def embed(x, method, umap_neighbors, tsne_perplexity):
"""
n = len(x)
if method == "umap":
reducer = umap.UMAP(n_components=2, n_neighbors=min(umap_neighbors, max(2, n - 1)), random_state=42)
reducer = umap.UMAP(n_components=2, n_neighbors=min(umap_neighbors, max(2, n - 1)), n_jobs=1, random_state=42)
elif method == "tsne":
reducer = TSNE(n_components=2, perplexity=min(tsne_perplexity, max(2, n - 1)), random_state=42)
reducer = TSNE(n_components=2, perplexity=min(tsne_perplexity, max(2, n - 1)), n_jobs=1, random_state=42)
else:
raise ValueError(f"Unknown method '{method}' (expected 'umap' or 'tsne')")
return reducer.fit_transform(x)
Expand Down
50 changes: 48 additions & 2 deletions modules/nf-core/custom/clustervisualization/tests/main.nf.test
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
nextflow_process {

name "Test Process CUSTOM_CLUSTERVISUALIZATION"
script "../main.nf"
process "CUSTOM_CLUSTERVISUALIZATION"

tag "modules"
tag "modules_nfcore"
tag "custom"
tag "custom/clustervisualization"

test("clustervisualization - features and clusters") {
when {
process {
Expand All @@ -18,18 +21,60 @@ nextflow_process {
"""
}
}

then {
def umap = path(process.out.umap_tsv[0][1]).csv(sep: "\t")
def tsne = path(process.out.tsne_tsv[0][1]).csv(sep: "\t")
assertAll(
{ assert process.success },
{ assert umap.columnNames == ["sample_id", "Dim1", "Dim2", "cluster"] },
{ assert tsne.columnNames == ["sample_id", "Dim1", "Dim2", "cluster"] },
{ assert umap.rowCount == tsne.rowCount },
{ assert snapshot(
process.out.umap_tsv,
process.out.tsne_tsv,
umap.columnNames,
umap.rowCount,
tsne.columnNames,
tsne.rowCount,
process.out.versions,
path(process.out.versions[0]).yaml
).match() }
)
}
}

test("clustervisualization - features from eigenvec") {
when {
process {
"""
input[0] = [
[ id:'test' ],
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test.eigenvec", checkIfExists: true),
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
]
"""
}
}

then {
def umap = path(process.out.umap_tsv[0][1]).csv(sep: "\t")
def tsne = path(process.out.tsne_tsv[0][1]).csv(sep: "\t")
assertAll(
{ assert process.success },
{ assert umap.columnNames == ["sample_id", "Dim1", "Dim2", "cluster"] },
{ assert tsne.columnNames == ["sample_id", "Dim1", "Dim2", "cluster"] },
{ assert umap.rowCount == tsne.rowCount },
{ assert snapshot(
umap.columnNames,
umap.rowCount,
tsne.columnNames,
tsne.rowCount,
process.out.versions,
path(process.out.versions[0]).yaml
).match() }
)
}
}

test("clustervisualization - features and clusters - stub") {
options "-stub"
when {
Expand All @@ -43,6 +88,7 @@ nextflow_process {
"""
}
}

then {
assertAll(
{ assert process.success },
Expand Down
Loading