diff --git a/CHANGELOG.md b/CHANGELOG.md index fb3d361..942cbb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ * Added `spaTrack` method (PR #4). * Added `Spearman's correlation` metric (PR #5). +* Added `Moran's I` metric (PR #6). ## MAJOR CHANGES @@ -15,6 +16,7 @@ ## MINOR CHANGES +* Added package versions to the moran's I config (PR #6). ## BUGFIXES diff --git a/src/metrics/morans_i/config.vsh.yaml b/src/metrics/morans_i/config.vsh.yaml new file mode 100644 index 0000000..f8cb4d4 --- /dev/null +++ b/src/metrics/morans_i/config.vsh.yaml @@ -0,0 +1,84 @@ +__merge__: ../../api/comp_metric.yaml + +name: morans_i + +info: + metrics: + - name: morans_i + label: Moran's I + summary: "Measures the spatial autocorrelation of the inferred pseudotime across the spatial coordinates of the cells/spots." + description: | + Computes Moran's I statistic for the inferred pseudotime, using a k-nearest-neighbour + graph built on the spatial coordinates (`obsm['X_spatial']`) of the cells/spots. + + Moran's I quantifies how similar the pseudotime values of spatially neighbouring + cells/spots are. Values close to 1 indicate a spatially smooth trajectory in which + neighbouring cells receive similar pseudotime values, values around 0 indicate a + spatially random assignment, and negative values indicate that neighbouring cells + receive dissimilar pseudotime values. + + Note that this metric evaluates the spatial coherence of the prediction only and does + not compare it against the ground-truth pseudotime; a spatially smooth but incorrect + ordering can still score highly. + + references: + doi: + - 10.2307/2332142 + bibtex: | + @article{Moran_1950, + author = {Moran, P. A. P.}, + title = {Notes on Continuous Stochastic Phenomena}, + journal = {Biometrika}, + volume = {37}, + number = {1/2}, + pages = {17--23}, + year = {1950}, + doi = {10.2307/2332142} + } + + links: + + documentation: https://scanpy.readthedocs.io/en/stable/api/generated/scanpy.metrics.morans_i.html + + repository: https://github.com/scverse/scanpy + min: -1 + max: 1 + maximize: true + +arguments: + - name: "--n_neighbors" + type: "integer" + default: 6 + description: Number of spatial neighbours used to build the k-NN graph. + +resources: + - type: python_script + path: script.py + + +engines: + - type: docker + image: python:3.11-slim + setup: + - type: apt + packages: + - procps + - git + - type: python + packages: + - anndata~=0.10.9 + - scanpy~=1.10.4 + - numpy~=2.4.6 + - pandas~=3.0.5 + - scipy~=1.17.1 + - pyyaml~=6.0.3 + - requests~=2.34.2 + - jsonschema~=4.26.0 + github: + - "openproblems-bio/core#subdirectory=packages/python/openproblems" + +runners: + - type: executable + - type: nextflow + directives: + label: [midtime,midmem,midcpu] diff --git a/src/metrics/morans_i/script.py b/src/metrics/morans_i/script.py new file mode 100644 index 0000000..c47e404 --- /dev/null +++ b/src/metrics/morans_i/script.py @@ -0,0 +1,78 @@ +import anndata as ad +import numpy as np +import pandas as pd +import scanpy as sc +from scanpy.metrics import morans_i + +## VIASH START +# Note: this section is auto-generated by viash at runtime. To edit it, make changes +# in config.vsh.yaml and then run `viash config inject config.vsh.yaml`. +par = { + 'input_solution': 'resources_test/task_spatial_trajectory_inference/dlpfc_151673/solution.h5ad', + 'input_prediction': 'resources_test/task_spatial_trajectory_inference/dlpfc_151673/prediction.h5ad', + 'output': 'output.h5ad', + 'n_neighbors': 6, +} +meta = { + 'name': 'morans_i' +} +## VIASH END + + +def calc_morans_i(adata, pt_col, coords_key, n_neighbors=6): + """Spatial autocorrelation of pseudotime via Moran's I.""" + try: + sc.pp.neighbors(adata, use_rep=coords_key, n_neighbors=n_neighbors, key_added="spatial_neighbors") + pt = pd.to_numeric(adata.obs[pt_col], errors="coerce").values + + return float(morans_i(adata.obsp["spatial_neighbors_connectivities"], pt)) + except Exception as e: + print(f"Moran's I skipped for {pt_col}: {e}") + return np.nan + +# read input data +print('Reading input files', flush=True) +input_solution = ad.read_h5ad(par['input_solution']) +input_prediction = ad.read_h5ad(par['input_prediction']) + +assert (input_prediction.obs_names == input_solution.obs_names).all(), "obs_names not the same in prediction and solution inputs" + +# inferred pseudotime and spatial coordinates +INFERRED_COL = "pseudotime_inferred" +COORDS_KEY = "X_spatial" + +# spatial coordinates in the solution, the pseudotime in the prediction +adata = ad.AnnData( + obs=pd.DataFrame( + {INFERRED_COL: input_prediction.obs[INFERRED_COL].values}, + index=input_solution.obs_names, + ), + obsm={COORDS_KEY: np.asarray(input_solution.obsm[COORDS_KEY])}, +) + +# generate results +print('Compute metrics', flush=True) +# metric_ids and metric_values can have length > 1 +# but should be of equal length + +score = calc_morans_i(adata, INFERRED_COL, COORDS_KEY, n_neighbors=par['n_neighbors']) + +uns_metric_ids = [ 'morans_i' ] +uns_metric_values = [ score ] + +# Write output data to file +print("Write output AnnData to file...", flush=True) + +output = ad.AnnData( + obs=pd.DataFrame(index=pd.Index(np.array([], dtype=str))), + var=pd.DataFrame(index=pd.Index(np.array([], dtype=str))), + uns={ + 'dataset_id': input_solution.uns.get('dataset_id', 'unknown'), + 'normalization_id': input_solution.uns.get('normalization_id', 'unknown'), + 'method_id': input_prediction.uns.get('method_id', 'unknown'), + 'metric_ids': uns_metric_ids, + 'metric_values': uns_metric_values, + } +) + +output.write_h5ad(par['output'], compression='gzip')