-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add cluster metrics viz #11372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add cluster metrics viz #11372
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 0320a41
Move custom clustering modules under custom
dbaku42 e424338
Move custom clustering modules under custom
dbaku42 670bc33
Fix custom clustering module lint
dbaku42 3a26119
Fix custom clustering module lint and snapshots
dbaku42 e4877e4
Address review comments for clustering custom modules
dbaku42 7e19b22
Fix custom clustering module metadata
dbaku42 fdb1be4
Add Dockerfile for custom/clustermetrics and custom/clustervisualiation
dbaku42 d3a382b
Add container directive for custom/clustermetrics and clustervisualia…
dbaku42 89287db
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 d5ac668
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 55e868a
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 e492edb
Update modules/nf-core/custom/clustermetrics/main.nf
dbaku42 b8529f6
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 12bb00d
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 a4a01a3
Update modules/nf-core/custom/clustervisualiation/templates/cluster_v…
dbaku42 060eb5d
fix: use template for cluster visualization module
dbaku42 d48a224
style: clean cluster visualization module main
dbaku42 2d85309
fix: address reviewer feedback for cluster modules
dbaku42 387cbeb
fix: address pinin4fjords follow-up review - template escaping, drop …
dbaku42 4ed380b
feat(custom/clustervisualization): add UMAP and t-SNE cluster visuali…
dbaku42 4fe91e7
fix: apply ruff formatting to cluster_viz.py template
dbaku42 aaded95
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 0980208
fix: align clustermetrics and clustervisualization envs and containers
dbaku42 f240623
fix: use docker:// prefix for singularity container to enable OCI con…
dbaku42 c46c26c
fix(custom/clustervisualization): set NUMBA_CACHE_DIR and MPLCONFIGDI…
dbaku42 ad39971
fix(custom/clustervisualization): move NUMBA_CACHE_DIR fix before any…
dbaku42 c8fec15
Apply suggestion from @pinin4fjords
dbaku42 3634466
Apply suggestion from @pinin4fjords
dbaku42 0ca17c9
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 6a7ac45
Prek and script fix
dbaku42 cddb5a8
Fixed pandas series problem in cluster_metrics.py
dbaku42 4a72291
fix: escape \n in f-strings for Groovy template compatibility
dbaku42 2bf8afa
Format CUSTOM_CLUSTERMETRICS template with ruff
dbaku42 fd15709
fix(clustermetrics,clustervisualization): update nf-test snapshots an…
dbaku42 792373b
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 6114ebd
fix environment conflict
dbaku42 8348609
feat: add custom clustering and metrics modules
dbaku42 f1915bd
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 ac9a4ba
Clean up cluster modules and fix CI
pinin4fjords ea89bb2
Use yaml.dump for versions.yml in cluster modules
pinin4fjords 51a747b
Fix portability of CUSTOM_CLUSTERING and CUSTOM_CLUSTERVISUALIZATION CI
pinin4fjords 39baf33
Tidy cluster module templates
pinin4fjords 465c8e4
Revert .gitignore drift unrelated to the PR
pinin4fjords 9870458
Address findings from fresh review pass
pinin4fjords 3f5a5fa
Align modules with nf-core spec: ext.args + dot-separated outputs
pinin4fjords f858003
chore: switch custom/clustering* modules to nf-core/test-datasets
dbaku42 9ecf6d4
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 6aead49
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 c840e84
update snapshot v2
dbaku42 05cbfc9
Update test and snapshots
dbaku42 cda844d
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 a2617a1
restore test and snapshot
dbaku42 f5a2ba7
fix(custom/clustervisualization): regenerate snapshot on Linux x86
pinin4fjords 776f3d5
chore(custom/clustering): rename to custom/pcaclustering
pinin4fjords 4c50b31
Add copyright and license headers
dbaku42 fbcb1c3
Add copyright and license information
dbaku42 874cc1b
Add copyright and license comments to cluster_viz.py
dbaku42 8edc6c4
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 32531f7
Rename pcaclustering in plink2pcaclustering
dbaku42 ba976f9
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 81f50f3
Fix tag for plink2pcaclustering in test file
dbaku42 1ab3556
Merge branch 'master' into add-cluster-metrics-viz
dbaku42 0625b62
snapshot update plink2pcaclustering
dbaku42 5ae6c73
refactor(custom/pcaclustering): accept generic sample_id features TSV
pinin4fjords e5429f1
Merge pull request #1 from pinin4fjords/refactor/pcaclustering-generi…
dbaku42 e45e7d9
Merge branch 'master' into add-cluster-metrics-viz
pinin4fjords 50d2883
fix(custom/pcaclustering): point test at existing `test_features.tsv`…
pinin4fjords e7174e1
refactor(custom/pcaclustering): drop hardcoded `sample_id` column name
pinin4fjords f5edb63
fix(custom/pcaclustering): preserve zero-padded numeric sample IDs
pinin4fjords File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| """ | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
143
modules/nf-core/custom/clustermetrics/templates/cluster_metrics.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| #!/usr/bin/env python3 | ||
|
|
||
|
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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| ] | ||
| """ | ||
|
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) | ||
| ] | ||
| """ | ||
|
pinin4fjords marked this conversation as resolved.
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() } | ||
| ) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.