Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
9abde15
New modules: cluster_metrics + cluster_viz
dbaku42 Apr 29, 2026
0320a41
Move custom clustering modules under custom
dbaku42 May 4, 2026
e424338
Move custom clustering modules under custom
dbaku42 May 4, 2026
670bc33
Fix custom clustering module lint
dbaku42 May 4, 2026
3a26119
Fix custom clustering module lint and snapshots
dbaku42 May 4, 2026
e4877e4
Address review comments for clustering custom modules
dbaku42 May 6, 2026
7e19b22
Fix custom clustering module metadata
dbaku42 May 6, 2026
fdb1be4
Add Dockerfile for custom/clustermetrics and custom/clustervisualiation
dbaku42 May 7, 2026
d3a382b
Add container directive for custom/clustermetrics and clustervisualia…
dbaku42 May 7, 2026
89287db
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 May 8, 2026
d5ac668
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 May 8, 2026
55e868a
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 May 8, 2026
e492edb
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 May 8, 2026
b8529f6
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 May 8, 2026
12bb00d
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 May 8, 2026
a4a01a3
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 May 8, 2026
060eb5d
fix: use template for cluster visualization module
dbaku42 May 8, 2026
d48a224
style: clean cluster visualization module main
dbaku42 May 8, 2026
2d85309
fix: address reviewer feedback for cluster modules
dbaku42 May 8, 2026
387cbeb
fix: address pinin4fjords follow-up review - template escaping, drop …
dbaku42 May 11, 2026
4ed380b
feat(custom/clustervisualization): add UMAP and t-SNE cluster visuali…
dbaku42 May 11, 2026
4fe91e7
fix: apply ruff formatting to cluster_viz.py template
dbaku42 May 11, 2026
aaded95
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 11, 2026
0980208
fix: align clustermetrics and clustervisualization envs and containers
dbaku42 May 11, 2026
f240623
fix: use docker:// prefix for singularity container to enable OCI con…
dbaku42 May 12, 2026
c46c26c
fix(custom/clustervisualization): set NUMBA_CACHE_DIR and MPLCONFIGDI…
dbaku42 May 12, 2026
ad39971
fix(custom/clustervisualization): move NUMBA_CACHE_DIR fix before any…
dbaku42 May 12, 2026
c8fec15
Apply suggestion from @pinin4fjords
dbaku42 May 12, 2026
3634466
Apply suggestion from @pinin4fjords
dbaku42 May 12, 2026
0ca17c9
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 12, 2026
6a7ac45
Prek and script fix
dbaku42 May 12, 2026
cddb5a8
Fixed pandas series problem in cluster_metrics.py
dbaku42 May 13, 2026
4a72291
fix: escape \n in f-strings for Groovy template compatibility
dbaku42 May 13, 2026
2bf8afa
Format CUSTOM_CLUSTERMETRICS template with ruff
dbaku42 May 13, 2026
fd15709
fix(clustermetrics,clustervisualization): update nf-test snapshots an…
dbaku42 May 13, 2026
792373b
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 13, 2026
6114ebd
fix environment conflict
dbaku42 May 13, 2026
8348609
feat: add custom clustering and metrics modules
dbaku42 May 13, 2026
f1915bd
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 13, 2026
ac9a4ba
Clean up cluster modules and fix CI
pinin4fjords May 14, 2026
ea89bb2
Use yaml.dump for versions.yml in cluster modules
pinin4fjords May 14, 2026
51a747b
Fix portability of CUSTOM_CLUSTERING and CUSTOM_CLUSTERVISUALIZATION CI
pinin4fjords May 14, 2026
39baf33
Tidy cluster module templates
pinin4fjords May 14, 2026
465c8e4
Revert .gitignore drift unrelated to the PR
pinin4fjords May 14, 2026
9870458
Address findings from fresh review pass
pinin4fjords May 14, 2026
3f5a5fa
Align modules with nf-core spec: ext.args + dot-separated outputs
pinin4fjords May 14, 2026
f858003
chore: switch custom/clustering* modules to nf-core/test-datasets
dbaku42 May 19, 2026
9ecf6d4
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 19, 2026
6aead49
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 19, 2026
c840e84
update snapshot v2
dbaku42 May 19, 2026
05cbfc9
Update test and snapshots
dbaku42 May 19, 2026
cda844d
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 19, 2026
a2617a1
restore test and snapshot
dbaku42 May 19, 2026
f5a2ba7
fix(custom/clustervisualization): regenerate snapshot on Linux x86
pinin4fjords May 19, 2026
776f3d5
chore(custom/clustering): rename to custom/pcaclustering
pinin4fjords May 19, 2026
4c50b31
Add copyright and license headers
dbaku42 May 20, 2026
fbcb1c3
Add copyright and license information
dbaku42 May 20, 2026
874cc1b
Add copyright and license comments to cluster_viz.py
dbaku42 May 20, 2026
8edc6c4
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 20, 2026
32531f7
Rename pcaclustering in plink2pcaclustering
dbaku42 May 20, 2026
ba976f9
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 20, 2026
81f50f3
Fix tag for plink2pcaclustering in test file
dbaku42 May 20, 2026
1ab3556
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 May 20, 2026
0625b62
snapshot update plink2pcaclustering
dbaku42 May 20, 2026
5ae6c73
refactor(custom/pcaclustering): accept generic sample_id features TSV
pinin4fjords May 21, 2026
e5429f1
Merge pull request #1 from pinin4fjords/refactor/pcaclustering-generi…
dbaku42 May 21, 2026
e45e7d9
Merge branch 'master' into add-cluster-metrics-viz
pinin4fjords May 21, 2026
50d2883
fix(custom/pcaclustering): point test at existing `test_features.tsv`…
pinin4fjords May 21, 2026
e7174e1
refactor(custom/pcaclustering): drop hardcoded `sample_id` column name
pinin4fjords May 21, 2026
f5edb63
fix(custom/pcaclustering): preserve zero-padded numeric sample IDs
pinin4fjords May 21, 2026
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
12 changes: 12 additions & 0 deletions modules/nf-core/custom/clustermetrics/environment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/nf-core/modules/master/modules/environment-schema.json
channels:
- conda-forge
- bioconda
dependencies:
- conda-forge::matplotlib=3.10.9
- conda-forge::numpy=2.4.4
- conda-forge::pandas=3.0.3
- conda-forge::python=3.12.13
- conda-forge::pyyaml=6.0.3
- conda-forge::scikit-learn=1.8.0
46 changes: 46 additions & 0 deletions modules/nf-core/custom/clustermetrics/main.nf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
process CUSTOM_CLUSTERMETRICS {
tag "$meta.id"
label 'process_medium'

conda "${moduleDir}/environment.yml"
container "${ workflow.containerEngine in ['singularity', 'apptainer'] && !task.ext.singularity_pull_docker_container ?
'https://community-cr-prod.seqera.io/docker/registry/v2/blobs/sha256/25/25129a5258522a434c386b800d3e2e3e6dc72d8a1171b7b10f21df3488526795/data' :
'community.wave.seqera.io/library/matplotlib_numpy_pandas_python_pruned:169e228afc7d3686' }"

input:
tuple val(meta), path(features), path(clusters)

output:
tuple val(meta), path("*.metrics.tsv") , emit: metrics
tuple val(meta), path("*.k_sweep.csv") , emit: k_sweep
tuple val(meta), path("*.selected.json"), emit: selected
tuple val(meta), path("*.png") , emit: plots, optional: true
path "versions.yml" , emit: versions, topic: versions

when:
task.ext.when == null || task.ext.when

script:
template 'cluster_metrics.py'

stub:
def prefix = task.ext.prefix ?: "${meta.id}"
"""
touch ${prefix}.metrics.tsv
touch ${prefix}.k_sweep.csv
touch ${prefix}.selected.json
touch ${prefix}.elbow.png
touch ${prefix}.silhouette.png
touch ${prefix}.davies_bouldin.png
touch ${prefix}.calinski_harabasz.png

cat <<-END_VERSIONS > versions.yml
"${task.process}":
python: \$(python3 --version | sed 's/Python //')
matplotlib: \$(python3 -c "from importlib.metadata import version; print(version('matplotlib'))")
numpy: \$(python3 -c "from importlib.metadata import version; print(version('numpy'))")
pandas: \$(python3 -c "from importlib.metadata import version; print(version('pandas'))")
scikit-learn: \$(python3 -c "from importlib.metadata import version; print(version('scikit-learn'))")
END_VERSIONS
"""
}
Comment thread
dbaku42 marked this conversation as resolved.
96 changes: 96 additions & 0 deletions modules/nf-core/custom/clustermetrics/meta.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: "CUSTOM_CLUSTERMETRICS"
description: "Computes clustering quality metrics (silhouette, Calinski-Harabasz,
Davies-Bouldin) and performs k-sweep analysis"
keywords:
- clustering
- metrics
- silhouette
- calinski-harabasz
- davies-bouldin
- evaluation
tools:
- "scikit-learn":
description: "Machine learning library for clustering metrics"
homepage: "https://scikit-learn.org/"
documentation: "https://scikit-learn.org/stable/modules/clustering.html"
licence:
- "BSD-3-Clause"
identifier: ""
input:
- - meta:
type: map
description: |
Groovy Map containing sample information
e.g. `[ id:'sample1' ]`
- 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"
ontologies:
- edam: http://edamontology.org/format_3475
- clusters:
type: file
description: |
Comma-separated cluster assignments with `sample_id` and integer
`cluster` columns. Label -1 is treated as DBSCAN noise.
pattern: "*.csv"
ontologies:
- edam: http://edamontology.org/format_3752
output:
metrics:
- - meta:
type: map
description: Groovy Map containing sample information
- "*.metrics.tsv":
type: file
description: TSV with selected cluster quality metrics
pattern: "*.metrics.tsv"
ontologies:
- edam: http://edamontology.org/format_3475
k_sweep:
- - meta:
type: map
description: Groovy Map containing sample information
- "*.k_sweep.csv":
type: file
description: CSV with metrics for different values of k
pattern: "*.k_sweep.csv"
ontologies:
- edam: http://edamontology.org/format_3752
selected:
- - meta:
type: map
description: Groovy Map containing sample information
- "*.selected.json":
type: file
description: JSON with the selected/best metrics
pattern: "*.selected.json"
ontologies:
- edam: http://edamontology.org/format_3464
plots:
- - meta:
type: map
description: Groovy Map containing sample information
- "*.png":
type: file
description: Optional PNG plots (elbow, silhouette, etc.)
pattern: "*.png"
ontologies: []
versions:
- "versions.yml":
type: file
description: File containing software versions
pattern: "versions.yml"
ontologies:
- edam: http://edamontology.org/format_3750
topics:
versions:
- versions.yml:
type: string
description: The name of the process
authors:
- "@dbaku42"
maintainers:
- "@dbaku42"
143 changes: 143 additions & 0 deletions modules/nf-core/custom/clustermetrics/templates/cluster_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env python3

Comment thread
SPPearce marked this conversation as resolved.
# Copyright (c) nf-core
# This software is licensed under the MIT License.
# SPDX-License-Identifier: MIT

import argparse
import json
import platform
import shlex

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import pandas as pd
import sklearn
import yaml
from sklearn.cluster import KMeans
from sklearn.metrics import (
calinski_harabasz_score,
davies_bouldin_score,
silhouette_score,
)


def load_features(path):
"""Read a TSV of `sample_id` + numeric feature columns, indexed by sample_id."""
df = pd.read_csv(path, sep="\\t")
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)


def load_clusters(path):
"""Read a CSV of `sample_id` + `cluster`, returning a Series of int labels."""
df = pd.read_csv(path)
if "sample_id" not in df.columns or "cluster" not in df.columns:
raise ValueError(f"clusters file must have 'sample_id' and 'cluster' columns. Found: {list(df.columns)}")
df["sample_id"] = df["sample_id"].astype(str)
return df.set_index("sample_id")["cluster"].astype(int)


def cluster_quality(x, labels):
"""Silhouette / Calinski-Harabasz / Davies-Bouldin for given (x, labels).

Treats label -1 as DBSCAN noise and excludes those points. Returns None
for each score when fewer than 2 clusters of more than one point remain.
"""
mask = labels != -1
x, labels = x[mask], labels[mask]
n = len(set(labels))
valid = 2 <= n < len(x)
return {
"silhouette": float(silhouette_score(x, labels)) if valid else None,
"calinski_harabasz": float(calinski_harabasz_score(x, labels)) if valid else None,
"davies_bouldin": float(davies_bouldin_score(x, labels)) if valid else None,
}


def plot_curve(sweep_df, metric, title, ylabel, out_png):
plt.figure(figsize=(7, 4.5))
vals = sweep_df[metric].dropna()
ks = sweep_df.loc[vals.index, "k"]
plt.plot(ks, vals, marker="o")
plt.xticks(sweep_df["k"].tolist())
plt.title(title)
plt.xlabel("k")
plt.ylabel(ylabel)
plt.tight_layout()
plt.savefig(out_png, dpi=200)
plt.close()


def main():
features = "$features"
clusters_path = "$clusters"
prefix = "${task.ext.prefix ?: meta.id}"

# Optional configuration via task.ext.args (nf-core convention).
raw_args = "$task.ext.args"
parser = argparse.ArgumentParser()
parser.add_argument("--k-min", type=int, default=2)
parser.add_argument("--k-max", type=int, default=12)
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")
if len(joined) < 2:
raise ValueError(f"Need at least 2 samples with matching sample_id in both inputs. Got {len(joined)}.")

labels = joined["cluster"].values
x = joined.drop(columns=["cluster"]).to_numpy(dtype=float)

# Quality metrics on the supplied labels.
selected = {"n_clusters": len(set(labels) - {-1}), **cluster_quality(x, labels)}
pd.DataFrame([selected]).to_csv(f"{prefix}.metrics.tsv", sep="\\t", index=False)
with open(f"{prefix}.selected.json", "w") as fh:
json.dump(selected, fh, indent=2)

# KMeans k-sweep for downstream comparison.
rows = []
for k in range(opts.k_min, min(opts.k_max, len(x)) + 1):
model = KMeans(n_clusters=k, n_init=10, random_state=42).fit(x)
rows.append({"k": k, "inertia": float(model.inertia_), **cluster_quality(x, model.labels_)})

sweep_df = pd.DataFrame(rows)
sweep_df.to_csv(f"{prefix}.k_sweep.csv", index=False, float_format="%.10g")

if not sweep_df.empty:
plot_curve(sweep_df, "inertia", "Elbow method (KMeans inertia)", "inertia", f"{prefix}.elbow.png")
plot_curve(
sweep_df, "silhouette", "Silhouette score (higher is better)", "silhouette", f"{prefix}.silhouette.png"
)
plot_curve(
sweep_df,
"davies_bouldin",
"Davies-Bouldin index (lower is better)",
"davies_bouldin",
f"{prefix}.davies_bouldin.png",
)
plot_curve(
sweep_df,
"calinski_harabasz",
"Calinski-Harabasz index (higher is better)",
"calinski_harabasz",
f"{prefix}.calinski_harabasz.png",
)

versions = {
"${task.process}": {
"python": platform.python_version(),
"pandas": pd.__version__,
"scikit-learn": sklearn.__version__,
"matplotlib": matplotlib.__version__,
}
}
with open("versions.yml", "w") as fh:
yaml.dump(versions, fh, default_flow_style=False, sort_keys=False)


if __name__ == "__main__":
main()
70 changes: 70 additions & 0 deletions modules/nf-core/custom/clustermetrics/tests/main.nf.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
nextflow_process {

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

tag "modules"
tag "modules_nfcore"
tag "custom"
tag "custom/clustermetrics"

test("clustermetrics - features and clusters") {

when {
process {
"""
input[0] = [
[ id:'test' ],
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_features.tsv", checkIfExists: true),
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
]
"""
Comment thread
dbaku42 marked this conversation as resolved.
}
}

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"

when {
process {
"""
input[0] = [
[ id:'test' ],
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_features.tsv", checkIfExists: true),
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
]
"""
Comment thread
pinin4fjords marked this conversation as resolved.
Comment thread
pinin4fjords marked this conversation as resolved.
}
}

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