Skip to content

Commit 2599d25

Browse files
dbaku42pinin4fjordsclaude
authored
Add cluster metrics viz (#11372)
* New modules: cluster_metrics + cluster_viz - cluster_metrics: computes clustering quality metrics + k-sweep - cluster_viz: generates PCA, UMAP and t-SNE plots colored by cluster - Both use conda environment.yml - Full nf-test coverage * Move custom clustering modules under custom * Move custom clustering modules under custom * Fix custom clustering module lint * Fix custom clustering module lint and snapshots * Address review comments for clustering custom modules * Fix custom clustering module metadata * Add Dockerfile for custom/clustermetrics and custom/clustervisualiation * Add container directive for custom/clustermetrics and clustervisualiation * Update modules/nf-core/custom/clustermetrics/main.nf Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustermetrics/main.nf Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustermetrics/main.nf Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustermetrics/main.nf Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustervisualiation/templates/cluster_viz.py Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustervisualiation/templates/cluster_viz.py Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Update modules/nf-core/custom/clustervisualiation/templates/cluster_viz.py Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * fix: use template for cluster visualization module * style: clean cluster visualization module main * fix: address reviewer feedback for cluster modules * fix: address pinin4fjords follow-up review - template escaping, drop PCA orphans, fix versions.yml, rename clustervisualiation -> clustervisualization * feat(custom/clustervisualization): add UMAP and t-SNE cluster visualization module * fix: apply ruff formatting to cluster_viz.py template * fix: align clustermetrics and clustervisualization envs and containers * fix: use docker:// prefix for singularity container to enable OCI conversion * fix(custom/clustervisualization): set NUMBA_CACHE_DIR and MPLCONFIGDIR to fix numba caching in Singularity * fix(custom/clustervisualization): move NUMBA_CACHE_DIR fix before any imports, and KMean n_init stable * Apply suggestion from @pinin4fjords Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Apply suggestion from @pinin4fjords Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> * Prek and script fix * Fixed pandas series problem in cluster_metrics.py * fix: escape \n in f-strings for Groovy template compatibility * Format CUSTOM_CLUSTERMETRICS template with ruff * fix(clustermetrics,clustervisualization): update nf-test snapshots and test assertions * fix environment conflict * feat: add custom clustering and metrics modules * Clean up cluster modules and fix CI Templates: - clustering.py: replace inline `${n_clusters}` / `${dbscan_eps}` / `${dbscan_min_samples}` (which fail ruff and don't parse as Python source) with locals assigned at the top of main(); replace yaml.dump with the format_yaml_like helper used by the other two templates so pyyaml is no longer needed in the env. - cluster_metrics.py and cluster_viz.py: drop the PLINK-aware _normalise_id_column / multi-mode load_clusters glue; require sample_id + cluster on the documented inputs. Drop the silent try/except plot warning. Drop the cluster_mode / alignment_mode branching from main() along with the redundant input_clusters / input_features / n_samples_used / alignment_mode metadata. Drop the argparse + sys.argv wrapper pattern in cluster_metrics.py in favour of the direct template-substitution main() that cluster_viz.py already uses. Environments: - Strip the misleading `# clustermetrics/environment.yml` headers; prune per-module deps so the env declares what the script actually imports (clustering: numpy/pandas/python/scikit-learn; clustermetrics: + matplotlib; clustervisualization: + matplotlib/seaborn/umap-learn). Pin to the versions Wave resolved unpinned: numpy 2.4.4, pandas 3.0.3, python 3.12.13, scikit-learn 1.8.0, matplotlib 3.10.9, seaborn 0.13.2, umap-learn 0.5.12. Containers: - Rebuild Wave containers per module and replace the shared `_pruned:0...` URL (which didn't actually contain the packages the env declared) with the matching freshly-built Docker tag. Switch the singularity branch from `docker://...` to the proper https blob URL per nf-core convention. Tests: - Drop the orphan `test_pca.eigenvec` test data file from clustervisualization now that the PCA piece is no longer in the script. - Regenerate snapshots (run on AWS x86 Linux via docker profile). - Pre-commit autofix (prettier, end-of-file-fixer, ruff-format). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use yaml.dump for versions.yml in cluster modules Adds pyyaml to all three environment.yml files and replaces the embedded format_yaml_like helper in clustering.py / cluster_metrics.py / cluster_viz.py with a one-line `yaml.dump(versions, fh, default_flow_style=False, sort_keys=False)`. format_yaml_like dates from before Seqera Containers / Wave made it cheap to add deps. Now that we control the env precisely, pyyaml is a clean swap and the templates are ~10 lines shorter each. Rebuilt Wave Docker + Singularity containers per module with pyyaml=6.0.3 pinned (matching what Wave resolved unpinned) and updated the container URLs. Regenerated snapshots on the AWS x86 Linux VM with --profile docker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix portability of CUSTOM_CLUSTERING and CUSTOM_CLUSTERVISUALIZATION CI Two CPU/container-environment portability issues surfaced on CI that didn't appear on the AWS x86 VM I used to regenerate snapshots: 1. CUSTOM_CLUSTERING `*_clustering_info.json` md5 differed between CI and the snapshot. KMeans inertia is a sum of squared distances, and BLAS reduction order varies by a few ULPs across CPU instruction sets even with a fixed `random_state`. Keep full precision in the production output and round the value in the test snapshot only, matching the `Math.round(... / 1000) * 1000` pattern used in `custom/basicpy`. 2. CUSTOM_CLUSTERVISUALIZATION stub `versions.yml` had `umap-learn: null` under singularity. The stub heredoc ran `python3 -c "import umap"`, which triggers numba's JIT cache write to a path that is read-only inside the singularity image, so the import silently failed and the bash substitution expanded to an empty string. Switched the stub version checks to `importlib.metadata.version(<pkg>)` for every package so they read dist-info without importing the module. Same answer, no import side effects. Regenerated the clustering snapshot on the VM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tidy cluster module templates Reuse commodity functions and reduce hand-rolled glue: - clustering.py: replace the 45-line manual eigenvec line parser with `pd.read_csv(sep=r"\\s+")` + a small header check. Same accepted layouts (FID/IID and IID-only, with the leading '#' on the header line); drops the no-header inference path PLINK2 doesn't actually emit. - clustering.py: `len(set(labels) - {-1})` instead of the `len(set(...)) - (1 if -1 in labels else 0)` puzzle; drop redundant `int()` wraps around `len()`; replace `Path(...).write_text(json.dumps())` with `json.dump` + `open()` to match the versions.yml write style. - cluster_metrics.py / cluster_viz.py: replace the positional-index alignment dance with `load_features().join(load_clusters(), how="inner")`. Both helpers now return DataFrame / Series indexed by sample_id, so pandas does the join. Drops ~10 lines of indexing logic per module and removes a subtle reliance on `common.index` preserving positional identity. - cluster_metrics.py: tighten `cluster_quality()` to one expression and drop the early-return + shared dict pattern. Returns just the three scores; `n_clusters` is set at the call site, so the k-sweep no longer needs to filter the result down before merging. - cluster_metrics.py / cluster_viz.py: same json.dump cleanup. Regenerated snapshots on the AWS x86 VM; all six tests pass under `--profile docker`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert .gitignore drift unrelated to the PR The .gitignore picked up entries for local snpclustering subworkflow scaffolding and a stray `modules/nf-core/clustering/` ignore. None of those should land in nf-core/modules master; restoring the file to match origin/master. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address findings from fresh review pass - clustering tests: add a DBSCAN scenario alongside the existing kmeans one. The DBSCAN branch of clustering.py was previously untested. - clustermetrics + clustervisualization meta.yml: tighten the `features` and `clusters` input descriptions and patterns. Both modules require a TSV with a `sample_id` column and numeric features, and a CSV with `sample_id` + integer `cluster` columns; the old `"Feature matrix file"` / `pattern: "*"` did not advertise the schema or noise-label convention. - All three modules' stub `versions.yml` blocks: switch every package lookup to `importlib.metadata.version(...)`. clustervisualization already did this for umap-learn to dodge numba's read-only cache issue under singularity; using it everywhere is more robust against similar import-side-effect surprises and keeps the three stubs consistent. All 7 nf-tests pass under `--profile docker` on the AWS x86 VM. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Align modules with nf-core spec: ext.args + dot-separated outputs nf-core spec (modules/general.md): "All non-mandatory command-line tool non-file arguments MUST be provided as a string via the $task.ext.args variable." Adds argparse-on-task.ext.args plumbing in two template scripts so the previously-hardcoded tuning parameters can be overridden via modules.config: - cluster_metrics.py: --k-min (default 2) and --k-max (default 12) for the KMeans sweep range. - cluster_viz.py: --umap-neighbors (default 15) and --tsne-perplexity (default 30) for the embedding parameters. Both are still clamped against sample count so tiny inputs work. The `task.ext.args` substitution is parsed with shlex+argparse from inside the template (matrixfilter does the same with R's parse_args), which keeps required inputs as `val` channels (per input-output-options.md) and optional ones via `ext.args` (per general.md). `clustering` has no optional non-file args; its required algorithm/n_clusters/dbscan_eps/dbscan_min_samples remain `val` channel inputs. Output filename style also unified to `${prefix}.<descriptor>.<ext>` (dot-separated) across all three modules, matching the spec example (`${prefix}.fq.gz`) and the existing dot-separated pattern in `clustervisualization` / `custom/tx2gene` / `custom/matrixfilter`: - clustering: `*_clusters.csv` -> `*.clusters.csv`, `*_clustering_info.json` -> `*.clustering_info.json`. - clustermetrics: `*_metrics.tsv` -> `*.metrics.tsv`, `*_k_sweep.csv` -> `*.k_sweep.csv`, `*_selected.json` -> `*.selected.json`, and plot files from `*_<name>.png` -> `*.<name>.png`. Also renamed `_calinski.png` -> `.calinski_harabasz.png` to match the other metric files (was the only truncated one). Updated meta.yml output patterns and stub touches to match. Regenerated snapshots on the AWS x86 VM with `--profile docker`; all 7 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: switch custom/clustering* modules to nf-core/test-datasets - remove local tests/data/ - update main.nf.test to use params.modules_testdata_base_path - update snapshots * update snapshot v2 * Update test and snapshots * restore test and snapshot * fix(custom/clustervisualization): regenerate snapshot on Linux x86 UMAP and t-SNE outputs are not bit-identical across CPU architectures despite the fixed random_state, so snapshots must be generated on the same platform CI uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(custom/clustering): rename to custom/pcaclustering The module is specifically a clusterer for PLINK2 .eigenvec output (PCA components). The previous name implied generality that the module doesn't have. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add copyright and license headers Add copyright and license information to clustering.py * Add copyright and license information Added copyright and licensing information to the file. * Add copyright and license comments to cluster_viz.py Added copyright and licensing information to the top of the file. * Rename pcaclustering in plink2pcaclustering * Fix tag for plink2pcaclustering in test file * snapshot update plink2pcaclustering * refactor(custom/pcaclustering): accept generic sample_id features TSV Replace the PLINK2 .eigenvec parser with a plain pandas read of a TSV that has a `sample_id` column plus numeric feature columns - the same input contract `custom/clustermetrics` already uses. PLINK2 eigenvec output is one supported source (drop FID, rename IID -> sample_id), not the only one. Also removes the duplicate `plink2pcaclustering/` directory left behind by an in-flight rename. clusters.csv / clustering_info.json md5s are byte-identical to those recorded in the existing snapshot; the .snap file is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(custom/pcaclustering): point test at existing `test_features.tsv` fixture The earlier generic-input refactor (PR #11372 / dbaku42#1) pointed at `popgen/clustering/test.tsv`, but the actual fixture in nf-core/test-datasets (added in nf-core/test-datasets#2051) is named `test_features.tsv`. CI was failing with "No such file or directory" on all three pcaclustering tests as a result. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(custom/pcaclustering): drop hardcoded `sample_id` column name Use the first column of the features TSV as sample IDs regardless of its header name, and treat all remaining columns as numeric features. The required column name was a hidden assumption about input shape - this relaxes it without adding inputs, so callers can feed in e.g. a reformatted PLINK eigenvec (with `IID` or any other ID header) directly, as long as the ID column is first. Output schema (clusters.csv emits `sample_id,cluster`) is unchanged, so downstream consumers (clustermetrics, clustervisualization) see the same contract. Verified clusters.csv / clustering_info.json md5s remain byte-identical to the snapshot against the existing test fixture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(custom/pcaclustering): preserve zero-padded numeric sample IDs Pin the first column to `dtype=str` at read time so pandas doesn't type-infer it as int and strip leading zeros (e.g. "0001" -> 1). Same output for the existing fixture (which has string IDs already); snapshot md5s unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jonathan Manning <pininforthefjords@gmail.com> Co-authored-by: Jonathan Manning <jonathan.manning@seqera.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2dc2369 commit 2599d25

18 files changed

Lines changed: 1321 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
# yaml-language-server: $schema=https://raw.githubusercontent.com/nf-core/modules/master/modules/environment-schema.json
3+
channels:
4+
- conda-forge
5+
- bioconda
6+
dependencies:
7+
- conda-forge::matplotlib=3.10.9
8+
- conda-forge::numpy=2.4.4
9+
- conda-forge::pandas=3.0.3
10+
- conda-forge::python=3.12.13
11+
- conda-forge::pyyaml=6.0.3
12+
- conda-forge::scikit-learn=1.8.0
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
process CUSTOM_CLUSTERMETRICS {
2+
tag "$meta.id"
3+
label 'process_medium'
4+
5+
conda "${moduleDir}/environment.yml"
6+
container "${ workflow.containerEngine in ['singularity', 'apptainer'] && !task.ext.singularity_pull_docker_container ?
7+
'https://community-cr-prod.seqera.io/docker/registry/v2/blobs/sha256/25/25129a5258522a434c386b800d3e2e3e6dc72d8a1171b7b10f21df3488526795/data' :
8+
'community.wave.seqera.io/library/matplotlib_numpy_pandas_python_pruned:169e228afc7d3686' }"
9+
10+
input:
11+
tuple val(meta), path(features), path(clusters)
12+
13+
output:
14+
tuple val(meta), path("*.metrics.tsv") , emit: metrics
15+
tuple val(meta), path("*.k_sweep.csv") , emit: k_sweep
16+
tuple val(meta), path("*.selected.json"), emit: selected
17+
tuple val(meta), path("*.png") , emit: plots, optional: true
18+
path "versions.yml" , emit: versions, topic: versions
19+
20+
when:
21+
task.ext.when == null || task.ext.when
22+
23+
script:
24+
template 'cluster_metrics.py'
25+
26+
stub:
27+
def prefix = task.ext.prefix ?: "${meta.id}"
28+
"""
29+
touch ${prefix}.metrics.tsv
30+
touch ${prefix}.k_sweep.csv
31+
touch ${prefix}.selected.json
32+
touch ${prefix}.elbow.png
33+
touch ${prefix}.silhouette.png
34+
touch ${prefix}.davies_bouldin.png
35+
touch ${prefix}.calinski_harabasz.png
36+
37+
cat <<-END_VERSIONS > versions.yml
38+
"${task.process}":
39+
python: \$(python3 --version | sed 's/Python //')
40+
matplotlib: \$(python3 -c "from importlib.metadata import version; print(version('matplotlib'))")
41+
numpy: \$(python3 -c "from importlib.metadata import version; print(version('numpy'))")
42+
pandas: \$(python3 -c "from importlib.metadata import version; print(version('pandas'))")
43+
scikit-learn: \$(python3 -c "from importlib.metadata import version; print(version('scikit-learn'))")
44+
END_VERSIONS
45+
"""
46+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
name: "CUSTOM_CLUSTERMETRICS"
2+
description: "Computes clustering quality metrics (silhouette, Calinski-Harabasz,
3+
Davies-Bouldin) and performs k-sweep analysis"
4+
keywords:
5+
- clustering
6+
- metrics
7+
- silhouette
8+
- calinski-harabasz
9+
- davies-bouldin
10+
- evaluation
11+
tools:
12+
- "scikit-learn":
13+
description: "Machine learning library for clustering metrics"
14+
homepage: "https://scikit-learn.org/"
15+
documentation: "https://scikit-learn.org/stable/modules/clustering.html"
16+
licence:
17+
- "BSD-3-Clause"
18+
identifier: ""
19+
input:
20+
- - meta:
21+
type: map
22+
description: |
23+
Groovy Map containing sample information
24+
e.g. `[ id:'sample1' ]`
25+
- features:
26+
type: file
27+
description: |
28+
Tab-separated feature matrix with a `sample_id` column and one
29+
column per numeric feature (e.g. PCA scores).
30+
pattern: "*.tsv"
31+
ontologies:
32+
- edam: http://edamontology.org/format_3475
33+
- clusters:
34+
type: file
35+
description: |
36+
Comma-separated cluster assignments with `sample_id` and integer
37+
`cluster` columns. Label -1 is treated as DBSCAN noise.
38+
pattern: "*.csv"
39+
ontologies:
40+
- edam: http://edamontology.org/format_3752
41+
output:
42+
metrics:
43+
- - meta:
44+
type: map
45+
description: Groovy Map containing sample information
46+
- "*.metrics.tsv":
47+
type: file
48+
description: TSV with selected cluster quality metrics
49+
pattern: "*.metrics.tsv"
50+
ontologies:
51+
- edam: http://edamontology.org/format_3475
52+
k_sweep:
53+
- - meta:
54+
type: map
55+
description: Groovy Map containing sample information
56+
- "*.k_sweep.csv":
57+
type: file
58+
description: CSV with metrics for different values of k
59+
pattern: "*.k_sweep.csv"
60+
ontologies:
61+
- edam: http://edamontology.org/format_3752
62+
selected:
63+
- - meta:
64+
type: map
65+
description: Groovy Map containing sample information
66+
- "*.selected.json":
67+
type: file
68+
description: JSON with the selected/best metrics
69+
pattern: "*.selected.json"
70+
ontologies:
71+
- edam: http://edamontology.org/format_3464
72+
plots:
73+
- - meta:
74+
type: map
75+
description: Groovy Map containing sample information
76+
- "*.png":
77+
type: file
78+
description: Optional PNG plots (elbow, silhouette, etc.)
79+
pattern: "*.png"
80+
ontologies: []
81+
versions:
82+
- "versions.yml":
83+
type: file
84+
description: File containing software versions
85+
pattern: "versions.yml"
86+
ontologies:
87+
- edam: http://edamontology.org/format_3750
88+
topics:
89+
versions:
90+
- versions.yml:
91+
type: string
92+
description: The name of the process
93+
authors:
94+
- "@dbaku42"
95+
maintainers:
96+
- "@dbaku42"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright (c) nf-core
4+
# This software is licensed under the MIT License.
5+
# SPDX-License-Identifier: MIT
6+
7+
import argparse
8+
import json
9+
import platform
10+
import shlex
11+
12+
import matplotlib
13+
14+
matplotlib.use("Agg")
15+
import matplotlib.pyplot as plt
16+
import pandas as pd
17+
import sklearn
18+
import yaml
19+
from sklearn.cluster import KMeans
20+
from sklearn.metrics import (
21+
calinski_harabasz_score,
22+
davies_bouldin_score,
23+
silhouette_score,
24+
)
25+
26+
27+
def load_features(path):
28+
"""Read a TSV of `sample_id` + numeric feature columns, indexed by sample_id."""
29+
df = pd.read_csv(path, sep="\\t")
30+
if "sample_id" not in df.columns:
31+
raise ValueError(f"features file must have a 'sample_id' column. Found: {list(df.columns)}")
32+
df["sample_id"] = df["sample_id"].astype(str)
33+
return df.set_index("sample_id").apply(pd.to_numeric, errors="coerce").fillna(0.0)
34+
35+
36+
def load_clusters(path):
37+
"""Read a CSV of `sample_id` + `cluster`, returning a Series of int labels."""
38+
df = pd.read_csv(path)
39+
if "sample_id" not in df.columns or "cluster" not in df.columns:
40+
raise ValueError(f"clusters file must have 'sample_id' and 'cluster' columns. Found: {list(df.columns)}")
41+
df["sample_id"] = df["sample_id"].astype(str)
42+
return df.set_index("sample_id")["cluster"].astype(int)
43+
44+
45+
def cluster_quality(x, labels):
46+
"""Silhouette / Calinski-Harabasz / Davies-Bouldin for given (x, labels).
47+
48+
Treats label -1 as DBSCAN noise and excludes those points. Returns None
49+
for each score when fewer than 2 clusters of more than one point remain.
50+
"""
51+
mask = labels != -1
52+
x, labels = x[mask], labels[mask]
53+
n = len(set(labels))
54+
valid = 2 <= n < len(x)
55+
return {
56+
"silhouette": float(silhouette_score(x, labels)) if valid else None,
57+
"calinski_harabasz": float(calinski_harabasz_score(x, labels)) if valid else None,
58+
"davies_bouldin": float(davies_bouldin_score(x, labels)) if valid else None,
59+
}
60+
61+
62+
def plot_curve(sweep_df, metric, title, ylabel, out_png):
63+
plt.figure(figsize=(7, 4.5))
64+
vals = sweep_df[metric].dropna()
65+
ks = sweep_df.loc[vals.index, "k"]
66+
plt.plot(ks, vals, marker="o")
67+
plt.xticks(sweep_df["k"].tolist())
68+
plt.title(title)
69+
plt.xlabel("k")
70+
plt.ylabel(ylabel)
71+
plt.tight_layout()
72+
plt.savefig(out_png, dpi=200)
73+
plt.close()
74+
75+
76+
def main():
77+
features = "$features"
78+
clusters_path = "$clusters"
79+
prefix = "${task.ext.prefix ?: meta.id}"
80+
81+
# Optional configuration via task.ext.args (nf-core convention).
82+
raw_args = "$task.ext.args"
83+
parser = argparse.ArgumentParser()
84+
parser.add_argument("--k-min", type=int, default=2)
85+
parser.add_argument("--k-max", type=int, default=12)
86+
opts = parser.parse_args(shlex.split(raw_args) if raw_args and raw_args != "null" else [])
87+
88+
joined = load_features(features).join(load_clusters(clusters_path), how="inner")
89+
if len(joined) < 2:
90+
raise ValueError(f"Need at least 2 samples with matching sample_id in both inputs. Got {len(joined)}.")
91+
92+
labels = joined["cluster"].values
93+
x = joined.drop(columns=["cluster"]).to_numpy(dtype=float)
94+
95+
# Quality metrics on the supplied labels.
96+
selected = {"n_clusters": len(set(labels) - {-1}), **cluster_quality(x, labels)}
97+
pd.DataFrame([selected]).to_csv(f"{prefix}.metrics.tsv", sep="\\t", index=False)
98+
with open(f"{prefix}.selected.json", "w") as fh:
99+
json.dump(selected, fh, indent=2)
100+
101+
# KMeans k-sweep for downstream comparison.
102+
rows = []
103+
for k in range(opts.k_min, min(opts.k_max, len(x)) + 1):
104+
model = KMeans(n_clusters=k, n_init=10, random_state=42).fit(x)
105+
rows.append({"k": k, "inertia": float(model.inertia_), **cluster_quality(x, model.labels_)})
106+
107+
sweep_df = pd.DataFrame(rows)
108+
sweep_df.to_csv(f"{prefix}.k_sweep.csv", index=False, float_format="%.10g")
109+
110+
if not sweep_df.empty:
111+
plot_curve(sweep_df, "inertia", "Elbow method (KMeans inertia)", "inertia", f"{prefix}.elbow.png")
112+
plot_curve(
113+
sweep_df, "silhouette", "Silhouette score (higher is better)", "silhouette", f"{prefix}.silhouette.png"
114+
)
115+
plot_curve(
116+
sweep_df,
117+
"davies_bouldin",
118+
"Davies-Bouldin index (lower is better)",
119+
"davies_bouldin",
120+
f"{prefix}.davies_bouldin.png",
121+
)
122+
plot_curve(
123+
sweep_df,
124+
"calinski_harabasz",
125+
"Calinski-Harabasz index (higher is better)",
126+
"calinski_harabasz",
127+
f"{prefix}.calinski_harabasz.png",
128+
)
129+
130+
versions = {
131+
"${task.process}": {
132+
"python": platform.python_version(),
133+
"pandas": pd.__version__,
134+
"scikit-learn": sklearn.__version__,
135+
"matplotlib": matplotlib.__version__,
136+
}
137+
}
138+
with open("versions.yml", "w") as fh:
139+
yaml.dump(versions, fh, default_flow_style=False, sort_keys=False)
140+
141+
142+
if __name__ == "__main__":
143+
main()
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
nextflow_process {
2+
3+
name "Test Process CUSTOM_CLUSTERMETRICS"
4+
script "../main.nf"
5+
process "CUSTOM_CLUSTERMETRICS"
6+
7+
tag "modules"
8+
tag "modules_nfcore"
9+
tag "custom"
10+
tag "custom/clustermetrics"
11+
12+
test("clustermetrics - features and clusters") {
13+
14+
when {
15+
process {
16+
"""
17+
input[0] = [
18+
[ id:'test' ],
19+
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_features.tsv", checkIfExists: true),
20+
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
21+
]
22+
"""
23+
}
24+
}
25+
26+
then {
27+
assertAll(
28+
{ assert process.success },
29+
{ assert snapshot(
30+
process.out.metrics,
31+
process.out.k_sweep,
32+
process.out.selected,
33+
process.out.versions,
34+
path(process.out.versions[0]).yaml
35+
).match() }
36+
)
37+
}
38+
}
39+
40+
test("clustermetrics - features and clusters - stub") {
41+
42+
options "-stub"
43+
44+
when {
45+
process {
46+
"""
47+
input[0] = [
48+
[ id:'test' ],
49+
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_features.tsv", checkIfExists: true),
50+
file(params.modules_testdata_base_path + "genomics/homo_sapiens/popgen/clustering/test_clusters.csv", checkIfExists: true)
51+
]
52+
"""
53+
}
54+
}
55+
56+
then {
57+
assertAll(
58+
{ assert process.success },
59+
{ assert snapshot(
60+
process.out.metrics,
61+
process.out.k_sweep,
62+
process.out.selected,
63+
process.out.plots,
64+
process.out.versions,
65+
path(process.out.versions[0]).yaml
66+
).match() }
67+
)
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)