Files
cellxgene/server/cli/annotate.py
Andrew Tolopko 30e19e47c6 feat: add cli annotation subcommand (#2539)
add `cellxgene annotate` subcommand for invoking MLflow model to generate new `obs` annotations, initially intended for cell type annotations.
2022-07-29 05:15:54 -07:00

232 lines
9.2 KiB
Python

import functools
import json
import os.path
import shlex
import shutil
import subprocess
import sys
from subprocess import STDOUT, PIPE
from tempfile import NamedTemporaryFile
import click
import pandas as pd
from click import BadParameter
from server.annotate.annotation_types import AnnotationType
from server.common.utils.data_locator import DataLocator
from server.common.utils.utils import sort_options
def annotate_args(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@sort_options
@click.command(
short_help="Annotate H5AD file columns. Run `cellxgene annotation --help` for more information.",
options_metavar="<options>",
)
@click.option(
"-i",
"--input-h5ad-file",
required=True,
type=str,
help="The input H5AD file containing the missing annotations.",
)
@click.option(
"-m",
"--model-url",
required=True,
help="The URL of the model used to prediction annotated labels. May be a local filesystem directory "
"or S3 path (s3://)",
)
@click.option(
"-l",
"--counts-layer",
help="If specified, raw counts will be read from the AnnData layer of the specified name. If unspecified, "
"raw counts will be read from `X` matrix, unless 'raw.X' exists, in which case that will be used.",
)
@click.option(
"-g",
"--gene-column-name",
help="The name of the `var` column that contains gene identifiers. The values in this column will be used to match "
"genes between the query and reference datasets. If not specified, the gene identifiers are expected to exist "
"in `var.index`.",
)
# TODO: Useful if we want to support discoverability of models
# @click.option(
# "-r",
# "--model-repository",
# help="The base URL of the model repository. Maybe a local filesystem directory or S3 path (s3://)"
# )
# TODO: Useful if we want to support other, future annotation types, beyond "Cell Type". Currently hidden
@click.option(
"-a",
"--annotation-type",
type=click.Choice([t.value for t in AnnotationType]),
default=AnnotationType.CELL_TYPE.value,
show_default=True,
hidden=True, # Remove if we add support for more annotation types
help="The type of annotation to perform. This model to be used will be inferred from the annotation type.",
)
@click.option(
"-c",
"--annotation-prefix",
type=str,
default="cxg",
show_default=True,
help="An optional prefix used to form the names of: 1) new `obs` annotation columns that will store the predicted "
"annotation values and confidence scores, 2) `obsm` embeddings (reference and umap embedding), and "
"3) `uns` metadata for the prediction operation",
)
@click.option(
"-n",
"--run-name",
type=str,
help="An optional run name that will be used as a suffix to form the names of new `obs` annotation columns that "
"will store the predicted annotation values and confidence scores. This can be used to allow multiple "
"annotation predictions to be run on a single AnnData object.",
)
@click.option(
"-u",
"--update-h5ad-file",
is_flag=True,
help="Flag indicating whether to update the input h5ad file with annotation values. This option is mutually "
"exclusive with --output-h5ad-file.",
)
@click.option(
"-o",
"--output-h5ad-file",
help="The output H5AD file that will contain the generated annotation values. This option is mutually "
"exclusive with --update-h5ad-file.",
)
@click.option("--use-model-cache/--no-use-model-cache", default=True)
@click.option(
"--use-gpu/--no-use-gpu",
default=True,
help="Whether to use a GPU for annotation operations (highly recommended, if available).",
)
# TODO: This is a cell type model-specific arg, so not ideal to specify here as a hardcoded option
@click.option(
"--classifier",
default="default",
help="For cell type annotation, the classifier level to use. The classifier is model-dependent, so refer to "
"documentation for the specified model for valid values.",
)
# TODO: This is a cell type model-specific arg, so not ideal to specify here as a hardcoded option
@click.option(
"--organism",
type=click.Choice(["Homo sapiens", "Mus musculus"], case_sensitive=True),
default="Homo sapiens",
help="For cell type annotation, the organism of the dataset. Used to normalize gene names to HGLC conventions when "
"an annotation model has been trained using data from different organism.",
)
@click.option(
"--model-cache-dir",
default=".models_cache",
help="Local directory used to store model files that are retrieved from a remote location. Model files will "
"be read from this directory first, if they exist, to avoid repeating large downloads.",
)
@click.option(
"--mlflow-env-manager",
type=click.Choice(["virtualenv", "conda", "local"]),
default="virtualenv",
help="Annotation model prediction will be installed and executed in the specified type of environment. MacOS users "
"on Apple Silicon (arm64, M1, M2, etc.) are recommended to use 'conda' to avoid Python package installation "
"errors. If 'conda' is specified then cellxgene must also have been installed within a conda environment",
)
@click.help_option("--help", "-h", help="Show this message and exit.")
def annotate(**cli_args):
_validate_options(cli_args)
print(f"Reading query dataset {cli_args['input_h5ad_file']}...")
annotation_prefix = "_".join(
filter(None, [cli_args.get("annotation_prefix"), cli_args.get("annotation_type"), cli_args.get("run_name")])
)
output_h5ad_file = cli_args["input_h5ad_file"] if cli_args["update_h5ad_file"] else cli_args["output_h5ad_file"]
model_url = cli_args.get("model_url")
local_model_path = _retrieve_model(cli_args.get("model_cache_dir"), model_url, cli_args.get("use_model_cache"))
print(f"Annotating {cli_args.get('input_h5ad_file')} with {cli_args.get('annotation_type')}...")
if cli_args["annotation_type"] == AnnotationType.CELL_TYPE.value:
predict_args = dict(
query_dataset_h5ad_path=cli_args.get("input_h5ad_file"),
output_h5ad_path=output_h5ad_file,
annotation_prefix=annotation_prefix,
counts_layer=cli_args.get("counts_layer"),
gene_column_name=cli_args.get("gene_column_name"),
classifier=cli_args.get("classifier"),
organism=cli_args.get("organism"),
use_gpu=cli_args.get("use_gpu"),
)
# Drop args that have values of `None` as these will cause problems when passing into MLflow predict, since it
# ultimately gets converted into 1-row Pandas DataFrame (None is interpreted as a float type column!)
predict_args = dict([(k, v) for k, v in predict_args.items() if v is not None])
# Invoke prediction using MLflow cli, as a separate process.
# This fully prepares the Python environment that is needed for executing the model.
# The Python environment will be reused after it is setup once.
with NamedTemporaryFile(buffering=0) as predict_args_file:
# write the mlflow predict arguments to a csv file, which will be passed to mlflow cmd
pd.DataFrame([json.dumps(predict_args)]).to_csv(predict_args_file, index=None)
predict_args_file.seek(0)
# run mlflow prediction in subprocess
predict_cmd = (
f"mlflow models predict "
f"--env-manager {cli_args['mlflow_env_manager']} "
f"--model-uri {local_model_path} "
f"--content-type csv --input-path {predict_args_file.name}"
)
p = subprocess.Popen(
args=shlex.split(predict_cmd), stdin=predict_args_file, text=True, bufsize=0, stdout=PIPE, stderr=STDOUT
)
# display mlflow process output as it runs
for line in p.stdout:
print(line.rstrip())
p.wait()
if p.returncode == 0:
print(f"Wrote annotations to {cli_args.get('output_h5ad_file')}")
else:
print("Annotation failed!")
else:
raise BadParameter(f"unknown annotation type {cli_args['annotation_type']}")
def _retrieve_model(model_cache_dir, model_url, use_cache=True):
local_cache_model_path = os.path.join(model_cache_dir, os.path.splitext(os.path.basename(model_url))[0])
if not os.path.exists(local_cache_model_path) or not use_cache:
print(f"Retrieving model from {model_url}")
# download from remote source
with DataLocator(model_url).local_handle() as model_archive_local_path:
# unpack archive to local cache dir
shutil.unpack_archive(model_archive_local_path, local_cache_model_path)
else:
print(f"Using cached model at {local_cache_model_path}")
return local_cache_model_path
def _validate_options(cli_args):
# TODO(atolopko): Use cloup library for this logic
if cli_args["update_h5ad_file"] and cli_args["output_h5ad_file"]:
click.echo("--update_h5ad_file and --output_h5ad_file are mutually exclusive")
sys.exit(1)
if not (cli_args["update_h5ad_file"] or cli_args["output_h5ad_file"]):
click.echo("--update_h5ad_file or --output_h5ad_file must be specified")
sys.exit(1)
if __name__ == "__main__":
annotate()