Repository navigation
Add DEG F1 and Spearman z-score metrics #84
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| __merge__: ../../api/comp_metric.yaml | ||
| name: deg | ||
| info: | ||
| metrics: | ||
| - name: deg_f1 | ||
| label: DEG F1 | ||
| summary: The F1 score of the predicted marker genes against the ground-truth marker genes. | ||
| description: | | ||
| Leiden clusters of the ground truth with at least 30 cells are tested one-vs-rest with a Wilcoxon | ||
| rank-sum test, on the ground truth and on the prediction. Up-regulated features with a | ||
| Benjamini-Hochberg adjusted p-value below 0.05 and an effect above the modality's threshold are | ||
| the DEGs. The metric is the F1 score of the predicted against the ground-truth DEGs, averaged | ||
| over the clusters. Clusters without ground-truth DEGs are skipped. Scores 0 when the prediction calls no DEGs. | ||
| The effect threshold is absolute, so the prediction must be on the scale of the `normalized` layer. | ||
| min: 0 | ||
| max: 1 | ||
| maximize: true | ||
| references: | ||
| doi: | ||
| - 10.1108/eb026584 | ||
| - name: spearman_z | ||
| label: Spearman DE z-score | ||
| summary: The rank correlation of the ground-truth and predicted DE z-scores over all features. | ||
| description: | | ||
| Leiden clusters of the ground truth with at least 30 cells are tested one-vs-rest with a Wilcoxon | ||
| rank-sum test, on the ground truth and on the prediction. The metric is the Spearman correlation | ||
| of the ground-truth and predicted z-scores over all features, averaged over the clusters. | ||
| min: -1 | ||
| max: 1 | ||
| maximize: true | ||
| references: | ||
| doi: | ||
| - 10.2307/1412159 | ||
| arguments: | ||
| - name: "--leiden_resolution" | ||
| type: double | ||
| default: 1 | ||
| description: Resolution of the Leiden clustering of the ground truth that defines the groups. | ||
| - name: "--min_group_size" | ||
| type: integer | ||
| default: 30 | ||
| description: Clusters with fewer cells are not scored. | ||
| - name: "--padj_threshold" | ||
| type: double | ||
| default: 0.05 | ||
| description: Maximum Benjamini-Hochberg adjusted p-value of a DEG. | ||
| - name: "--effect_threshold_gex" | ||
| type: double | ||
| default: 0.25 | ||
| description: Minimum difference of means (cluster minus rest) on the `normalized` layer for a DEG, when the target modality is GEX. | ||
| - name: "--effect_threshold_adt" | ||
| type: double | ||
| default: 0.5 | ||
| description: Minimum difference of means (cluster minus rest) on the `normalized` layer for a DEG, when the target modality is ADT. | ||
| - name: "--effect_threshold_atac" | ||
| type: double | ||
| default: 0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With a threshold of 0, ATAC F1 ignores the scale of the prediction: dividing the solution by 2 scores 1.00 on ATAC, but 0.38-0.62 on GEX and ADT. That contradicts "the prediction must be on the scale of the |
||
| description: Minimum difference of means (cluster minus rest) on the `normalized` layer for a DEG, when the target modality is ATAC. | ||
| - name: "--seed" | ||
| type: integer | ||
| default: 0 | ||
| description: Random seed of the PCA, the neighbour graph and the Leiden clustering. | ||
| resources: | ||
| - type: python_script | ||
| path: script.py | ||
| engines: | ||
| - type: docker | ||
| image: openproblems/base_python:1 | ||
| setup: | ||
| - type: python | ||
| packages: | ||
| - igraph | ||
| runners: | ||
| - type: executable | ||
| - type: nextflow | ||
| directives: | ||
| label: [midtime, lowmem, lowcpu] | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,156 @@ | ||||||||
| import anndata as ad | ||||||||
| import logging | ||||||||
| import numpy as np | ||||||||
| import pandas as pd | ||||||||
| import scanpy as sc | ||||||||
| from scipy import sparse, stats | ||||||||
|
|
||||||||
| ## VIASH START | ||||||||
| par = { | ||||||||
| "input_test_mod2" : "resources_test/task_predict_modality/openproblems_neurips2021/bmmc_cite/swap/test_mod2.h5ad", | ||||||||
| "input_prediction" : "resources_test/task_predict_modality/openproblems_neurips2021/bmmc_cite/swap/prediction.h5ad", | ||||||||
| "output" : "output/scores.h5ad", | ||||||||
| "leiden_resolution" : 1, | ||||||||
| "min_group_size" : 30, | ||||||||
| "padj_threshold" : 0.05, | ||||||||
| "effect_threshold_gex" : 0.25, | ||||||||
| "effect_threshold_adt" : 0.5, | ||||||||
| "effect_threshold_atac" : 0, | ||||||||
| "seed" : 0 | ||||||||
| } | ||||||||
| ## VIASH END | ||||||||
|
|
||||||||
| logging.basicConfig(level=logging.INFO) | ||||||||
|
|
||||||||
|
|
||||||||
| def dense(X): | ||||||||
| return np.asarray(X.toarray() if sparse.issparse(X) else X, dtype=np.float64) | ||||||||
|
|
||||||||
|
|
||||||||
| def leiden_groups(X, var_mask, n_comps): | ||||||||
| """Leiden clusters of the ground truth, as integer codes per cell.""" | ||||||||
| tmp = ad.AnnData(X[:, var_mask]) | ||||||||
| n_comps = min(n_comps, tmp.n_obs - 1, tmp.n_vars - 1) | ||||||||
| sc.pp.pca(tmp, n_comps=n_comps, random_state=par["seed"]) | ||||||||
| sc.pp.neighbors(tmp, use_rep="X_pca", n_neighbors=15, random_state=par["seed"]) | ||||||||
| sc.tl.leiden(tmp, resolution=par["leiden_resolution"], key_added="leiden", flavor="igraph", | ||||||||
| n_iterations=2, directed=False, random_state=par["seed"]) | ||||||||
| return tmp.obs["leiden"].astype(int).to_numpy() | ||||||||
|
|
||||||||
|
|
||||||||
| def de_one_vs_rest(X, codes, n_groups): | ||||||||
| """One-vs-rest Wilcoxon rank-sum test (tie-corrected) for every group and feature. | ||||||||
| Returns the signed z-score, the effect (mean of the group minus mean of the rest) and the BH-adjusted p-values, | ||||||||
| all groups x features.""" | ||||||||
| tmp = ad.AnnData(X, var=pd.DataFrame(index=np.arange(X.shape[1]).astype(str))) | ||||||||
| tmp.obs["group"] = pd.Categorical(codes.astype(str), categories=np.arange(n_groups).astype(str)) | ||||||||
| sc.tl.rank_genes_groups(tmp, "group", method="wilcoxon", reference="rest", tie_correct=True, | ||||||||
| corr_method="benjamini-hochberg", n_genes=X.shape[1]) | ||||||||
| res = tmp.uns["rank_genes_groups"] | ||||||||
| z = np.empty((n_groups, X.shape[1])) | ||||||||
| padj = np.empty((n_groups, X.shape[1])) | ||||||||
| for g in range(n_groups): | ||||||||
| # scanpy sorts the features by score, so put them back in feature order | ||||||||
| order = res["names"][str(g)].astype(int) | ||||||||
| z[g, order] = res["scores"][str(g)] | ||||||||
| padj[g, order] = res["pvals_adj"][str(g)] | ||||||||
| # scanpy's logfoldchanges assume log1p data, so the effect is the difference of means on the layer itself | ||||||||
| n_g = np.bincount(codes, minlength=n_groups)[:, None] | ||||||||
| sum_g = np.vstack([X[codes == g].sum(axis=0) for g in range(n_groups)]) | ||||||||
| effect = sum_g / n_g - (X.sum(axis=0) - sum_g) / (X.shape[0] - n_g) | ||||||||
| return z, effect, padj | ||||||||
|
|
||||||||
|
|
||||||||
| logging.info("Reading solution file") | ||||||||
| ad_sol = ad.read_h5ad(par["input_test_mod2"]) | ||||||||
|
|
||||||||
| logging.info("Reading prediction file") | ||||||||
| ad_pred = ad.read_h5ad(par["input_prediction"]) | ||||||||
|
|
||||||||
| logging.info("Check prediction format") | ||||||||
| if ad_sol.uns["dataset_id"] != ad_pred.uns["dataset_id"]: | ||||||||
| raise ValueError("Prediction and solution have differing dataset_ids") | ||||||||
|
|
||||||||
| if ad_sol.shape != ad_pred.shape: | ||||||||
| raise ValueError("Dataset and prediction anndata objects should have the same shape / dimensions.") | ||||||||
|
|
||||||||
| # the clusters are defined on the solution and applied to the prediction, so the cells must be paired | ||||||||
| if not (ad_pred.obs_names.equals(ad_sol.obs_names) and ad_pred.var_names.equals(ad_sol.var_names)): | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This crashes with a
Suggested change
With this change a real |
||||||||
| logging.info("Reordering the prediction to the cells and features of the solution") | ||||||||
| ad_pred = ad_pred[ad_sol.obs_names, ad_sol.var_names] | ||||||||
|
|
||||||||
| sol = dense(ad_sol.layers["normalized"]) | ||||||||
| pred = dense(ad_pred.layers["normalized"]) | ||||||||
|
|
||||||||
| # score non-finite predictions as zero, rather than returning a NaN metric | ||||||||
| non_finite = ~np.isfinite(pred) | ||||||||
| if non_finite.any(): | ||||||||
| logging.info("Prediction contains %d non-finite values, scoring them as 0", non_finite.sum()) | ||||||||
| pred[non_finite] = 0 | ||||||||
|
|
||||||||
| modality = ad_sol.uns["modality"] | ||||||||
| effect_threshold = { | ||||||||
| "GEX": par["effect_threshold_gex"], | ||||||||
| "ADT": par["effect_threshold_adt"], | ||||||||
| "ATAC": par["effect_threshold_atac"], | ||||||||
| }[modality] | ||||||||
|
|
||||||||
| logging.info("Clustering the solution") | ||||||||
| # proteins: all of the few features; GEX and ATAC: the highly variable features | ||||||||
| if modality == "ADT" or ad_sol.var["hvg"].sum() < 50: | ||||||||
| var_mask, n_comps = np.ones(ad_sol.n_vars, dtype=bool), 30 | ||||||||
| else: | ||||||||
| var_mask, n_comps = ad_sol.var["hvg"].to_numpy(dtype=bool), 50 | ||||||||
| labels = leiden_groups(sol, var_mask, n_comps) | ||||||||
|
|
||||||||
| # clusters that are too small to test are left out, together with their cells | ||||||||
| sizes = np.bincount(labels) | ||||||||
| groups = np.flatnonzero(sizes >= par["min_group_size"]) | ||||||||
| logging.info("%d clusters, %d with at least %d cells", len(sizes), len(groups), par["min_group_size"]) | ||||||||
| if len(groups) < 2: | ||||||||
| raise ValueError(f"DEG recall needs at least 2 clusters with {par['min_group_size']} cells or more, found {len(groups)}") | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leftover from before the rename:
Suggested change
|
||||||||
|
|
||||||||
| keep = np.isin(labels, groups) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cells of small clusters also get removed from the rest group, while the PR description says each cluster is tested against the remaining cells. On Passing |
||||||||
| codes = pd.Categorical(labels[keep], categories=groups).codes | ||||||||
|
|
||||||||
| logging.info("Computing DEGs of the solution and the prediction") | ||||||||
| z_sol, effect_sol, padj_sol = de_one_vs_rest(sol[keep], codes, len(groups)) | ||||||||
| z_pred, effect_pred, padj_pred = de_one_vs_rest(pred[keep], codes, len(groups)) | ||||||||
|
|
||||||||
| deg_sol = (padj_sol < par["padj_threshold"]) & (effect_sol > effect_threshold) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The p-value is two-sided and the effect is a difference of means, so a feature that is significantly lower by rank but higher on average counts as up-regulated. None of the 31k DEGs in the real solutions do this, so it's only a cheap safeguard, also for the predictions:
Suggested change
|
||||||||
| deg_pred = (padj_pred < par["padj_threshold"]) & (effect_pred > effect_threshold) | ||||||||
|
|
||||||||
| logging.info("Computing DEG F1") | ||||||||
| n_deg_sol = deg_sol.sum(axis=1) | ||||||||
| n_deg_pred = deg_pred.sum(axis=1) | ||||||||
| n_both = (deg_sol & deg_pred).sum(axis=1) | ||||||||
| # 2 * precision * recall / (precision + recall), which is 0 when the prediction calls no DEGs | ||||||||
| f1_per_group = 2 * n_both[n_deg_sol > 0] / (n_deg_sol + n_deg_pred)[n_deg_sol > 0] | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Skipping clusters without ground-truth DEGs means that DEGs a prediction calls in those clusters are never penalised. This happens for 5 clusters across 3 of the 8 test sets, all ADT or ATAC. Is that intended? If so, I'd mention it in the metric description. |
||||||||
| if f1_per_group.size: | ||||||||
| deg_f1 = float(f1_per_group.mean()) | ||||||||
| else: | ||||||||
| logging.warning("No cluster has DEGs in the solution, scoring DEG F1 as 0") | ||||||||
| deg_f1 = 0.0 | ||||||||
|
|
||||||||
| logging.info("Computing Spearman correlation of the DE z-scores") | ||||||||
| spearman_per_group = [] | ||||||||
| for z_s, z_p in zip(z_sol, z_pred): | ||||||||
| # zero-variance z-scores contribute 0 rather than NaN | ||||||||
| if np.ptp(z_s) == 0 or np.ptp(z_p) == 0: | ||||||||
| spearman_per_group.append(0.0) | ||||||||
| else: | ||||||||
| spearman_per_group.append(stats.spearmanr(z_s, z_p)[0]) | ||||||||
| spearman_z = float(np.mean(spearman_per_group)) | ||||||||
|
|
||||||||
| logging.info("Create output object") | ||||||||
| out = ad.AnnData( | ||||||||
| uns = { | ||||||||
| "dataset_id" : ad_pred.uns["dataset_id"], | ||||||||
| "method_id" : ad_pred.uns["method_id"], | ||||||||
| "metric_ids" : ["deg_f1", "spearman_z"], | ||||||||
| "metric_values" : [deg_f1, spearman_z], | ||||||||
| } | ||||||||
| ) | ||||||||
|
|
||||||||
| logging.info("Write output to h5ad file") | ||||||||
| out.write_h5ad(par["output"], compression=9) | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ methods = [ | |
| // construct list of metrics | ||
| metrics = [ | ||
| correlation, | ||
| deg, | ||
| mse | ||
| ] | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.